Skip to content

[browser] Simplify synchronization primitives#125084

Draft
pavelsavara wants to merge 3 commits intodotnet:mainfrom
pavelsavara:browser_trim_locks
Draft

[browser] Simplify synchronization primitives#125084
pavelsavara wants to merge 3 commits intodotnet:mainfrom
pavelsavara:browser_trim_locks

Conversation

@pavelsavara
Copy link
Member

Simplify synchronization primitives for single-threaded browser targets

Summary

Replace Interlocked operations and spinning/waiting logic with plain reads and writes across all synchronization primitives on single-threaded browser WASM targets (FEATURE_SINGLE_THREADED). On a single-threaded runtime, atomic operations and spin loops are both unnecessary and expensive (especially under the interpreter), and blocking waits are impossible since no other thread can release a lock.

Motivation

On the single-threaded browser target:

  • Interlocked.CompareExchange/Decrement/Exchange are unnecessary — there's only one thread, so plain reads/writes are sufficient and cheaper under the interpreter.
  • Spin loops are meaningless — if a lock is held, no other thread can release it, so spinning would deadlock.
  • Wait subsystem (WaitSubsystem, LowLevelMonitor, wait handles) is unreachable — blocking waits are impossible without multiple threads.
  • Contention tracking, waiter signaling, adaptive spin counts are dead code on ST.

These changes eliminate this overhead at compile time using #if FEATURE_SINGLE_THREADED, ensuring both debug and release builds benefit.

Changes

22 files changed, ~620 insertions, ~24 deletions

Lock.cs — Fast path and slow path simplification

  • TryEnter_Inlined: Plain _owningThreadId read/write instead of State.TryLock() (which uses Interlocked.CompareExchange)
  • ExitImpl: Plain _owningThreadId/_recursionCount writes instead of State.Unlock() (which uses Interlocked.Decrement)
  • TryEnterSlow: Handles recursion via plain field access; throws PlatformNotSupportedException for unacquirable locks (deadlock on MT)
  • ExitAll/Reenter/TryEnterOneShot: Plain field operations
  • Gated dead types: State struct (~550 lines), TryLockResult enum, StaticsInitializationStage enum
  • Gated dead methods: LazyInitializeOrEnter, TryInitializeStatics, spin/waiter helpers, CreateWaitEvent, SignalWaiterIfNecessary
  • Gated dead fields: _spinCount, _waiterStartTimeMs, _waitEvent, all static fields
  • ContentionCount returns 0; Dispose() is no-op; IsSingleProcessor returns true

ObjectHeader.CoreCLR.cs — Thin lock without CAS

  • TryAcquireThinLock: Reads/writes header directly without Interlocked.CompareExchange — checks hash/syncblock → UseSlowPath, checks same thread → increment recursion via plain write, checks free → write threadID
  • Release: Plain header writes instead of CAS
  • TryAcquireThinLockSpin: Entire spin loop gated out
  • AcquireInternal (InternalCall): Gated out on ST

ObjectHeader.Mono.cs — Lock word without CAS

  • TryEnterInflatedFast: Plain write to SyncBlock.Status instead of Interlocked.CompareExchange
  • TryEnterFast: Plain write (h.Header.synchronization) instead of LockWordCompareExchange
  • TryExitInflated/TryExitFlat: Plain writes, always succeeds on ST

SpinLock.cs — Eliminate spinning entirely

  • CompareExchange helper: Plain read/write instead of Interlocked.CompareExchange
  • ContinueTryEnter: Try lock once (no spinning/yielding/waiter management)
  • ContinueTryEnterWithThreadTracking: Try once (no spin loop)
  • Exit/ExitSlowPath: Plain writes instead of Interlocked.Decrement/Exchange
  • DecrementWaiters: Gated out entirely (no waiters on ST)
  • MT-only constants gated: SLEEP_ONE_FREQUENCY, TIMEOUT_CHECK_FREQUENCY, WAITERS_MASK, MAXIMUM_WAITERS

LowLevelLock.cs — Plain lock/unlock

  • TryAcquire: Plain _state read/write instead of Interlocked.CompareExchange
  • Release: _state = 0 instead of Interlocked.Decrement + SignalWaiter
  • Acquire: Debug.Fail + throw if TryAcquire fails (should never happen on ST)
  • Gated: WaitAndAcquire, SignalWaiter, TryAcquire_NoFastPath, MT-only fields/constants

