0

I've had to use SemaphoreSlim to ensure single-threaded access to some parts of my code, and would like to make sure that I'm disposing of everything correctly. Suppose I have the following class:

public class Foo
{
    private readonly CancellationTokenSource _canceller = new CancellationTokenSource();
    private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);

    ~Foo()
    {
        Dispose(false);
        GC.SuppressFinalize(this);
    }

    public void Dispose()
    {
        Dispose(true);
    }

    protected void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        _canceller.Cancel();

        if (_disposing)
            _semaphore.Dispose();

        _disposed = true;
    }

    public async Task ExecuteAsync()
    {
        try
        {
            await _semaphore.WaitAsync(_canceller.Token);
        }
        catch (OperationCanceledException)
        {
            throw new ObjectDisposedException(nameof(Foo), "Cannot execute on a Foo after it has been disposed");
        }
        
        try
        {
            // Critical section
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

When I call Dispose(true), I'm effectively executing the following lines one after the other:

_canceller.Cancel();
_semaphore.Dispose();

My question is, what happens to any other threads/tasks that are currently waiting for the critical section? Can I guarantee that they will always see the cancellation first, and so will not have a problem with the semaphore being disposed? This hasn't caused any issues so far, but that doesn't mean that it is safe.

Andrew Williamson
  • 8,299
  • 3
  • 34
  • 62
  • 1
    Side note: it's not safe to call `TaskCompletionSource.Cancel` from a finalizer. [I recommend not having a finalizer at all](https://blog.stephencleary.com/2009/08/second-rule-of-implementing-idisposable.html). – Stephen Cleary May 28 '21 at 12:52

1 Answers1

1

The documentation for SemaphoreSlim seem very specific about your question.

All public and protected members of SemaphoreSlim are thread-safe and may be used concurrently from multiple threads, with the exception of Dispose(), which must be used only when all other operations on the SemaphoreSlim have completed.

The only real way to Guarantee that all other operations have completed is to make sure all the tasks have been completed as the example in the documentation shows.

public class Foo
{
    private readonly List<Task> _semaphoreTasks = new list<Task>();

    protected void Dispose(bool disposing)
    {
        if (_disposed)
            return;

        _canceller.Cancel();

        if (_disposing) 
        {
            Task.WaitAll(tasks);
            _semaphore.Dispose();
        }

        _disposed = true;
    }

    public async Task ExecuteAsync()
    {
        try
        {
            var task = _semaphore.WaitAsync(_canceller.Token);
            _semaphoreTasks.Add(task);
            await task;
        // ...

This would also most likely require making sure things are added to the list while the dispose is executing. (You could set the private class variable to a local method variable and then set the class variable to null)

Erik Philips
  • 53,428
  • 11
  • 128
  • 150
  • I was hoping that by using the cancellation token, I *could* guarantee that all other operations have completed. I'm not sure if the `SemaphoreSlim` class continues executing other code internally after a wait operation has been cancelled, but I would think not. – Andrew Williamson May 28 '21 at 04:28
  • The alternatives are that I keep track of how many calls have been made to ExecuteAsync, and wait for them all to cancel before continuing to dispose (I'd rather not do that), or document that Dispose is not thread safe, and leave it up to the caller to wait before disposing. I'm just thinking of other classes that handle Dispose being called during an operation, like 'Stream' and 'Socket'. This is a connection-based class, and I would like similar behaviour – Andrew Williamson May 28 '21 at 04:32
  • Or just store all the Tasks in a list, as in the example, and before disposing, wait for them, also like the example. – Erik Philips May 28 '21 at 04:37
  • 3
    @AndrewWilliamson: `I'm not sure if the SemaphoreSlim class continues executing other code internally after a wait operation has been cancelled` - `Cancel` just initiates the cancellation. Other code is still executing `SemaphoreSlim.Wait`. Maybe that code completes before `SemaphoreSlim.Dispose` is called, and maybe not. I'd consider just not calling `SemaphoreSlim.Dispose` at all. – Stephen Cleary May 28 '21 at 12:53
  • Thanks for the insights. Erik, the class that I am writing is based on the `SerialPort` class, which means sometimes the only way to cancel a read is to dispose the serial port. Using a list would work, but would also need my Dispose method to block, hopefully I can avoid that but I'll consider it. Stephen, will that potentially leak resources? I have seen related posts that say to not dispose `Task` and `CancellationTokenSource` unless they have accessed the `WaitHandle`. Is `SemaphoreSlim` the same? – Andrew Williamson May 29 '21 at 11:28
  • @AndrewWilliamson [Stephen Cleary](https://blog.stephencleary.com/) is one of (IMHO) the most versed persons regarding async outside of Microsoft I am aware of. Taking his advice is very advisable. – Erik Philips May 29 '21 at 19:48
  • Also you could use an OpenAsync/CloseAsync/Dispose pattern. Nothing can be queued until open is called and that is when the semiphore is created. It would then be destroyed upon CloseAsync() and Disposed. Then Dispose is really just a check to make sure everything is internally ok. Call close form Dispose if you have to, which should cause close to run Sync regardless (because of no await/async) and you'd be fine too. – Erik Philips May 29 '21 at 19:51
  • 2
    @ErikPhilips if it is safe to not dispose SemaphoreSlim, I would rather go with that option, as it makes the implementation much simpler and less error prone. There seems to be [some debate in this question](https://stackoverflow.com/a/39195920/2363967) on whether we do need to dispose or not, and whether we should rely on such an implementation-specific detail, but I think the pros outweigh the cons here. Thank you for your help – Andrew Williamson Jun 03 '21 at 02:06
  • @StephenCleary I'm in a different company now but I'm still curious - is it safe to omit the 'Dispose' call for a SemaphoreSlim if we haven't accessed the wait handle? – Andrew Williamson Dec 20 '22 at 20:45
  • @AndrewWilliamson: I believe so. Worst case is that some handles stay open until the finalizer runs, and if that's a problem, you can use reference-counted disposables to eagerly dispose. – Stephen Cleary Dec 20 '22 at 22:24