Wrapping a task in an async with a timeout?

44 views Asked by At

I would like to run a function in a background thread, then consume the result with a timeout from an async context.

Here is my code:

open System.Threading.Tasks

let forever () =
  while true do
    ()
  123

async {
  printfn "Computing..."

  let! ct = Async.CancellationToken

  let! c =
    Task.Run(forever, ct)
    |> Async.AwaitTask
    |> fun t -> Async.StartChild(t, millisecondsTimeout = 1_000)

  let! x = c

  printfn $"%i{x}"
}
|> Async.RunSynchronously

I would expect this to raise an exception after 1 second, but it hangs forever.

How can I fix this?

1

There are 1 answers

3
Ruben Bartelink On BEST ANSWER

This smells of https://xyproblem.info hence I'd suggest posting a followup that is more representative of your overall needs (i.e. to what degree are you mixing Task and Async in your real problem; are you trying to make a general helper, or just trying to understand things)

async expressions check for cancellation of the ambient CT at wait points (i.e. a let! etc). task expressions (and Task stuff in general) has no such semantics

  • your forever cannot be cancelled in any meaningful way as it is written as
    • you did not pass it a CancellationToken
    • you are hence not checking/able to check for a .IsCancellationTokenRequested to trigger the end of the loop

If you'll allow some soapboxing, the high level conclusion of all this is:

  1. if you opt to do task stuff ANYWHERE, you should flow cancellation tokens to make yourself think and consider all the cases
  2. and that makes business level code horrendous
  3. So all app code should be async {, with brief call-outs to Task stuff as required, but always passing a CT
  4. People who say "oh great F# has task in the box now, we no longer need to pay the allocation and slight perf rax of Async are very mistaken - you can't afford for task to leak into your code. If the async { Builder ever gets rewritten to take advantage of the resumable state machine mechanisms that task uses, then the overhead drops more. Some of that future is already being explored in https://github.com/TheAngryByrd/IcedTasks etc

You have multiple style issues in your snippet which don't make it easy to five a succinct answer:

  • piping Async.AwaitTask and Async.StartChild makes things very confusing. Don't do that. If you stop doing that, it will be even more obvious what's wrong (you are starting the task and waiting for it to complete before any StartChild could possibly enter the picture)
  • cancellation in Async etc (but also Task) is 100% co-operative (and I don't see you clearly communicating you get that point), and the in the box stuff never abandons a wait on a task or async execution (except things like Async.StartAsTask etc. Highly recommend multiple reads of https://learn.microsoft.com/en-us/dotnet/fsharp/tutorials/async

Helper for doing this inside a task expr:

/// Runs an inner task with a dedicated Linked Token Source. Cancels via the ct upon completion, before Disposing the LCTS
let inline runWithCancellation (ct: CancellationToken) ([<InlineIfLambda>]f: CancellationToken -> Task) = task {
    use cts = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct) // https://stackoverflow.com/questions/6960520/when-to-dispose-cancellationtokensource
    try do! f cts.Token
    finally cts.Cancel() }

It's consumed in the Propulsion project in various ways


An alternate approach (closer to what you're trying) is something like: (source)

module Async =

    /// Wraps a computation, cancelling (and triggering a timeout exception) if it doesn't complete within the specified timeout
    let timeoutAfter (timeout: TimeSpan) (c: Async<'a>) = async {
        let! r = Async.StartChild(c, millisecondsTimeout = int timeout.TotalMilliseconds)
        return! r }

Related Questions in F#

Related Questions in F#-ASYNC