diff --git a/docs/release-notes/.FSharp.Core/11.0.100.md b/docs/release-notes/.FSharp.Core/11.0.100.md index 70bbaae06fe..f1844d27b0e 100644 --- a/docs/release-notes/.FSharp.Core/11.0.100.md +++ b/docs/release-notes/.FSharp.Core/11.0.100.md @@ -2,3 +2,7 @@ * Fix `Array.exists2` documentation examples to use equal-length arrays; the previous examples would throw `ArgumentException` at runtime instead of returning the documented `false`/`true` values. ([PR #19672](https://github.com/dotnet/fsharp/pull/19672)) * Move `Async.StartChild` to the "Starting Async Computations" docs category alongside `Async.StartChildAsTask`. ([Issue #19667](https://github.com/dotnet/fsharp/issues/19667)) + +### Added + +* Add `Async.RunSynchronouslyImmediate` which always starts on the current thread without a thread-pool hop, unlike `Async.RunSynchronously`. ([Issue #1042](https://github.com/fsharp/fslang-suggestions/issues/1042), [PR #19804](https://github.com/dotnet/fsharp/pull/19804)) diff --git a/src/FSharp.Core/async.fs b/src/FSharp.Core/async.fs index f18e451f357..c34eb3e6988 100644 --- a/src/FSharp.Core/async.fs +++ b/src/FSharp.Core/async.fs @@ -1096,7 +1096,7 @@ module AsyncPrimitives = /// Run the asynchronous workflow and wait for its result. [] - let QueueAsyncAndWaitForResultSynchronously (token: CancellationToken) computation timeout = + let QueueAsyncAndWaitForResultSynchronously computation (token: CancellationToken) timeout = let token, innerCTS = // If timeout is provided, we govern the async by our own CTS, to cancel // when execution times out. Otherwise, the user-supplied token governs the async. @@ -1138,7 +1138,7 @@ module AsyncPrimitives = res.Commit() [] - let RunImmediate (cancellationToken: CancellationToken) computation = + let RunSynchronouslyImmediate computation (cancellationToken: CancellationToken) = use resultCell = new ResultCell>() let trampolineHolder = TrampolineHolder() @@ -1158,11 +1158,11 @@ module AsyncPrimitives = res.Commit() [] - let RunSynchronously cancellationToken (computation: Async<'T>) timeout = - // Reuse the current ThreadPool thread if possible. + let RunSynchronouslyBackgroundThreadPool (computation: Async<'T>) cancellationToken timeout = + // Run inline only where it's guaranteed to be safe match SynchronizationContext.Current, Thread.CurrentThread.IsThreadPoolThread, timeout with - | null, true, None -> RunImmediate cancellationToken computation - | _ -> QueueAsyncAndWaitForResultSynchronously cancellationToken computation timeout + | null, true, None -> RunSynchronouslyImmediate computation cancellationToken // best stacktrace in case of exception + | _ -> QueueAsyncAndWaitForResultSynchronously computation cancellationToken timeout // less useful stack traces [] let Start cancellationToken (computation: Async) = @@ -1511,7 +1511,13 @@ type Async = | Some token when not token.CanBeCanceled -> timeout, token | Some token -> None, token - RunSynchronously cancellationToken computation timeout + RunSynchronouslyBackgroundThreadPool computation cancellationToken timeout + + static member RunSynchronouslyImmediate(computation: Async<'T>, ?cancellationToken: CancellationToken) = + let cancellationToken = + defaultArg cancellationToken defaultCancellationTokenSource.Token + + RunSynchronouslyImmediate computation cancellationToken static member Start(computation, ?cancellationToken) = let cancellationToken = diff --git a/src/FSharp.Core/async.fsi b/src/FSharp.Core/async.fsi index b2fe66ddd13..e48359199db 100644 --- a/src/FSharp.Core/async.fsi +++ b/src/FSharp.Core/async.fsi @@ -47,28 +47,27 @@ namespace Microsoft.FSharp.Control [] type Async = - /// Runs the asynchronous computation and await its result. - /// - /// If an exception occurs in the asynchronous computation then an exception is re-raised by this - /// function. - /// - /// If no cancellation token is provided then the default cancellation token is used. - /// - /// The computation is started on the current thread if is null, - /// has - /// of true, and no timeout is specified. Otherwise the computation is started by queueing a new work item in the thread pool, - /// and the current thread is blocked awaiting the completion of the computation. - /// - /// The timeout parameter is given in milliseconds. A value of -1 is equivalent to - /// . + ///

Runs the asynchronous computation in a background context threadpool thread and awaits its result, + /// blocking the calling thread.

Any exception raised by the computation is propagated to the caller, with a potentially truncated stack-trace.

+ /// + ///

The computation runs on the current thread when + /// is null, + /// is true, and no timeout is specified. + /// Otherwise — which includes F# interactive sessions and GUI threads — it is offloaded to the thread + /// pool while the calling thread blocks; in that case exception stack traces will be incomplete, + /// showing only thread-pool frames and omitting the caller's frame.

+ ///

For F# interactive, F# scripts, and unit tests where complete exception stack traces are desired, + /// prefer , which + /// always starts on the calling thread. (Note that overload does not support a timeout, and blocking the foreground thread can lead to deadlock). + ///

///
/// /// The computation to run. - /// The amount of time in milliseconds to wait for the result of the - /// computation before raising a . If no value is provided - /// for timeout then a default of -1 is used to correspond to . + /// The number of milliseconds to wait for the result of the + /// computation before raising a . If no value or -1 is provided + /// the timeout will be . /// The cancellation token to be associated with the computation. - /// If one is not supplied, the default cancellation token is used. + /// If omitted, Async.DefaultCancellationToken is used. /// /// The result of the computation. /// @@ -87,10 +86,58 @@ namespace Microsoft.FSharp.Control /// /// printfn "D" /// - /// Prints "A", "B" immediately, then "C", "D" in 1 second. result is set to 17. + /// Prints "A", "B" immediately, then "C", "D" in 1 second. Yields result = 17. /// static member RunSynchronously : computation:Async<'T> * ?timeout : int * ?cancellationToken:CancellationToken-> 'T - + + ///

Runs the asynchronous computation synchronously, always starting and blocking on the + /// calling thread regardless of being non- + /// null or being false.

+ ///

Any exception raised by the computation is propagated to the caller, with a complete stack-trace.

+ ///

Warning: may cause deadlock if called on a UI thread.

+ ///
+ /// + /// + ///

Warning: this method hard-blocks the calling thread for the duration of the computation, + /// including threads that have a non-null + /// such as UI threads. Calling it + /// from a UI thread will make the UI unresponsive and risks deadlock if any continuation in the + /// computation needs to be dispatched back to that context. + ///

+ ///

Unlike , this + /// method never offloads to the thread pool and/or a background context, so exception stack traces always include + /// the full call chain from the invocation site. This makes it the preferred mechanism for interactive use in + /// F# scripts and F# interactive (FSI), and for unit tests. + ///

+ ///

This overload does not support a timeout; see + /// if a timeout is required. + ///

+ ///
+ /// + /// The computation to run. + /// The cancellation token to be associated with the computation. + /// If omitted, Async.DefaultCancellationToken is used. + /// The result of the computation. + /// Starting Async Computations + /// + /// + /// + /// printfn "A" + /// + /// let result = async { + /// printfn "B" + /// do! Async.Sleep(1000) + /// printfn "C" + /// 17 + /// } |> Async.RunSynchronouslyImmediate + /// + /// printfn "D" + /// + /// Prints "A", "B" immediately (on the calling thread), then, after one second, "C" (from a thread-pool thread), + /// quickly followed by "D" (on the calling thread). Yields result = 17. + /// + static member RunSynchronouslyImmediate : computation:Async<'T> * ?cancellationToken:CancellationToken -> 'T + /// Starts the asynchronous computation in the thread pool. Do not await its result. /// /// If no cancellation token is provided then the default cancellation token is used. diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl index 5b6cc0bce4e..89fb1bb6146 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.debug.bsl @@ -672,6 +672,7 @@ Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartAsTa Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartImmediateAsTask[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: System.Tuple`3[Microsoft.FSharp.Core.FSharpFunc`2[System.Tuple`3[TArg,System.AsyncCallback,System.Object],System.IAsyncResult],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,T],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,Microsoft.FSharp.Core.Unit]] AsBeginEnd[TArg,T](Microsoft.FSharp.Core.FSharpFunc`2[TArg,Microsoft.FSharp.Control.FSharpAsync`1[T]]) Microsoft.FSharp.Control.FSharpAsync: T RunSynchronously[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Int32], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) +Microsoft.FSharp.Control.FSharpAsync: T RunSynchronouslyImmediate[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void CancelDefaultToken() Microsoft.FSharp.Control.FSharpAsync: Void Start(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void StartImmediate(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl index 217d4b7c837..6d29205d290 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard20.release.bsl @@ -672,6 +672,7 @@ Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartAsTa Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartImmediateAsTask[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: System.Tuple`3[Microsoft.FSharp.Core.FSharpFunc`2[System.Tuple`3[TArg,System.AsyncCallback,System.Object],System.IAsyncResult],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,T],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,Microsoft.FSharp.Core.Unit]] AsBeginEnd[TArg,T](Microsoft.FSharp.Core.FSharpFunc`2[TArg,Microsoft.FSharp.Control.FSharpAsync`1[T]]) Microsoft.FSharp.Control.FSharpAsync: T RunSynchronously[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Int32], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) +Microsoft.FSharp.Control.FSharpAsync: T RunSynchronouslyImmediate[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void CancelDefaultToken() Microsoft.FSharp.Control.FSharpAsync: Void Start(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void StartImmediate(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl index 43defdb622e..d8d7ff44b21 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.debug.bsl @@ -674,6 +674,7 @@ Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartAsTa Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartImmediateAsTask[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: System.Tuple`3[Microsoft.FSharp.Core.FSharpFunc`2[System.Tuple`3[TArg,System.AsyncCallback,System.Object],System.IAsyncResult],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,T],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,Microsoft.FSharp.Core.Unit]] AsBeginEnd[TArg,T](Microsoft.FSharp.Core.FSharpFunc`2[TArg,Microsoft.FSharp.Control.FSharpAsync`1[T]]) Microsoft.FSharp.Control.FSharpAsync: T RunSynchronously[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Int32], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) +Microsoft.FSharp.Control.FSharpAsync: T RunSynchronouslyImmediate[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void CancelDefaultToken() Microsoft.FSharp.Control.FSharpAsync: Void Start(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void StartImmediate(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl index ed913ea04d3..db9f41d97a8 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netstandard21.release.bsl @@ -674,6 +674,7 @@ Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartAsTa Microsoft.FSharp.Control.FSharpAsync: System.Threading.Tasks.Task`1[T] StartImmediateAsTask[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: System.Tuple`3[Microsoft.FSharp.Core.FSharpFunc`2[System.Tuple`3[TArg,System.AsyncCallback,System.Object],System.IAsyncResult],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,T],Microsoft.FSharp.Core.FSharpFunc`2[System.IAsyncResult,Microsoft.FSharp.Core.Unit]] AsBeginEnd[TArg,T](Microsoft.FSharp.Core.FSharpFunc`2[TArg,Microsoft.FSharp.Control.FSharpAsync`1[T]]) Microsoft.FSharp.Control.FSharpAsync: T RunSynchronously[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Int32], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) +Microsoft.FSharp.Control.FSharpAsync: T RunSynchronouslyImmediate[T](Microsoft.FSharp.Control.FSharpAsync`1[T], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void CancelDefaultToken() Microsoft.FSharp.Control.FSharpAsync: Void Start(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) Microsoft.FSharp.Control.FSharpAsync: Void StartImmediate(Microsoft.FSharp.Control.FSharpAsync`1[Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpOption`1[System.Threading.CancellationToken]) diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModule.fs b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModule.fs index 3315c18b9d9..4e177bd4b75 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModule.fs +++ b/tests/FSharp.Core.UnitTests/FSharp.Core/Microsoft.FSharp.Control/AsyncModule.fs @@ -447,6 +447,111 @@ type AsyncModule() = } |> Async.RunSynchronously + // ---- RunSynchronouslyImmediate: basic functionality ---- + + [] + member _.``RunSynchronouslyImmediate returns value``() = + let result = async { return 42 } |> Async.RunSynchronouslyImmediate + Assert.Equal(42, result) + + [] + member _.``RunSynchronouslyImmediate propagates exception``() = + Assert.Throws(fun () -> + async { return invalidOp "test" } + |> Async.RunSynchronouslyImmediate + |> ignore + ) |> ignore + + [] + member _.``RunSynchronouslyImmediate respects pre-cancelled token``() = + use cts = new CancellationTokenSource() + cts.Cancel() + Assert.Throws(fun () -> + Async.RunSynchronouslyImmediate(async { return 1 }, cancellationToken = cts.Token) + |> ignore + ) |> ignore + + [] + member _.``RunSynchronouslyImmediate works with Sleep``() = + let result = + async { + do! Async.Sleep 10 + return 17 + } + |> Async.RunSynchronouslyImmediate + Assert.AreEqual(17, result) + + // ---- RunSynchronouslyImmediate: differences from RunSynchronously ---- + // + // RunSynchronously will offload to the thread pool when SynchronizationContext.Current is + // non-null or Thread.IsThreadPoolThread is false (e.g. FSI, GUI threads, dedicated test threads). + // In those cases the computation commences on a different thread and exception stack traces are + // incomplete. RunSynchronouslyImmediate always executes the first step on the calling thread, + // giving a complete call stack that is much more useful during interactive testing. + + [] + // RunSynchronously offloads to the thread pool when SynchronizationContext.Current is non-null + // (see RunSynchronously.ThreadJump.IfSyncCtxtNonNull). + // and/or the caller is not a threadpool thread + // RunSynchronouslyImmediate always starts on the calling thread regardless. + member _.``RunSynchronouslyImmediate.StartsOnCallingThread WhenSyncContextNonNull``() = + let t = Thread(fun () -> + Assert.False(Thread.CurrentThread.IsThreadPoolThread) + let old = SynchronizationContext.Current + SynchronizationContext.SetSynchronizationContext(SynchronizationContext()) + Assert.NotNull(SynchronizationContext.Current) + let mutable startThreadId = -1 + async { startThreadId <- Thread.CurrentThread.ManagedThreadId } + |> Async.RunSynchronouslyImmediate + SynchronizationContext.SetSynchronizationContext(old) + Assert.Equal(Thread.CurrentThread.ManagedThreadId, startThreadId) ) + t.Start() + t.Join() + + [] + // Demonstrates the key difference in starting-thread identity between the two methods when called + // from a non-thread-pool thread (e.g. FSI, a test runner's main thread, or a dedicated thread): + // RunSynchronously offloads the computation to a thread-pool thread (different thread ID), + // while RunSynchronouslyImmediate keeps it on the calling thread (same thread ID). + // The latter ensures that exception stack traces include frames from the caller's thread, + // making failures much easier to diagnose during interactive testing. + member _.``RunSynchronouslyImmediate.vs.RunSynchronously.CallerThreadIdentity``() = + let mutable runSyncThreadId = -1 + let mutable immThreadId = -1 + let mutable callerThreadId = -1 + let t = Thread(fun () -> + callerThreadId <- Thread.CurrentThread.ManagedThreadId + async { runSyncThreadId <- Thread.CurrentThread.ManagedThreadId } + |> Async.RunSynchronously + async { immThreadId <- Thread.CurrentThread.ManagedThreadId } + |> Async.RunSynchronouslyImmediate) + t.Start() + t.Join() + Assert.NotEqual(callerThreadId, runSyncThreadId) + Assert.AreEqual(callerThreadId, immThreadId) + + [] + // Because RunSynchronouslyImmediate starts on the calling thread, an exception thrown before + // any do! in the computation is captured on that thread. When re-raised to the caller the + // exception stack trace therefore includes the caller's thread frames, whereas RunSynchronously + // (when it offloads) only carries thread-pool frames in the original portion of the trace. + member _.``RunSynchronouslyImmediate.ExceptionOriginatesOnCallingThread``() = + let mutable callerThreadId = -1 + let mutable exceptionOriginThreadId = -1 + let t = Thread(fun () -> + callerThreadId <- Thread.CurrentThread.ManagedThreadId + try async { + exceptionOriginThreadId <- Thread.CurrentThread.ManagedThreadId + failwith "boom" + } + |> Async.RunSynchronouslyImmediate + with e -> + printfn $"STACKTRACE ===\n{e.StackTrace}\n===" + ()) + t.Start() + t.Join() + Assert.AreEqual(callerThreadId, exceptionOriginThreadId) + [] member _.``RaceBetweenCancellationAndError.AwaitWaitHandle``() = let disposedEvent = new System.Threading.ManualResetEvent(false)