Monitor.cs — Wait/Pulse stubs

  • Wait: Calls ThrowIfMultithreadingIsNotSupported() then return default
  • Pulse/PulseAll: No-ops (just null check)
  • s_conditionTable, GetCondition: Gated out; stub s_conditionTable = null kept for CoreCLR VM

Condition.cs — Entire file gated

  • Wrapped with #if !FEATURE_SINGLE_THREADED — Wait/Pulse impossible on ST

WaitSubsystem (4 files) — Entire subsystem gated

  • WaitSubsystem.Unix.cs, WaitSubsystem.WaitableObject.Unix.cs, WaitSubsystem.HandleManager.Unix.cs: Wrapped with #if !FEATURE_SINGLE_THREADED
  • WaitSubsystem.ThreadWaitInfo.Unix.cs: Gated + #else stub with empty ThreadWaitInfo class (required by CoreCLR VM ThreadBaseObject._waitInfo field layout)

Unix wait primitives — Throwing stubs

  • WaitHandle.Unix.cs: WaitOneCore, WaitMultipleIgnoringSyncContextCore, SignalAndWaitCore → throw PlatformNotSupportedException
  • EventWaitHandle.Unix.cs: CreateEventCore, Reset, Set → throw
  • Mutex.Unix.cs: CreateMutexCore, ReleaseMutex → throw
  • Semaphore.Unix.cs: CreateSemaphoreCore, ReleaseCore → throw
  • SafeWaitHandle.Unix.cs: WaitSubsystem.DeleteHandle call gated
  • Thread.Unix.cs: SleepInternal → throw; JoinInternal → throw; SetJoinHandle → empty

Thread files — WaitSubsystem entry point disconnection

  • Thread.CoreCLR.cs: WaitInfo property → throw on ST; Interrupt → skip WaitSubsystem call; OnThreadExiting → skip WaitInfo cleanup
  • Thread.Mono.cs: Same pattern; _waitInfo field gated out (safe on Mono — no VM layout constraint)

Supporting changes

  • ThreadBlockingInfo.cs: Condition references gated with && !FEATURE_SINGLE_THREADED
  • NativeRuntimeEventSource.Threading.cs + .NativeSinks.cs: Lock contention event overloads gated (reference LockIdForEvents which doesn't exist on ST)

Testing

  • System.Threading tests (browser/wasm, CoreCLR): 631 run, 546 passed, 0 failed, 85 skipped
  • Build verification: CoreCLR browser/wasm ✅, CoreCLR Windows ✅, Mono browser ✅

Design decisions

  1. #if FEATURE_SINGLE_THREADED (compile-time) over runtime checks: Ensures dead code is eliminated in both debug and release builds, and avoids interpreter overhead for checking RuntimeFeature.IsMultithreadingSupported on every lock operation.

  2. Keep _state field in Lock.cs: Required by CoreCLR VM via DEFINE_FIELD(LOCK, STATE, _state) in corelib.h. The field exists but is unused on ST.

  3. Keep WaitSubsystem.ThreadWaitInfo as stub: CoreCLR's ThreadBaseObject (in object.h) defines _waitInfo at a fixed layout offset. The empty stub class satisfies the type reference without pulling in the WaitSubsystem dependency chain.

  4. LowLevelLock kept functional (not gated entirely): Has consumers beyond WaitSubsystem — ThreadPoolWorkQueue, ThreadInt64PersistentCounter, PortableThreadPool. Simplified to plain read/write instead of removed.

  5. SpinLock try-once semantics: On ST, ContinueTryEnter tries to acquire the lock exactly once. If it fails, it returns lockTaken=false (for timeout=0) or was already tried in the fast path. No spinning since it would deadlock.

@pavelsavara pavelsavara added this to the 11.0.0 milestone Mar 2, 2026
@pavelsavara pavelsavara self-assigned this Mar 2, 2026
Copilot AI review requested due to automatic review settings March 2, 2026 23:28
@pavelsavara pavelsavara added arch-wasm WebAssembly architecture area-System.Threading os-browser Browser variant of arch-wasm labels Mar 2, 2026
@dotnet-policy-service
Copy link
Contributor

Tagging subscribers to this area: @agocke, @VSadov
See info in area-owners.md if you want to be subscribed.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR simplifies low-level synchronization primitives for single-threaded WASM targets (FEATURE_SINGLE_THREADED) by removing atomic/spin/wait behavior that can’t be meaningfully exercised without multithreading, and by compiling out the Unix WaitSubsystem to reduce overhead.

Changes:

  • Replaces Interlocked-based locking/unlocking paths with plain reads/writes (and removes spinning) under FEATURE_SINGLE_THREADED.
  • Compiles out the Unix WaitSubsystem and Condition implementation on single-threaded targets, adding minimal stubs where required for type/layout.
  • Stubs various Unix wait-handle operations (WaitOne/SignalAndWait, Event/Mutex/Semaphore operations, Thread wait-related hooks) with PlatformNotSupportedException on single-threaded targets.

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/mono/System.Private.CoreLib/src/System/Threading/Thread.Mono.cs Gates Mono thread wait-info usage on !FEATURE_SINGLE_THREADED; throws for WaitInfo on ST.
src/mono/System.Private.CoreLib/src/System/Threading/ObjectHeader.Mono.cs Removes CAS-based lock-word operations on ST; uses plain header/status writes.
src/libraries/System.Private.CoreLib/src/System/Threading/WaitSubsystem.WaitableObject.Unix.cs Compiles out WaitSubsystem waitable objects on ST.
src/libraries/System.Private.CoreLib/src/System/Threading/WaitSubsystem.Unix.cs Compiles out WaitSubsystem core implementation on ST.
src/libraries/System.Private.CoreLib/src/System/Threading/WaitSubsystem.ThreadWaitInfo.Unix.cs Compiles out ThreadWaitInfo implementation on ST and adds minimal stub type.
src/libraries/System.Private.CoreLib/src/System/Threading/WaitSubsystem.HandleManager.Unix.cs Compiles out handle manager on ST.
src/libraries/System.Private.CoreLib/src/System/Threading/WaitHandle.Unix.cs Stubs wait operations to throw PlatformNotSupportedException on ST.
src/libraries/System.Private.CoreLib/src/System/Threading/ThreadBlockingInfo.cs Gates Condition-related blocking info on !FEATURE_SINGLE_THREADED.
src/libraries/System.Private.CoreLib/src/System/Threading/Thread.Unix.cs Stubs sleep/join internals on ST.
src/libraries/System.Private.CoreLib/src/System/Threading/SpinLock.cs Removes spinning/Interlocked operations on ST; implements try-once semantics.
src/libraries/System.Private.CoreLib/src/System/Threading/Semaphore.Unix.cs Stubs semaphore creation/open/release on ST.
src/libraries/System.Private.CoreLib/src/System/Threading/Mutex.Unix.cs Stubs mutex creation/open/release on ST; refactors name handling placement.
src/libraries/System.Private.CoreLib/src/System/Threading/Monitor.cs Compiles out object→Condition mapping on ST; stubs Wait/Pulse/PulseAll.
src/libraries/System.Private.CoreLib/src/System/Threading/LowLevelLock.cs Simplifies LowLevelLock to plain state ops on ST and compiles out wait paths.
src/libraries/System.Private.CoreLib/src/System/Threading/Lock.cs Major ST-only simplification: removes state machine/spin/wait logic; plain field ops and stubs.
src/libraries/System.Private.CoreLib/src/System/Threading/EventWaitHandle.Unix.cs Stubs event creation/open/set/reset on ST.
src/libraries/System.Private.CoreLib/src/System/Threading/Condition.cs Compiles out Condition implementation on ST.
src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/NativeRuntimeEventSource.Threading.cs Gates lock-contention event overloads that depend on MT-only Lock APIs.
src/libraries/System.Private.CoreLib/src/System/Diagnostics/Tracing/NativeRuntimeEventSource.Threading.NativeSinks.cs Same gating for native sink overloads.
src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeWaitHandle.Unix.cs Avoids WaitSubsystem handle deletion on ST.
src/coreclr/System.Private.CoreLib/src/System/Threading/Thread.CoreCLR.cs Gates CoreCLR wait-subsystem interactions on !FEATURE_SINGLE_THREADED; throws for WaitInfo on ST.
src/coreclr/System.Private.CoreLib/src/System/Threading/ObjectHeader.CoreCLR.cs Removes InternalCall acquire/release on ST; directly manipulates header bits without CAS/spin.

Comment on lines +42 to +43
private static void SleepInternal(int millisecondsTimeout) =>
throw new PlatformNotSupportedException();
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On FEATURE_SINGLE_THREADED, Thread.Sleep will always throw PlatformNotSupportedException. This is likely to surface in common code paths like SpinWait.SpinOnce() (which calls Thread.Sleep(0) / Thread.Sleep(1) after enough yields), causing unexpected exceptions even when callers only intend to yield. Consider at least supporting the non-blocking millisecondsTimeout == 0 case by mapping it to UninterruptibleSleep0()/Thread.Yield(), and only throwing for blocking timeouts (> 0 or Infinite), or alternatively adjusting the call sites to avoid Thread.Sleep on single-threaded targets.

Suggested change
private static void SleepInternal(int millisecondsTimeout) =>
throw new PlatformNotSupportedException();
private static void SleepInternal(int millisecondsTimeout)
{
if (millisecondsTimeout == 0)
{
UninterruptibleSleep0();
return;
}
throw new PlatformNotSupportedException();
}

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +33
out bool createdNew) =>
throw new PlatformNotSupportedException();

