-
Notifications
You must be signed in to change notification settings - Fork 831
Description
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 toAsync.StartChild,Async.StartorAsync.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.StartInThreadPoolandAsync.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" // goodKnown 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
Labels
Type
Projects
Status