-
Notifications
You must be signed in to change notification settings - Fork 5.2k
Description
Background and motivation
The SemaphoreSlim.WaitAsync method is a foundational component for asynchronous coordination. Internally, its TaskNode (the underlying TaskCompletionSource) is constructed with TaskCreationOptions.RunContinuationsAsynchronously.
runtime/src/libraries/System.Private.CoreLib/src/System/Threading/SemaphoreSlim.cs
Line 72 in 302a563
| internal TaskNode() : base((object?)null, TaskCreationOptions.RunContinuationsAsynchronously) { } |
This is a deliberate and critical design choice. On the Windows platform, it prevents a class of severe bugs known as "thread-stealing." Without this flag, an I/O Completion Port (IOCP) thread that calls Release() could have its thread co-opted to synchronously run the application's continuation logic. This starves the IOCP pool, degrading the system's ability to process I/O.
However, this safeguard is specific to the Windows I/O architecture.
On Linux and other Unix-like systems, there is no distinct IOCP pool. All asynchronous operations, I/O completions, and general thread pool work are handled by the same general-purpose .NET thread pool. On these platforms, the RunContinuationsAsynchronously flag provides no protective benefit. It solely serves as performance overhead by forcing a context switch and scheduling event where a synchronous (inline) continuation would be safe and more efficient.
For high-throughput, low-latency services running on Linux, this forced context switch is a measurable cost. This proposal seeks to provide an expert-level option to disable this behavior for performance-critical applications on platforms where the IOCP-related risk is not a factor.
API Proposal
Allow a developer to opt-out of the default forced-asynchronous continuation behavior by adding a SemaphoreSlimOptions enum that can be passed to the SemaphoreSlim constructor.
When SemaphoreSlimOptions.AllowSynchronousContinuations option is enabled, a call to Release() that unblocks a WaitAsync waiter would be allowed to run that waiter's continuation synchronously on the releasing thread.
The default behavior must remain SemaphoreSlimOptions.None to maintain backward compatibility and protect the vast majority of users from common concurrency pitfalls.
namespace System.Threading;
public class SemaphoreSlim : IDisposable
{
// === NEW "MASTER" CONSTRUCTORS ===
// These are the new overloads. Note they do *not* have
// default parameters.
public SemaphoreSlim(int initialCount, SemaphoreSlimOptions options)
: this(initialCount, int.MaxValue, options) { }
public SemaphoreSlim(int initialCount, int maxCount, SemaphoreSlimOptions options)
{
// This is the new "master" implementation that
// checks the options flag.
// ... implementation ...
}
// === EXISTING CONSTRUCTORS (UNCHANGED PUBLIC API) ===
// These are kept for 100% backward compatibility.
// They are just modified to chain to the new master constructors,
// passing the default 'None' value.
public SemaphoreSlim(int initialCount)
: this(initialCount, int.MaxValue, SemaphoreSlimOptions.None) { }
public SemaphoreSlim(int initialCount, int maxCount)
: this(initialCount, maxCount, SemaphoreSlimOptions.None) { }
// ... rest of the class ...
}
public enum SemaphoreSlimOptions
{
/// <summary>
/// Provides the default behavior (no special options). Continuations
/// are forced to run asynchronously.
/// </summary>
None = 0,
/// <summary>
/// Allows asynchronous waiters to have their continuations
/// run synchronously on the thread that calls Release().
/// This improves performance but carries significant risk.
/// </summary>
AllowSynchronousContinuations = 1
}API Usage
using System.Collections.Concurrent;
/// <summary>
/// An asynchronousresource pool that uses SemaphoreSlim to manage access to a fixed number of resources.
/// This pool is optimized for high-throughput, low-latency scenarios by using 'AllowSynchronousContinuations'.
/// </summary>
public class ResourcePool<T>
{
readonly SemaphoreSlim gate;
readonly ConcurrentQueue<T> pool;
/// <summary>
/// Initializes the pool with a collection of resources.
/// The pool's size is determined by the number of items.
/// </summary>
/// <param name="items">The initial set of resources to fill the pool with.</param>
public ResourcePool(ICollection<T> items)
{
int size = items.Count;
// The pool is instantiated with the performance flag.
// This is safe because this design is lock-free.
gate = new SemaphoreSlim(
size, // initialCount
size, // maxCount
SemaphoreSlimOptions.AllowSynchronousContinuations);
pool = new ConcurrentQueue<T>(items);
}
/// <summary>
/// Asynchronously acquires a resource from the pool.
/// </summary>
public async Task<T> TakeResourceAsync()
{
// Asynchronously waits for a resource to be available
await gate.WaitAsync();
if (pool.TryDequeue(out T? resource))
{
return resource;
}
// This should be unreachable if the semaphore is used correctly.
throw new InvalidOperationException("Semaphore count and pool count mismatched.");
}
/// <summary>
/// Returns a resource to the pool.
/// </summary>
public void ReturnResource(T resource)
{
// No lock needed.
pool.Enqueue(resource);
// --- PERFORMANCE CRITICAL PATH ---
// Because of 'AllowSynchronousContinuations', the thread
// calling Release() may immediately execute the continuation
// of a waiting 'GetResourceAsync()' task.
//
// This allows the waiter to run, dequeue the resource, and
// return, all without the overhead of a thread context switch.
gate.Release();
}
}Alternative Designs
1. Use a bool allowSynchronousContinuations Parameter.
public SemaphoreSlim(
int initialCount,
int maxCount,
bool allowSynchronousContinuations);Pros:
- Minimal API: This is the smallest possible change to the API surface. It doesn't require adding a new SemaphoreSlimOptions type for a single flag.
- Direct: The intent is very clear from the parameter name.
Cons (Why this is not the preferred .NET pattern):
- The "Boolean Trap": This pattern is not extensible. If the team ever wants to add a second option (e.g., PreferFairness), they would be forced to add yet another overload: (int, int, bool, bool). This leads to a "boolean explosion" of constructors that are difficult to manage and use.
2. Do Nothing (Status Quo)
Pros:
- Maintains maximum safety and simplicity. No new API to learn or misuse.
Cons:
- Fails to address the legitimate performance overhead on non-Windows platforms.
- Forces advanced users to "roll their own" high-performance, Linux-aware semaphore, likely by copying the SemaphoreSlim source and removing the one flag. This leads to duplicated effort and potential for error.
Risks
This is an expert-level feature that carries known, significant risks. Enabling this option requires a developer to guarantee their code does not fall into these common pitfalls.
- Risk of Re-entrancy/Deadlock: This is the most significant risk. If AllowSynchronousContinuations is enabled and Release() is called from within a lock statement, a waiting WaitAsync continuation could run synchronously on that same thread. If that continuation logic then attempts to acquire the same lock, it will deadlock against itself.
- Risk of IOCP Starvation (Windows): If a developer enables this option on a Windows service, they will re-introduce the very IOCP thread-stealing bug that SemaphoreSlim's current design correctly prevents. This can lead to severe I/O thread pool starvation and application-wide performance degradation.
- Risk of StackOverflowException: This flag introduces the risk of an unbounded stack dive. This occurs if the code in a synchronous continuation also calls Release() on the same semaphore, and another task is waiting. The semaphore will synchronously run the next task's continuation, which may in turn call Release() again, all on the same thread's stack. If a large number of waiters are queued, this "recursive-like" chain will exhaust the stack and crash the process.
All these risks (Deadlock, Starvation, and StackOverflow) are not new to the .NET platform. Any developer who manually uses TaskCompletionSource<T> and omits TaskCreationOptions.RunContinuationsAsynchronously faces this identical set of trade-offs. The same developer who creates a "chained release" pattern with a raw TCS would also cause a StackOverflowException.
This is precisely why the default behavior must remain SemaphoreSlimOptions.None. This maintains backward compatibility and protects the vast majority of users from these common concurrency pitfalls. The feature should only be used by developers who can analyze their code and guarantee these conditions are not met.