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.
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 astask.The implementation for binding to an
Async<'T>in thetaskcomputation expression builder is as follows: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:
If it doesn't apply to
let! ... and!..., it certainly shouldn't apply tolet!in isolation. The methodAsync.StartAsTaskforces a context switch and by above's analogy is essentiallyAsync.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
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:
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.