Search code examples
c#asynchronousconcurrencylocking

Is there a version of SemaphoreSlim or another method that will let the same thread in downstream?


I am refactoring older synchronous C# code to use an async library. The current synchronous code makes liberal usage of locks. Outer methods often call inner methods, where both lock on the same objects. These are often "protected objects" defined in the base class and locked upon in base virtual methods and the overrides that call the base. For synchronous code, that's ok as the thread entering the outer/override method lock can also enter the inner/base method one. That is not the case for async / SemaphoreSlim(1,1)s.

I'm looking for a robust locking mechanism I can use in the async world that will allow subsequent downstream calls to the same locking object, to enter the lock, as per the behaviour in synchronous "lock {...}" syntax. The closest I have come is semaphore slim, but it is too restrictive for my needs. It restricts access not only to other threads, but to the same thread requesting entrance in the inner call too. Alternatively, is there a way to know that the thread is already "inside" the semaphore before calling the inner SemaphoreSlim.waitasync()?

Answers questioning the design structure of the inner/outer methods both locking on the same object are welcome (I question it myself!), but if so please propose alternative options. I have thought of only using private SemaphoreSlim(1,1)s, and having inheritors of the base class use their own private semaphores. But it gets tricky to manage quite quickly.

Sync Example: Because the same thread is requesting entrance to the lock in both inner and outer, it lets it in and the method can complete.

private object LockObject = new object();

public void Outer()
{
    lock (LockObject)
    {
        foreach (var item in collection)
        {
            Inner(item);
        }
    }

}

public void Inner(string item)
{
    lock (LockObject)
    {
        DoWork(item);
    }
}

Async Example: The semaphore doesn't work like that, it will get stuck at the first iteration of inner async because it's just a signal, it doesn't let another one pass until it is released, even if the same thread requests it

protected SemaphoreSlim LockObjectAsync = new SemaphoreSlim(1,1);

public async Task OuterAsync()
{
    try
    {
        await LockObjectAsync.WaitAsync();
        foreach (var item in collection)
        {
            await InnerAsync(item);
        }
    }
    finally
    {
        LockObjectAsync.Release();
    }

}

public async Task InnerAsync(string item)
{
    try
    {
        await LockObjectAsync.WaitAsync();
        DoWork(item);
    }
    finally
    {
        LockObjectAsync.Release();
    }
}

Solution

  • I am in full agreement with Servy here:

    Reentrancy like this should generally be avoided even in synchronous code (it usually makes it easier to make mistakes).

    Here's a blog post on the subject I wrote a while ago. Kinda long-winded; sorry.

    I'm looking for a robust locking mechanism I can use in the async world that will allow subsequent downstream calls to the same locking object, to enter the lock, as per the behaviour in synchronous "lock {...}" syntax.

    TL;DR: There isn't one.

    Longer answer: An implementation exists, but I wouldn't use the word "robust".


    My recommended solution is to refactor first so that the code no longer depends on lock re-entrancy. Make the existing code use SemaphoreSlim (with synchronous Waits) instead of lock.

    This refactoring isn't extremely straightforward, but a pattern I like to use is to refactor the "inner" methods into private (or protected if necessary) implementation methods that are always executed under lock. I strongly recommend these inner methods follow a naming convention; I tend to use the ugly-but-in-your-face _UnderLock. Using your example code this would look like:

    private object LockObject = new();
            
    public void Outer()
    {
      lock (LockObject)
      {
        foreach (var item in collection)
        {
          Inner_UnderLock(item);
        }
      }
    }
    
    public void Inner(string item)
    {
      lock (LockObject)
      {
        Inner_UnderLock(item);
      }
    }
    
    private void Inner_UnderLock(string item)
    {
      DoWork(item);
    }
    

    This gets more complex if there are multiple locks, but for simple cases this refactoring works well. Then you can replace the reentrant locks with non-reentrant SemaphoreSlims:

    private SemaphoreSlim LockObject = new(1);
            
    public void Outer()
    {
      LockObject.Wait();
      try
      {
        foreach (var item in collection)
        {
          Inner_UnderLock(item);
        }
      }
      finally
      {
        LockObject.Release();
      }
    }
    
    public void Inner(string item)
    {
      LockObject.Wait();
      try
      {
        Inner_UnderLock(item);
      }
      finally
      {
        LockObject.Release();
      }
    }
    
    private void Inner_UnderLock(string item)
    {
      DoWork(item);
    }
    

    If you have many of these methods, look into writing a little extension method for SemaphoreSlim that returns IDisposable, and then you end up with using blocks that look more similar to the old lock blocks instead of having try/finally everywhere.


    The not-recommended solution:

    As canton7 suspected, an asynchronous recursive lock is possible, and I have written one. However, that code has never been published nor supported, nor will it ever be. It hasn't been proven in production or even fully tested. But it does, technically, exist.