Skip to content

[API Proposal]: Add SemaphoreSlimTokenSource for Scoped SemaphoreSlim Access with using #116679

Closed
@CaCTuCaTu4ECKuu

Description

@CaCTuCaTu4ECKuu

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    api-suggestionEarly API idea and discussion, it is NOT ready for implementationneeds-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions