Why does Async.StartChild return `Async<Async<'T>>`?

Viewed 125

I'm very new to F# and I've been reading F# for Fun and Profit. In the Why use F#? series, there's a post describing async code. I ran across the Async.StartChild function and I don't understand why the return value is what it is.

The example:

let sleepWorkflow  = async {
    printfn "Starting sleep workflow at %O" DateTime.Now.TimeOfDay
    do! Async.Sleep 2000
    printfn "Finished sleep workflow at %O" DateTime.Now.TimeOfDay
}

let nestedWorkflow  = async {

    printfn "Starting parent"
    let! childWorkflow = Async.StartChild sleepWorkflow

    // give the child a chance and then keep working
    do! Async.Sleep 100
    printfn "Doing something useful while waiting "

    // block on the child
    let! result = childWorkflow

    // done
    printfn "Finished parent"
}

My question is why shouldn't Async.StartChild just return Async<'T> instead of Async<Async<'T>>? You have to use let! twice on it. The documentation even states:

This method should normally be used as the immediate right-hand-side of a let! binding in an F# asynchronous workflow [...] When used in this way, each use of StartChild starts an instance of childComputation and returns a completor object representing a computation to wait for the completion of the operation. When executed, the completor awaits the completion of childComputation.

In some testing, adding some calls to sleep, it seems that without the initial let! the child computation is never started.

Why have this return type / behavior? I'm used to C# where calling an async method will always "kick off" the task right away even if you don't await it. In fact, in C#, if the async method doesn't call any async code it will run synchronously.

Edit for clarification:

What is the advantage of this:

let! waiter = Async.StartChild otherComp // Start computation
// ...
let! result = waiter // Block

Compared to if Async.StartChild returned an Async<'T>:

let waiter = Async.StartChild otherComp // Start computation
// ...
let !result = waiter // Block
2 Answers

The idea is this: You start another async-computation (in the background) with let wait = Async.StartChild otherComp and you get the waiter back.

What that means is that let! result = waiter will block and wait for the result of your background-computation whenever you want.

if Async.StartChild would return a Async<'t> you would wait with let! x = otherComp right there and it would be just like a normal let! result = otherComp`


And yes F# Async-Workflows will only start once you do something like Async.Start... or Async.RunSynchronously (it's not like a Task that usually runs as soon as you created it)

that's why in C# you can create a Task (var task = CreateMyTask()) at one point (that would be the Async.StartChild part) and then later use var result = await task to wait there for the result (that's the let! result = waiter part).


Why Async.StartChild returns Async<Async<'T>> instead of Async<'T>

This is because the workflows started this way is supposed to behave like a child-task/process. When you cancel the containing workflow the child should be canceled as well.

So on a technical level the child-workflow needs access to the cancelation-token without you passing it explicitly and that is one thing the Async-Type handles in the background for you when you use Bind (aka let! here).

So it has to be this type in order for that passing of the cancelation-token to work.

I've been thinking about this a bit and can't come up with a solid explanation. In fact, as a proof of concept, I was able to write a crude version of StartChild that has the behavior you want:

let myStartChild computation =

    let mutable resultOpt = None
    let handle = new ManualResetEvent(false)

    async {
        let! result = computation   // run the computation
        resultOpt <- Some result    // store the result
        handle.Set() |> ignore      // signal that the computation has completed
    } |> Async.Start

    async {
        handle.WaitOne() |> ignore   // wait for the signal
        handle.Dispose()             // cleanup
        return resultOpt.Value       // return the result
    }

I wrote a basic smoke test and it seems to work fine, so either I'm overlooking something important (maybe having to do with cancelation tokens?), or the answer to your question is that it doesn't have to be that way.

I'm not an Async expert by any means, so I'd love for someone with more knowledge than me to chime in.

Update: Based on Carsten's updated answer, I think we have a complete explanation: You can either:

  • Have the signature you want, but without cancelation support, or
  • Use the standard Async<Async<'T>> signature if you need cancelation.

The second version is more flexible, which is why it's in the standard library.

Related