private static OpenExistingResult OpenExistingWorker(
string name,
NamedWaitHandleOptionsInternal options,
out Semaphore? result) =>
throw new PlatformNotSupportedException();
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the FEATURE_SINGLE_THREADED stub for the named overload of CreateSemaphoreCore, ValidateArguments(initialCount, maximumCount) is no longer called (it is called in the non-stub implementation). This changes exception behavior for invalid counts (ArgumentOutOfRange/ArgumentException) to PlatformNotSupportedException. Consider keeping the same argument validation (and potentially the same SR message for named primitives) before throwing so callers get consistent input-validation exceptions.

Suggested change
out bool createdNew) =>
throw new PlatformNotSupportedException();
private static OpenExistingResult OpenExistingWorker(
string name,
NamedWaitHandleOptionsInternal options,
out Semaphore? result) =>
throw new PlatformNotSupportedException();
out bool createdNew)
{
ValidateArguments(initialCount, maximumCount);
createdNew = false;
if (name is not null)
{
throw new PlatformNotSupportedException(SR.PlatformNotSupported_NamedSynchronizationPrimitives);
}
throw new PlatformNotSupportedException();
}
private static OpenExistingResult OpenExistingWorker(
string name,
NamedWaitHandleOptionsInternal options,
out Semaphore? result) =>
throw new PlatformNotSupportedException(SR.PlatformNotSupported_NamedSynchronizationPrimitives);

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +27
out bool createdNew) =>
throw new PlatformNotSupportedException();
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the FEATURE_SINGLE_THREADED stub for the named overload of CreateEventCore, ValidateMode(mode) is no longer called (it is called in the non-stub implementation). This changes exception behavior for invalid EventResetMode values to PlatformNotSupportedException. Consider validating mode (and any other argument checks that normally run) before throwing so the API preserves its argument-validation behavior even when the operation itself isn’t supported on single-threaded targets.

Suggested change
out bool createdNew) =>
throw new PlatformNotSupportedException();
out bool createdNew)
{
ValidateMode(mode);
if (name is not null)
{
throw new PlatformNotSupportedException(SR.PlatformNotSupported_NamedSynchronizationPrimitives);
}
throw new PlatformNotSupportedException();
}

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +28
out Mutex? result) =>
throw new PlatformNotSupportedException();
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The non-FEATURE_SINGLE_THREADED implementation of OpenExistingWorker throws ArgumentException for null/empty name before attempting to open the mutex. The FEATURE_SINGLE_THREADED stub now throws PlatformNotSupportedException unconditionally, which changes input-validation behavior for Mutex.OpenExisting/TryOpenExisting (e.g., empty name). Consider keeping the same ArgumentException.ThrowIfNullOrEmpty(name) validation before throwing so callers get consistent parameter-validation exceptions.

Suggested change
out Mutex? result) =>
throw new PlatformNotSupportedException();
out Mutex? result)
{
ArgumentException.ThrowIfNullOrEmpty(name);
throw new PlatformNotSupportedException();
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-System.Threading os-browser Browser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants