feat(Async): RunSynchronouslyImmediate#19804
Conversation
❗ Release notes requiredYou can open this PR in browser to add release notes: open in github.dev
|
d6a0470 to
69e41a2
Compare
There was a problem hiding this comment.
Pull request overview
Adds Async.RunSynchronouslyImmediate to FSharp.Core to allow running an Async<'T> synchronously while always executing the initial step on the calling thread (aimed at improved diagnostics/stack traces in FSI/tests), with accompanying API surface updates, documentation, unit tests, and release notes.
Changes:
- Add public API
Async.RunSynchronouslyImmediateand wire it through Async primitives. - Add unit tests characterizing basic behavior and key differences vs
Async.RunSynchronously. - Update FSharp.Core surface area baselines and release notes; extend XML docs for
RunSynchronously/RunSynchronouslyImmediate.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModule.fs | Adds unit tests for RunSynchronouslyImmediate and contrasts with RunSynchronously. |
| tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl | Records new public surface area entry for RunSynchronouslyImmediate. |
| tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl | Records new public surface area entry for RunSynchronouslyImmediate. |
| tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl | Records new public surface area entry for RunSynchronouslyImmediate. |
| tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl | Records new public surface area entry for RunSynchronouslyImmediate. |
| src/FSharp.Core/async.fsi | Adds XML docs for the new API and revises RunSynchronously docs to reference it. |
| src/FSharp.Core/async.fs | Implements the new API by exposing an “immediate” synchronous runner and refactoring RunSynchronously internals. |
| docs/release-notes/.FSharp.Core/11.0.100.md | Adds release note entry for Async.RunSynchronouslyImmediate. |
Comments suppressed due to low confidence (1)
tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModule.fs:527
- This test uses a raw Thread but doesn’t capture exceptions from the thread body. Any unexpected exception inside the thread (including from Async.RunSynchronously) will be unhandled and may crash the test process rather than reporting a normal xUnit failure. Capture exceptions in the thread and rethrow/assert after Join.
let t = Thread(fun () ->
callerThreadId <- Thread.CurrentThread.ManagedThreadId
async { runSyncThreadId <- Thread.CurrentThread.ManagedThreadId }
|> Async.RunSynchronously
async { immThreadId <- Thread.CurrentThread.ManagedThreadId }
|> Async.RunSynchronouslyImmediate)
|
@T-Gro I've implemented as per OP of fsharp/fslang-suggestions#1042 Firstly, in a debugger context a first chance exception breakpoint has more direct causation vs having to disentangle an adjacent thread waiting, which tooling may or may not be able to convey unaided. However, while the exception stack trace has less noise, it's not significantly better AFAICT:
It seems the TL;DR value prop is:
But its not a slam dunk as:
This is making me think I should dial back the xmldoc from where I have it trying to set a "use the new RunSynchronouslyImmediate in FSI from now on" vibe (as I've tried to do with AwaitTask vs Await where it is a slam dunk) |
75fed30 to
f9e0383
Compare
| /// Run the asynchronous workflow and wait for its result. | ||
| [<DebuggerHidden>] | ||
| let QueueAsyncAndWaitForResultSynchronously (token: CancellationToken) computation timeout = | ||
| let QueueAsyncAndWaitForResultSynchronously computation (token: CancellationToken) timeout = |
There was a problem hiding this comment.
can revert this, but trying to get arg order consistent across most involved functions
| trampolineHolder | ||
| (fun res -> resultCell.RegisterResult(AsyncResult.Ok res, reuseThread = true)) | ||
| (fun edi -> resultCell.RegisterResult(AsyncResult.Error edi, reuseThread = true)) | ||
| (fun exn -> resultCell.RegisterResult(AsyncResult.Canceled exn, reuseThread = true)) |
There was a problem hiding this comment.
old behavior resulting in OperationCanceledException
There was a problem hiding this comment.
does it change RunSynchronously implementation?
There was a problem hiding this comment.
@majocha in the PR as it stands, the core impl of RunSynchronouslyImmediate is:
https://github.com/bartelink/fsharp/blob/013acc1fca33bdfcd6f978d531bc0c6e1e34c199/src/FSharp.Core/async.fs#L1157
- that new code is based on OP of Add Async.RunImmediate fsharp/fslang-suggestions#1042 (comment)
- RunSynchronously used to delegate to https://github.com/dotnet/fsharp/pull/19804/changes#diff-7045116fe4610c3375ffcdd7df2ee411e71879bdf25890c8d3cf047c1b5208e2L1141 where conditions allow (no thread hop required)
- RunSynchronously now delegates to 1
The key diff is that:
- the old way with the ResultCell Commit would result in
OperationCanceledExceptionbeing observed by caller if cancellation happened during running - the new way is
tcs.Task.GetAwaiter().GetResult(), which results in aTaskCanceledException(which, to the best of my knowledge does not provide a way to wire in the CT info)
The pros from my perspective are:
- RSI is simple and direct and matches the OP, without coupling to
ResultCell - if/when we ever get to split RunSynchronously into orthogonal easy to explain concepts and/or methods then those impls will tie to more ubiquitous things like
TaskCompletionSource - nobody needs to debug or reason about actual RSI vs the RSI-but-with-ResultCell-via-RS differences
Not an easy call, which is why I'm looking for someone else to vouch for it!
| computation | ||
| |> unfake | ||
|
|
||
| [<DebuggerHidden>] |
There was a problem hiding this comment.
moved up as required by RunSynchronously
note the whole PR could be made much shorter by having the Async layer call Async.FromContinuations
However, as mentioned in other places, I believe its for the best that RunSynchronouslyImmediate and the immediate path of RunSychronously should have strong clear ties in the lower layer as this PR does
|
The open review comments and Don's comment here fsharp/fslang-suggestions#1042 (comment) make me think:
In general I think one PR that adds one API is a perfectly fine outcome for now, but if someone out there (@dsyme ?) has a really clear picture of all the use cases, it may be worth at least speccing out in full the orthogonal elements of what only the current impl of In short, if someone is convinced that RSI + RSB and/or some complementary APIs makes it possible to deprecate and eventually move away from from a complected |
|
FWIW my limited experience with internal |
|
@bartelink : And consider marking it obsolete later in a separate PR. What RunSynchronously provides is the automated decision-making based on inputs+context. Even though it can be handy when writing library code (that does not know where/how it will be run), it does go against the "Pit of success" spirit. |
Will double check we have an xmldoc cross reference (think we do already) I don't think it's practical to actually make Let me know if there's anything else to do to get this completely ready for whenever merge makes sense.
See also fsharp/fslang-suggestions#1042 (comment) - maybe a pathway can be that there is a camelCase Async.* helper that uses RSI and shouts "pick me" for fsi/tests cases? |
013acc1 to
5074cab
Compare
331b0b6 to
71c8638
Compare
71c8638 to
d1c658a
Compare
| (fun edi -> tcs.SetException edi.SourceException) | ||
| // NOTE In this case, cancellation will surface as a TaskCanceledException (with CT.None) from GetResult() | ||
| // (as opposed to the OperationCanceledException that RegisterResult cancellation ends up mapping to) | ||
| (fun _operationCanceledExn -> tcs.SetCanceled()) |
There was a problem hiding this comment.
This ccont also backs RunSynchronously's inline fast-path (L1175), so the token gets stripped there:
try Async.RunSynchronously(comp, cancellationToken = myTok)
with
| :? OperationCanceledException as e when e.CancellationToken = myTok -> fallback
| :? OperationCanceledException -> reraise ()- inline (thread-pool caller): token stripped ->
reraise()-- wrong branch - queued (other caller): token kept ->
fallback - same call, thread-dependent
- (fun _operationCanceledExn -> tcs.SetCanceled())
+ (fun operationCanceledExn -> tcs.SetException operationCanceledExn)Then the test at AsyncModule.fs:470 flips: Assert.Throws<TaskCanceledException> -> Assert.Throws<OperationCanceledException>.
- this leaves the internal
tcs.TaskFaultednotCanceled— invisible here (immediatelyGetResult()'d, only the thrown exception escapes) SetCanceled(token)(keeps both) is .NET 5+, absent on ns2.0/2.1, soSetExceptionis the only token-preserving option
There was a problem hiding this comment.
applied this change
See also clones in 8422a23 (all call sites identical, though obviously there's potential for effects from the breaking nature of the change)
see also related comment thread #19804 (comment)
There was a problem hiding this comment.
(All impls of RSI, and the RS shortcut path are now identical, and the test documenting the behavior change has become a pinning test validating that it's OperationCanceledException with the triggering token)
| open Xunit | ||
| open FSharp.Test.Utilities | ||
|
|
||
| #if !FSHARPCORE_USE_PACKAGE // TODO For 11.x, remove shimming to rely fully on shipped FSharp.Core variant |
There was a problem hiding this comment.
Guard is inverted (illib.fs defines the shim when the package lacks the member).
async { return 42 } |> Async.RunSynchronouslyImmediate // FSHARPCORE_USE_PACKAGE=true (BUILDING_USING_DOTNET default)- package = shipped 10.0.101, no such member
- shim also excluded by the
!->FS0039 - green CI only builds the in-repo config
-#if !FSHARPCORE_USE_PACKAGE
+#if FSHARPCORE_USE_PACKAGEThere was a problem hiding this comment.
applied this change but it breaks MacOS build so have temporarily reinstated
There was a problem hiding this comment.
@T-Gro I have not managed to get to the bottom of this as to be honest I don't understand the full array of build scenarios.
(Not saying you don't have a point; it's just that in practice my programming-by-coincidence had a purpose!)
Bottom line is I applied it and it broke the build, and reverting it made it green
I'm happy for you to either try to resolve it by working in this branch or providing me some more suggestions
| // This file mimics how Roslyn handles their compilation references for compilation testing | ||
| module Utilities = | ||
|
|
||
| #if !FSHARPCORE_USE_PACKAGE // TODO For 11.x, remove shimming to rely fully on shipped FSharp.Core variant |
There was a problem hiding this comment.
Same inverted guard as Common.fs:19 -> breaks package-mode build (FSHARPCORE_USE_PACKAGE=true, the BUILDING_USING_DOTNET default) with FS0039.
-#if !FSHARPCORE_USE_PACKAGE
+#if FSHARPCORE_USE_PACKAGEThere was a problem hiding this comment.
Applied and then reverted as per common.fs:19
| /// | ||
| /// The timeout parameter is given in milliseconds. A value of -1 is equivalent to | ||
| /// <see cref="F:System.Threading.Timeout.Infinite"/>. | ||
| /// <summary><p>Runs the asynchronous computation on a threadpool thread, honoring the ambient |
There was a problem hiding this comment.
SynchronizationContext.SetSynchronizationContext ctx
async { return 1 } |> Async.RunSynchronously // ctx.Post called 0 times; runs inline- "on a threadpool thread" -- false (fast-path is inline)
- "honoring the ambient SynchronizationContext" -- false (never posts)
- /// <summary><p>Runs the asynchronous computation on a threadpool thread, honoring the ambient
- /// <see cref="T:System.Threading.SynchronizationContext"/>.</p>
+ /// <summary><p>Runs the computation and blocks the caller until it completes. Runs inline on the
+ /// calling thread when it is a thread-pool thread with no ambient SynchronizationContext and no
+ /// timeout; otherwise runs on the thread pool.</p>There was a problem hiding this comment.
Your wording feels much better (I was aware that my wording was technically not 100% correct - my aim was to emphasize the core difference vs RSI: even if you are doing something tiny, you'll incur a thread hop more often than you might expect from the name).
Also wanted to be pedantic on some specifics:
on a threadpool thread" -- false (fast-path is inline)
but fast path requires you to already be on a thread pool thread. So RunSynchronously didnt need to do a hop, but the code is run on a thread pool thread?
"honoring the ambient SynchronizationContext" -- false (never posts)
I think this is due to special casing triggered by the reuseThread = true parameter to RegisterResult (async.fs: 1004)
But I may well have missed the point you're trying to make.
Happy to apply your summary assuming all the above tallies with your understanding/intent.
| /// </p> | ||
| /// <p>Does not support a timeout; see | ||
| /// <see cref="M:Microsoft.FSharp.Control.FSharpAsync.RunSynchronously`1"/> if one is desired.</p> | ||
| /// <p>Does not ensure execution takes place on a threadpool thread; see |
There was a problem hiding this comment.
async { return 1 } |> Async.RunSynchronously // also runs inline -- not a threadpool guarantee- cross-ref to
RunSynchronouslyhere implies a threadpool guarantee it doesn't provide
- /// <p>Does not ensure execution takes place on a threadpool thread; see
- /// <see cref="M:Microsoft.FSharp.Control.FSharpAsync.RunSynchronously`1"/> or
- /// <see cref="M:Microsoft.FSharp.Control.FSharpAsync.SwitchToThreadPool"/> if this is required.</p>
+ /// <p>Does not ensure execution takes place on a threadpool thread; see
+ /// <see cref="M:Microsoft.FSharp.Control.FSharpAsync.SwitchToThreadPool"/> if this is required.</p>There was a problem hiding this comment.
The key driver for me referring to both was to try to explain the key behavioral difference of consequence between RS and RSI in terms of other concrete methods as a pedagogical mechanism.
AIUI (but I may very well be wrong) RunSynchronously does provide this guarantee, as which:
- stays on current thread until the first asynchronous suspension if all these things are true:
- the current thread is a threadpool thread
- and
SynchronizationContext.Currentisnull - and we're not trying to apply a timeout
- in all other cases it posts the work into the thread pool and uses the calling thread to await
Note I renamed the internal impl to RunSynchronouslyBackgroundThreadPool to try to convey the above semantics in shorthand.
All of that said, clearly my wording didn't get the point across as intended.
Hence I feel it may make more sense to reword it and/or remove it entirely as the proposed change turns it into a non-sequitur as the sentence no longer enables people to triangulate/bridge the concepts as intended?
|
Sorry for the late replies. Decisions:
It's internal-only — replace its single call site with
Third name is too much here, let's avoid it. Ship RSI alone, RS unchanged + "see also". Merge-ready once the two blockers in my review land (inverted |
|
@T-Gro I believe this is ready for re-review; feel free to mark comment threads as resolved as you read them. Happy to address any follow-ups later and/or tomorrow |


Implements
RunSynchronouslyImmediateper fsharp/fslang-suggestions#1042See also fsharp/fslang-suggestions#1467
Heavily revises the xmldoc for
RunSynchronouslyin order to convey the tradeoffs involved in selecting between the new and the old.Updated

RunSynchronously:NEW

RunSynchronouslyImmediate:NOTE xmldocs are intended to convey a nuanced message as explained in detail in #19804 (comment):
AsyncResult.CommitandQueueAsyncAndWaitForResultSynchronouslyChecklist
Async.RunSynchronously