Description
Background and motivation
The SemaphoreSlim
class is a powerful and lightweight synchronization primitive for managing access to a limited number of resources in asynchronous code. However, using SemaphoreSlim
often requires wrapping critical sections in try
/finally
blocks to ensure Release
is called, which can make the code verbose and error-prone:
try
{
await _semaphore.WaitAsync();
// Critical section
}
finally
{
_semaphore.Release();
}
The introduction of System.Threading.Lock in .NET 9 with its EnterScope method and using pattern provides a more ergonomic way to manage locks, eliminating the need for explicit try/finally blocks:
using var scope = myLock.EnterScope();
// Critical section
Inspired by this pattern, I propose adding a new class, tentatively named SemaphoreSlimTokenSource, to System.Threading to bring similar ergonomics to SemaphoreSlim. This class would simplify resource access management by supporting using for automatic acquisition and release of the semaphore, while also introducing a token-based API for additional flexibility.
API Proposal
I propose the following API for a new SemaphoreSlimTokenSource class:
namespace System.Threading
{
public class SemaphoreSlimTokenSource : IDisposable
{
public interface ISemaphoreSlimToken: IAsyncDisposable
{
bool IsReleased { get; }
void Release();
}
public SemaphoreSlimTokenSource(int initialCount, int maxCount);
public Task<ISemaphoreSlimToken> EnterScopeAsync();
public Task<ISemaphoreSlimToken> EnterScopeAsync(CancellationToken cancellationToken);
public void Dispose();
}
}
EnterScope returns an ISemaphoreSlimToken that acquires the semaphore asynchronously (using WaitAsync) and releases it when disposed (via IAsyncDisposable).
The ISemaphoreSlimToken interface provides:
IsReleased to check if the semaphore has been released.
Release to manually release the semaphore, allowing flexible control.
The CancellationToken overload supports cancellation during semaphore acquisition.
Benefits
Simplified Code with using:
Eliminates the need for explicit try/finally blocks, making asynchronous code more concise and less error-prone:
await using var sLock = await myLocker.EnterScopeAsync();
// Critical section
This mirrors the ergonomic benefits of System.Threading.Lock for synchronous code.
Token-Based Flexibility:
The IToken can be passed to other objects, allowing them to check the semaphore's state (IsReleased) or release it manually (Release). This is similar to how CancellationToken is used in limited contexts, though I'm not entirely sure about the practical value of this feature and welcome feedback on its utility.
Alignment with .NET Patterns:
The name SemaphoreSlimTokenSource and the token-based API draw inspiration from CancellationTokenSource, making it intuitive for .NET developers.
The use of IAsyncDisposable ensures compatibility with modern async/await patterns.
MVP Reference Implementation
using System;
using System.Threading;
using System.Threading.Tasks;
public class SemaphoreSlimTokenSource : IDisposable
{
public interface ISemaphoreSlimToken : IAsyncDisposable
{
bool IsReleased { get; }
void Release();
}
private sealed class SemaphoreSlimToken : ISemaphoreSlimToken
{
private readonly SemaphoreSlimTokenSource _parent;
private bool _isReleased;
public SemaphoreSlimToken(SemaphoreSlimTokenSource source)
{
_parent = source;
_isReleased = false;
}
public bool IsReleased => _isReleased;
public ValueTask DisposeAsync()
{
if (_isReleased)
return ValueTask.CompletedTask;
_isReleased = true;
_parent.Semaphore.Release();
return ValueTask.CompletedTask;
}
public void Release() => DisposeAsync.GetAwaiter().GetResult();
}
public readonly SemaphoreSlim Semaphore;
public SemaphoreSlimTokenSource(int initialCount, int maxCount)
{
Semaphore = new SemaphoreSlim(initialCount, maxCount);
}
public async Task<ISemaphoreSlimToken> EnterScope()
{
var token = new SemaphoreSlimToken(this);
await Semaphore.WaitAsync().ConfigureAwait(false);
return token;
}
public async Task<ISemaphoreSlimToken> EnterScope(CancellationToken cancellationToken)
{
var token = new SemaphoreSlimToken(this);
await Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return token;
}
public void Dispose()
{
Semaphore.Dispose();
}
}
API Usage
var myLocker = new SemaphoreSlimTokenSource(1, 1);
async Task MyPreciousCode()
{
await using var sLock = await myLocker.EnterScopeAsync();
// Critical section
await Task.Delay(1000);
// Semaphore is automatically released
}
async Task Basic()
{
try
{
await myLocker.Semaphore.WaitAsync();
// Critical section
await Task.Delay(1000);
}
finally
{
myLocker.Semaphore.Release();
// Semaphore is released as usual
}
}
Alternative Designs
I provided MVP, but if SemaphoreSlimTokenSource will implement all of SemaphoreSlim methods there will be no need for Semaphore property to be exposed so that it can transparently be used as replacement for SemaphoreSlim providing addition functionality for support of existing code base
Risks
Not that I can see any
In terms of susceptibility to user error - no more that cancellation tokens