Search code examples
c#lockingmutexcancellationtokensource

Thread synchronization (locking) that only releases to the last-in thread


What is the proper way to ensure that only the 'last-in' thread is given access to a mutex/locked region while intermediary threads do not acquire the lock?

Example sequence:

A acquires lock
B waits
C waits
B fails to acquire lock*
A releases lock
C acquires lock

*B should fail to acquire the lock either via an exception (as in SemaphoreSlim.Wait(CancellationToken) or a boolean Monitor.TryEnter() type construct.

I can think of several similar schemes to achieve this (such as using a CancellationTokenSource and SemaphoreSlim), but none of them seem particularly elegant.

Is there a common practice for this scenario?


Solution

  • This should work like you want, it uses a SemaphoreSlim with a size of 1 to control it. I also added support for passing in a CancelationToken to cancel waiting for the lock early, it also supports WaitAsync returning a task instead of blocking.

    public sealed class LastInLocker : IDisposable
    {
        private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);
        private CancellationTokenSource _cts = new CancellationTokenSource();
        private bool _disposed = false;
    
        public void Wait()
        {
            Wait(CancellationToken.None);
        }
    
        public void Wait(CancellationToken earlyCancellationToken)
        {
            if(_disposed)
                throw new ObjectDisposedException("LastInLocker");
    
            var token = ReplaceTokenSource(earlyCancellationToken);
            _semaphore.Wait(token);
        }
    
        public Task WaitAsync()
        {
            return WaitAsync(CancellationToken.None);
        }
    
        public async Task WaitAsync(CancellationToken earlyCancellationToken)
        {
            if (_disposed)
                throw new ObjectDisposedException("LastInLocker");
    
            var token = ReplaceTokenSource(earlyCancellationToken);
    
            //I await here because if ReplaceTokenSource thows a exception I want the 
            //observing of that exception to be deferred until the caller awaits my 
            //returned task.
            await _semaphore.WaitAsync(token).ConfigureAwait(false);
        }
    
        public void Release()
        {
            if (_disposed)
                throw new ObjectDisposedException("LastInLocker");
    
            _semaphore.Release();
        }
    
        private CancellationToken ReplaceTokenSource(CancellationToken earlyCancellationToken)
        {
            var newSource = CancellationTokenSource.CreateLinkedTokenSource(earlyCancellationToken);
            var oldSource = Interlocked.Exchange(ref _cts, newSource);
            oldSource.Cancel();
            oldSource.Dispose();
    
            return newSource.Token;
        }
    
        public void Dispose()
        {
            _disposed = true;
    
            _semaphore.Dispose();
            _cts.Dispose();
        }
    }
    

    Here is a little test program that re-creates your test example

    internal class Program
    {
        static LastInLocker locker = new LastInLocker();
        private static void Main(string[] args)
        {
            Task.Run(() => Test("A"));
            Thread.Sleep(500);
            Task.Run(() => Test("B"));
            Thread.Sleep(500);
            Task.Run(() => Test("C"));
            Console.ReadLine();
        }
    
        private static void Test(string name)
        {
            Console.WriteLine("{0} waits for lock", name);
            try
            {
                locker.Wait();
                Console.WriteLine("{0} acquires lock", name);
    
                Thread.Sleep(4000);
                locker.Release();
    
                Console.WriteLine("{0} releases lock", name);
            }
            catch (Exception)
            {
                Console.WriteLine("{0} fails to acquire lock", name);
            }
        }
    }
    

    outputs

    A waits for lock
    A acquires lock
    B waits for lock
    C waits for lock
    B fails to acquire lock
    A releases lock
    C acquires lock
    C releases lock