Binding to `Async<'T>` in a `task` CE uses StartAsTask, which involves a context switch
See original GitHub issueThis was pointed out to me by @bartelink while working on TaskSeq
, separately reported in this issue: https://github.com/fsprojects/FSharp.Control.TaskSeq/issues/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 (https://github.com/dotnet/fsharp/discussions/11043#discussioncomment-345815) by @dsyme, shows that this is likely not how it should behave. I quote:
no implementation of
let! ... and! ..
. should introduce calls toAsync.StartChild
,Async.Start
orAsync.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 likeAsync.StartChildInThreadPool
,Async.StartInThreadPool
andAsync.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.
Issue Analytics
- State:
- Created 9 months ago
- Reactions:5
- Comments:11 (9 by maintainers)
Top GitHub Comments
Isn’t this what is wanted in the end? I must have overlooked this before. https://github.com/fsharp/fslang-suggestions/issues/1042
It is already implemented in the compiler’s internal test suite, could be moved to Fsharp.Core: https://github.com/dotnet/fsharp/pull/11788/files#diff-0b39085aa15d27c80662a1386282d6c4a9d2e8747b3124024d755bb96f9094f6
Async.RunSynchronously has analogies to the backgroundTask{} in that behaviour, and I believe it was a good intention to prevent hangs when using it in UI applications.
Due to the amount of code using it, I do not think we can turn the behavior around. We could introduce a new variant of it that would not switch to a background thread, and name it differently.