Mikhail Brinchuk
Mikhail Brinchuk

Reputation: 666

Async.StartChild with timeout and sync wait inside of child async

Consider the following code:

open System
open System.Diagnostics
open System.Threading
open System.Threading.Tasks

type Async with
    static member WithTimeout (timeout: int) operation =
        async {
            let! child = Async.StartChild (operation, timeout)
            try
                let! _result = child
                return true
            with :? TimeoutException -> return false
        }

    static member WithTaskTimeout<'T> (timeout: int) (operation: Async<'T>) = async {
        let delay = Task.Delay(timeout)

        let! task = Task.WhenAny(operation |> Async.StartAsTask :> Task, delay) |> Async.AwaitTask
        if task = delay then
            return false
        else
            return true
    }

[<EntryPoint>]
let main _ =
    let syncSleep = async {
        Thread.Sleep(4000)
        return 1
    }

    let asyncSleep = async {
        do! Async.Sleep(4000)
        return 1
    }

    let run name async =
        let time action prefix =
            let sw = Stopwatch.StartNew()
            let result = action |> Async.RunSynchronously
            sw.Stop()

            printfn "%s | %s returned %O. Elapsed: %O" prefix name result sw.Elapsed

        time (async |> Async.WithTimeout 2000) "Async"
        time (async |> Async.WithTaskTimeout 2000) "Task "

    run "Thread.Sleep" syncSleep
    run "Async.Sleep " asyncSleep

    0

On Mono 5.18.1.3 it produces the following output:

Async | Thread.Sleep returned False. Elapsed: 00:00:04
Task  | Thread.Sleep returned False. Elapsed: 00:00:02
Async | Async.Sleep  returned False. Elapsed: 00:00:02
Task  | Async.Sleep  returned False. Elapsed: 00:00:02

So when child async has synchronous wait inside, Async.StartChild returns not when timeout passes, but when the inner async comes to a completion. At the same time task-based execute with timeout in both calls returns just only after timeout.

Why Async.StartChild behaves this way?

Upvotes: 2

Views: 218

Answers (1)

scrwtp
scrwtp

Reputation: 13577

The way timeouts are handled in async workflows is through cancellations. What happens in your scenario with a blocking wait is that the first possible moment to check for the cancellation is after the wait completes.

Async workflows use a model called cooperative cancellation, which means that the caller and the callee cooperate on handling the cancellation through an intermediary CancellationToken. When Async.StartChild needs to cancel the child workflow, it will request a cancellation on the token, and then it falls to the child workflow to check the state of the cancellation token and call the cancellation continuation. Those checks are baked into async primitives, look for IsCancellationRequested here.

Since your child workflow is blocked on Thread.Sleep, this wil not happen until the sleep completes.

Worth noting that the same model is used by TPL tasks. You just don't see it because your task timeout depends on the semantics of Task.WhenAny - and those might not be what you expect w.r.t. the remaining running tasks.

Upvotes: 1

Related Questions