Skip to content

Binding to Async<'T> in a task CE uses StartAsTask, which involves a context switch #14490

@abelbraaksma

Description

@abelbraaksma

This was pointed out to me by @bartelink while working on TaskSeq, separately reported in this issue: fsprojects/FSharp.Control.TaskSeq#135, where I took the same approach as task.

The implementation for binding to an Async<'T> in the task computation expression builder is as follows:

member inline this.Bind(computation: Async<'TResult1>, continuation: ('TResult1 -> TaskCode<'TOverall, 'TResult2>)) : TaskCode<'TOverall, 'TResult2> =
    this.Bind(Async.StartAsTask computation, continuation)

Per the discussion here, its points by @gusty, and specifically this answer (#11043 (comment)) by @dsyme, shows that this is likely not how it should behave. I quote:

no implementation of let! ... and! ... should introduce calls to Async.StartChild, Async.Start or Async.Parallel - all of which start queue work in the thread pool. These calls must always be explicit. To be honest I feel it would be better if all of these had names like Async.StartChildInThreadPool, Async.StartInThreadPool and Async.ParallelInThreadPool. ANy introduction of the thread pool should be explicit

If it doesn't apply to let! ... and!..., it certainly shouldn't apply to let! in isolation. The method Async.StartAsTask forces a context switch and by above's analogy is essentially Async.StartInThreadpoolAsTask.

To bring Don's (and @gusty's in that thread) point home: we should be explicit and opt-in to parallelism or context switches. Here it's the opposite, we have to explicitly opt-out.

While this doesn't introduce parallelism, it may have subtle behavior related to side effects or updating mutables and the like.

TLDR: we should switch to Async.StartImmediateAsTask (if, hopefully, this doesn't introduce a backward compat issue we cannot come back from).

Repro steps

let currentBehavior() =
    let t = task {
        let a = Thread.CurrentThread.ManagedThreadId
        let! b = async {
            return Thread.CurrentThread.ManagedThreadId
        }
        let c = Thread.CurrentThread.ManagedThreadId
        return $"Before: {a}, in async: {b}, after async: {c}"
    }
    let d = Thread.CurrentThread.ManagedThreadId
    $"{t.Result}, after task: {d}"
    
let expectedBehavior() =
    let t = task {
        let a = Thread.CurrentThread.ManagedThreadId
        let! b =
            async {
                return Thread.CurrentThread.ManagedThreadId
            }
            |> Async.StartImmediateAsTask

        let c = Thread.CurrentThread.ManagedThreadId
        return $"Before: {a}, in async: {b}, after async: {c}"
    }
    let d = Thread.CurrentThread.ManagedThreadId
    $"{t.Result}, after task: {d}"

Expected behavior

No thread switch takes place. It should print "Before: 1, in async: 1, after: 1, after task: 1" in both cases.

Actual behavior

Two (!) extra thread switches take place. It actually prints this:

> currentBehavior();;
val it: string = "Before: 1, in async: 3, after async: 3, after task: 1"  // not good

> expectedBehavior();;
val it: string = "Before: 1, in async: 1, after async: 1, after task: 1"  // good

Known workarounds

Explicitly use Async.StartImmedateAsTask.

PS: this also applies to backgroundTask, perhaps even more so, as that already involves a context switch, so there's even less reason to add another context switch on top of it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugImpact-Low(Internal MS Team use only) Describes an issue with limited impact on existing code.

    Type

    No type

    Projects

    Status

    Done

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions