From d1ed8917f9102a4928572f598cba499941ff3aed Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 16:33:07 -0500 Subject: [PATCH 1/2] [release/11.0] Allow uncontended synchronous waits on single-threaded runtimes Backport of dotnet/runtime#135413 (product changes only). Fixes dotnet/runtime#134972. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d101a37-f69f-400b-9be7-a9302506a580 --- .../src/System/Threading/Condition.cs | 4 + .../System/Threading/ManualResetEventSlim.cs | 3 +- .../src/System/Threading/Monitor.cs | 1 - .../src/System/Threading/SemaphoreSlim.cs | 4 +- .../src/System/Threading/Tasks/Task.cs | 73 +++++++++++-------- .../src/System/Threading/Monitor.Mono.cs | 4 +- src/mono/wasm/threads.md | 6 ++ 7 files changed, 60 insertions(+), 35 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Condition.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Condition.cs index 34e0834290dba5..c6021a81c7b434 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Condition.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Condition.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using System.Diagnostics.Tracing; +using System.Runtime.CompilerServices; namespace System.Threading { @@ -137,6 +138,9 @@ public bool Wait(int millisecondsTimeout, object associatedObjectForMonitorWait) if (!_lock.IsHeldByCurrentThread) throw new SynchronizationLockException(); + if (millisecondsTimeout != 0) + RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + using ThreadBlockingInfo.Scope threadBlockingScope = new(this, millisecondsTimeout); Waiter waiter = GetWaiterForCurrentThread(); diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/ManualResetEventSlim.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/ManualResetEventSlim.cs index 0ececc08ec100b..e6e3f29900f1ec 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/ManualResetEventSlim.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/ManualResetEventSlim.cs @@ -513,8 +513,6 @@ public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken) ArgumentOutOfRangeException.ThrowIfLessThan(millisecondsTimeout, -1); - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); - if (!IsSet) { if (millisecondsTimeout == 0) @@ -523,6 +521,7 @@ public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken) return false; } + RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); // We spin briefly before falling back to allocating and/or waiting on a true event. long startTime = 0; diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Monitor.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Monitor.cs index 3f68264c559469..fbb0301c309f58 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Monitor.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Monitor.cs @@ -140,7 +140,6 @@ private static Lock GetLockObject(object obj) public static bool Wait(object obj, int millisecondsTimeout) { ArgumentNullException.ThrowIfNull(obj); - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); return GetLockObject(obj).Wait(millisecondsTimeout, obj); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/SemaphoreSlim.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/SemaphoreSlim.cs index a530c22b051d20..6fe4227c751792 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/SemaphoreSlim.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/SemaphoreSlim.cs @@ -320,8 +320,6 @@ private bool WaitCore(long millisecondsTimeout, CancellationToken cancellationTo return false; } - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); - long startTime = 0; if (millisecondsTimeout != Timeout.Infinite && millisecondsTimeout > 0) { @@ -367,6 +365,8 @@ private bool WaitCore(long millisecondsTimeout, CancellationToken cancellationTo // then block on (once we've released the lock). if (m_asyncHead is not null) { + RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + Debug.Assert(m_asyncTail is not null, "tail should not be null if head isn't"); asyncWaitTask = WaitAsyncCore(millisecondsTimeout, cancellationToken); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs index 6bb445bb3fe645..73ef3e5a76eb7b 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/Tasks/Task.cs @@ -3157,8 +3157,6 @@ private bool InternalWaitCore(int millisecondsTimeout, CancellationToken cancell } else { - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); - returnValue = SpinThenBlockingWait(millisecondsTimeout, cancellationToken); } @@ -3209,13 +3207,24 @@ internal SetOnInvokeMres() : base(false, 0) { } /// true if the task is completed; otherwise, false. private bool SpinThenBlockingWait(int millisecondsTimeout, CancellationToken cancellationToken) { + if (!RuntimeFeature.IsMultithreadingSupported) + { + if (IsCompleted) + { + return true; + } + if (millisecondsTimeout == 0) + { + return false; + } + RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + } + bool infiniteWait = millisecondsTimeout == Timeout.Infinite; uint startTimeTicks = infiniteWait ? 0 : (uint)Environment.TickCount; bool returnValue = SpinWait(millisecondsTimeout); if (!returnValue) { - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); - // We're about to block waiting for the task to complete, which is expensive, and if // the task being waited on depends on some other work to run, this thread could end up // waiting for some other thread to do work. If the two threads are part of the same scheduler, @@ -3294,10 +3303,7 @@ private bool SpinWait(int millisecondsTimeout) { if (IsCompleted) return true; - if (!RuntimeFeature.IsMultithreadingSupported) - { - return false; - } + Debug.Assert(RuntimeFeature.IsMultithreadingSupported); if (millisecondsTimeout == 0) { @@ -5135,8 +5141,6 @@ private static bool WaitAllCore(ReadOnlySpan tasks, int millisecondsTimeou // If any of the waited-upon tasks end as Faulted or Canceled, set these to true. bool exceptionSeen = false, cancellationSeen = false; - bool returnValue = true; - // Collects incomplete tasks in "waitedOnTaskList" for (int i = tasks.Length - 1; i >= 0; i--) { @@ -5175,29 +5179,35 @@ private static bool WaitAllCore(ReadOnlySpan tasks, int millisecondsTimeou if (waitedOnTaskList != null) { - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + if (!RuntimeFeature.IsMultithreadingSupported) + { + if (millisecondsTimeout != 0) + RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + + return false; + } // Block waiting for the tasks to complete. - returnValue = WaitAllBlockingCore(waitedOnTaskList, millisecondsTimeout, cancellationToken); + if (!WaitAllBlockingCore(waitedOnTaskList, millisecondsTimeout, cancellationToken)) + { + return false; + } - // If the wait didn't time out, ensure exceptions are propagated, and if a debugger is + // The wait didn't time out, so ensure exceptions are propagated, and if a debugger is // attached and one of these tasks requires it, that we notify the debugger of a wait completion. - if (returnValue) + // Add any exceptions for this task to the collection, and if it's wait + // notification bit is set, store it to operate on at the end. + foreach (Task task in waitedOnTaskList) { - // Add any exceptions for this task to the collection, and if it's wait - // notification bit is set, store it to operate on at the end. - foreach (Task task in waitedOnTaskList) - { - if (task.IsFaulted) exceptionSeen = true; - else if (task.IsCanceled) cancellationSeen = true; - if (task.IsWaitNotificationEnabled) AddToList(task, ref notificationTasks, initSize: 1); - } + if (task.IsFaulted) exceptionSeen = true; + else if (task.IsCanceled) cancellationSeen = true; + if (task.IsWaitNotificationEnabled) AddToList(task, ref notificationTasks, initSize: 1); } } - // Now that we're done and about to exit, if the wait completed and if we have + // Now that we're done and about to exit, if we have // any tasks with a notification bit set, signal the debugger if any requires it. - if (returnValue && notificationTasks != null) + if (notificationTasks != null) { // Loop through each task tha that had its bit set, and notify the debugger // about the first one that requires it. The debugger will reset the bit @@ -5210,7 +5220,7 @@ private static bool WaitAllCore(ReadOnlySpan tasks, int millisecondsTimeou } // If one or more threw exceptions, aggregate and throw them. - if (returnValue && (exceptionSeen || cancellationSeen)) + if (exceptionSeen || cancellationSeen) { // If the WaitAll was canceled and tasks were canceled but not faulted, // prioritize throwing an OCE for canceling the WaitAll over throwing an @@ -5225,7 +5235,7 @@ private static bool WaitAllCore(ReadOnlySpan tasks, int millisecondsTimeou ThrowHelper.ThrowAggregateException(exceptions); } - return returnValue; + return true; } /// Adds an element to the list, initializing the list if it's null. @@ -5249,8 +5259,7 @@ private static bool WaitAllBlockingCore(List tasks, int millisecondsTimeou { Debug.Assert(tasks != null, "Expected a non-null list of tasks"); Debug.Assert(tasks.Count > 0, "Expected at least one task"); - - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + Debug.Assert(RuntimeFeature.IsMultithreadingSupported); bool waitCompleted = false; var mres = new SetOnCountdownMres(tasks.Count); @@ -5517,7 +5526,13 @@ private static int WaitAnyCore(Task[] tasks, int millisecondsTimeout, Cancellati if (signaledTaskIndex == -1 && tasks.Length != 0) { - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + if (!RuntimeFeature.IsMultithreadingSupported) + { + if (millisecondsTimeout != 0) + RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + + return -1; + } Task firstCompleted = TaskFactory.CommonCWAnyLogic(tasks, isSyncBlocking: true); bool waitCompleted = firstCompleted.Wait(millisecondsTimeout, cancellationToken); diff --git a/src/mono/System.Private.CoreLib/src/System/Threading/Monitor.Mono.cs b/src/mono/System.Private.CoreLib/src/System/Threading/Monitor.Mono.cs index dad53629506b8f..7f248522770673 100644 --- a/src/mono/System.Private.CoreLib/src/System/Threading/Monitor.Mono.cs +++ b/src/mono/System.Private.CoreLib/src/System/Threading/Monitor.Mono.cs @@ -77,7 +77,6 @@ public static bool IsEntered(object obj) public static bool Wait(object obj, int millisecondsTimeout) { ArgumentNullException.ThrowIfNull(obj); - RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); return ObjWait(millisecondsTimeout, obj); } @@ -126,6 +125,9 @@ private static bool ObjWait(int millisecondsTimeout, object obj) if (!ObjectHeader.HasOwner(obj)) throw new SynchronizationLockException(); + if (millisecondsTimeout != 0) + RuntimeFeature.ThrowIfMultithreadingIsNotSupported(); + bool sendWaitEvents = millisecondsTimeout != 0 && NativeRuntimeEventSource.Log.IsEnabled(EventLevel.Verbose, NativeRuntimeEventSource.Keywords.WaitHandleKeyword); diff --git a/src/mono/wasm/threads.md b/src/mono/wasm/threads.md index 2931e6172738c2..781482496237bc 100644 --- a/src/mono/wasm/threads.md +++ b/src/mono/wasm/threads.md @@ -39,6 +39,12 @@ JIT/interpreter/AOT to drop the multi-threaded implementation in the single-thre The implementation should not use `[UnsupportedOSPlatform("browser")]` +Synchronous wait APIs that are unsupported without multithreading should call +`ThrowIfMultithreadingIsNotSupported()` only when they would actually block. They can succeed when +a semaphore count, task completion, or set state is already available, and a zero-timeout wait +should return its normal result. The public APIs remain annotated as unsupported because whether a +wait blocks depends on runtime state. + ## Native runtime preprocessor defines ## In `src/mono/mono` and `src/mono/wasm` `DISABLE_THREADS` is defined for single-threaded builds (same From e632b3b19e67c12cd59bace20f4f00df24587025 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 16 Oct 2026 16:43:21 -0500 Subject: [PATCH 2/2] [release/11.0] Add single-threaded wait regression tests Tests from dotnet/runtime#135413. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d101a37-f69f-400b-9be7-a9302506a580 --- .../Task/TaskRtTests_Core.cs | 16 +++++++++++++++ .../tests/ManualResetEventSlimTests.cs | 15 ++++++++++++++ .../System.Threading/tests/MonitorTests.cs | 13 ++++++++++++ .../tests/SemaphoreSlimTests.cs | 20 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/Task/TaskRtTests_Core.cs b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/Task/TaskRtTests_Core.cs index bf744367cdb50d..05981b1c31e9aa 100644 --- a/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/Task/TaskRtTests_Core.cs +++ b/src/libraries/System.Runtime/tests/System.Threading.Tasks.Tests/Task/TaskRtTests_Core.cs @@ -2430,6 +2430,22 @@ public static void RunTaskWaitTest() } } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMultithreadingSupported))] + public static void Wait_SingleThreaded() + { + Task incompleteTask = new TaskCompletionSource().Task; + + Task.CompletedTask.Wait(); + Assert.True(Task.CompletedTask.Wait(0)); + Assert.False(incompleteTask.Wait(0)); + Assert.False(Task.WaitAll([incompleteTask], 0)); + Assert.Equal(-1, Task.WaitAny([incompleteTask], 0)); + + Assert.Throws(() => incompleteTask.Wait(1)); + Assert.Throws(() => Task.WaitAll([incompleteTask], 1)); + Assert.Throws(() => Task.WaitAny([incompleteTask], 1)); + } + // Just runs a task and waits on it. [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] public static void RunTaskWaitTest_NegativeTests() diff --git a/src/libraries/System.Threading/tests/ManualResetEventSlimTests.cs b/src/libraries/System.Threading/tests/ManualResetEventSlimTests.cs index 72bb8f10d5b957..4c689a1ed74186 100644 --- a/src/libraries/System.Threading/tests/ManualResetEventSlimTests.cs +++ b/src/libraries/System.Threading/tests/ManualResetEventSlimTests.cs @@ -68,6 +68,21 @@ public static void RunManualResetEventSlimTest2_TimeoutWait() } } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMultithreadingSupported))] + public static void Wait_SingleThreaded() + { + using ManualResetEventSlim setEvent = new(true); + setEvent.Wait(); + Assert.True(setEvent.Wait(1)); + Assert.True(setEvent.Wait(TimeSpan.Zero)); + + using ManualResetEventSlim unsetEvent = new(false); + Assert.False(unsetEvent.Wait(0)); + Assert.False(unsetEvent.Wait(TimeSpan.Zero)); + Assert.Throws(() => unsetEvent.Wait(1)); + Assert.Throws(() => unsetEvent.Wait()); + } + // Tests timeout on an event that is never set. [Fact] public static void RunManualResetEventSlimTest3_ConstructorTests() diff --git a/src/libraries/System.Threading/tests/MonitorTests.cs b/src/libraries/System.Threading/tests/MonitorTests.cs index 71b373967028d3..b9c8cb2a08fc5c 100644 --- a/src/libraries/System.Threading/tests/MonitorTests.cs +++ b/src/libraries/System.Threading/tests/MonitorTests.cs @@ -413,6 +413,19 @@ public static void Wait_Invalid() () => Monitor.Wait(obj, TimeSpan.FromMilliseconds((double)int.MaxValue + 1))); } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMultithreadingSupported))] + public static void Wait_SingleThreaded() + { + object obj = new(); + lock (obj) + { + Assert.False(Monitor.Wait(obj, 0)); + Assert.False(Monitor.Wait(obj, TimeSpan.Zero)); + Assert.Throws(() => Monitor.Wait(obj, 1)); + Assert.Throws(() => Monitor.Wait(obj)); + } + } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] public static void WaitTest() { diff --git a/src/libraries/System.Threading/tests/SemaphoreSlimTests.cs b/src/libraries/System.Threading/tests/SemaphoreSlimTests.cs index 194dbbebd8df8a..b90a6d2be4e3b2 100644 --- a/src/libraries/System.Threading/tests/SemaphoreSlimTests.cs +++ b/src/libraries/System.Threading/tests/SemaphoreSlimTests.cs @@ -63,6 +63,26 @@ public static void RunSemaphoreSlimTest1_Wait() RunSemaphoreSlimTest1_Wait_Helper(1, 10, TimeSpan.FromMilliseconds(uint.MaxValue), true, null); } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsNotMultithreadingSupported))] + public static void Wait_SingleThreaded() + { + using SemaphoreSlim semaphore = new(1, 1); + + semaphore.Wait(); + semaphore.Release(); + semaphore.Wait(CancellationToken.None); + semaphore.Release(); + Assert.True(semaphore.Wait(1)); + semaphore.Release(); + Assert.True(semaphore.Wait(TimeSpan.FromMilliseconds(1))); + semaphore.Release(); + Assert.True(semaphore.Wait(0, CancellationToken.None)); + + Assert.False(semaphore.Wait(0)); + Assert.Throws(() => semaphore.Wait(1)); + Assert.Throws(() => semaphore.Wait()); + } + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsMultithreadingSupported))] public static void RunSemaphoreSlimTest1_Wait_NegativeCases() {