0

The question may sound a bit abstract, here's an example of a non async piece of code that does some lazy loading and ensures that the lazy loading is only done once by the first thread:

public class LazyExample
{
    private object ExpensiveResource = null;

    private object ExpensiveResourceSyncRoot = new object();

    public object GetExpensiveResource()
    {
        if (ExpensiveResource != null) // Checkpoint 1
            return ExpensiveResource;

        lock (ExpensiveResourceSyncRoot) // initially there will be a queue of threads waiting here that have already passed Checkpoint 1
        {
            if (ExpensiveResource != null) // prevent re-retrieval by all subsequent threads that passed Checkpoint 1
                return ExpensiveResource;

            // create the expensive resource but do not assign yet...
            object expensiveResource = new object();

            // initialize the expensive resource, for example:
            // - Call an API...
            // - Do some Database lookups...
            Thread.Sleep(1); 

            // finally as last step before releasing the lock, assign the fully initialized expensive object
            ExpensiveResource = expensiveResource;
        }

        return ExpensiveResource;
    }
}

In our lib, the async virus has started to infest many calls. Since awaiting is not allowed directly inside a lock we now wrap the async stuff in a new method like so:

public class LazyExample
{
    private object ExpensiveResource = null;

    private object ExpensiveResourceSyncRoot = new object();

    public async Task<object> GetExpensiveResourceAsync()
    {
        if (ExpensiveResource != null) // Checkpoint 1
            return ExpensiveResource;

        lock (ExpensiveResourceSyncRoot) // initially there will be a queue of threads waiting here that have already passed Checkpoint 1
        {
            if (ExpensiveResource != null) // prevent re-retrieval by all subsequent threads that passed Checkpoint 1
                return ExpensiveResource;
            
            // assign the fully initialized expensive object
            ExpensiveResource = CreateAndInitializeExpensiveResourceAsync().Result;
        }

        return ExpensiveResource;
    }

    private async Task<object> CreateAndInitializeExpensiveResourceAsync()
    {
        object expensiveResource = new object();

        // initialize the expensive resource, this time async:
        await Task.Delay(1);

        return expensiveResource;
    }
}

This however feels like putting a zip-tie around a safety latch and defeating the cause.

In seemingly random cases we need to wrap a call in order to prevent deadlocks like so:

ExpensiveResource = Task.Run(CreateAndInitializeExpensiveResourceAsync).Result;

This will force the method to run in a different thread and cause the current thread to go idle until it joins (extra overhead for no good reason as far as I can tell).

So my question is: is it safe to offload async stuff to a separate method (a new stack frame if you will) inside a lock or are we indeed defeating a safety latch?

Louis Somers
  • 2,560
  • 3
  • 27
  • 57
  • 2
    See for example here: https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/march/async-await-best-practices-in-asynchronous-programming (Async all the way part). Deadlock is not related to `lock` here. Note also that there are "async lock" constructs to use to simulate semantics of lock in async code. – Evk Nov 24 '22 at 14:02
  • Be aware that a Task is not a Thread. Lock does lock different threads and not different tasks if they are not running in different threads. Instead of lock you may rather look at other mechanism like SemaphoreSlim , – Ralf Nov 24 '22 at 14:09
  • What prevents you from using other locking constructs? https://stackoverflow.com/questions/20084695/c-sharp-lock-and-async-method – CookedCthulhu Nov 24 '22 at 14:11
  • Somewhat related: [Enforce an async method to be called once](https://stackoverflow.com/questions/28340177/enforce-an-async-method-to-be-called-once). – Theodor Zoulias Nov 24 '22 at 14:11
  • So essentially you are asking if it's safe to [block on async code](https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html), correct? – Theodor Zoulias Nov 24 '22 at 14:13
  • 1
    @TheodorZoulias Haha, no I just had a gut feeling that our (working) codebase is jumping fences and Canton7 helped me to understand "why". So now I can make confident decisions on where to spend our time effort and resources and beat the competition without compromising rigidity ;-) – Louis Somers Nov 24 '22 at 15:57

1 Answers1

3

Synchronously waiting for your async operation to finish, putting a lock around that, and then punting that off to the ThreadPool to make it async again is a solution, but it's just about the worst option out there.

First, why not use an async lock? SemaphoreSlim is the classic:

public class LazyExample
{
    private object ExpensiveResource = null;

    private readonly SemaphoreSlim ExpensiveResourceSyncRoot = new(1, 1);

    public async Task<object> GetExpensiveResourceAsync()
    {
        if (ExpensiveResource != null) // Checkpoint 1
            return ExpensiveResource;

        await ExpensiveResourceSyncRoot.WaitAsync();
        try
        {
            if (ExpensiveResource != null) // prevent re-retrieval by all subsequent threads that passed Checkpoint 1
                return ExpensiveResource;

            // finally as last step before releasing the lock, assign the fully initialized expensive object
            ExpensiveResource = await CreateAndInitializeExpensiveResourceAsync();
        }
        finally
        {
            ExpensiveResourceSyncRoot.Release();
        }

        return ExpensiveResource;
    }
}

Secondly, there's a much better solution to this problem of lazy async initialisation. Instead of caching ExpensiveResource, cache a Task which represents the initialisation of ExpensiveResource. When a consumer awaits that Task, either they'll find that it's already complete, in which case the resource is available immediately, or they'll have to wait, in which case the resource is still being created, and by awaiting the Task they'll be notified when it is available.

public class LazyExample
{
    private Task<object> ExpensiveResource = null;

    private readonly object ExpensiveResourceSyncRoot = new();

    // Note this is not async, and does not contain awaits
    public Task<object> GetExpensiveResourceAsync()
    {
        if (ExpensiveResource != null)
            return ExpensiveResource;

        lock (ExpensiveResourceSyncRoot)
        {
            if (ExpensiveResource != null)
                return ExpensiveResource;

            // Note that we don't await this.
            // ExpensiveResource holds the Task which represents the creation of the resource
            ExpensiveResource = CreateAndInitializeExpensiveResourceAsync();
        }

        return ExpensiveResource;
    }
}

We can further simplify with a Lazy<T>:

public class LazyExample
{
    private readonly Lazy<Task<object>> ExpensiveResource;

    public LazyExample()
    {
        ExpensiveResource = new(CreateAndInitializeExpensiveResourceAsync);
    }

    public Task<object> GetExpensiveResourceAsync() => ExpensiveResource.Value;
}
canton7
  • 37,633
  • 3
  • 64
  • 77
  • I thought that `Lock(a){}` was simply syntax sugar for using a semaphore. So your first example appears to be almost identical to a lock, and since that works, then why did they break compilation when using the simpler notation? Just to discourage the use? Lazy<> is new to me, will have to see if it performs as good as `if (ExpensiveResource != null)`... – Louis Somers Nov 24 '22 at 14:55
  • 1
    `lock` is syntactic sugar for `Monitor`, not `SemaphoreSlim`. The problem is that `Monitor` is recursive: it keeps track of what thread acquired it, so that if the same thread attempts to acquire it again it doesn't deadlock (think about acquiring a lock, and then calling a method which acquires the same lock). That's a problem with async code, because if there's an await between the acquire and release, the thread which releases the lock might not be the one which acquired it, and that's going to confuse `Monitor`. `SemaphoreSlim` is not recursive and does not have this problem – canton7 Nov 24 '22 at 14:58
  • 2
    Note that `Lazy` just does the same double-checked locking that you did (with a little more care taken with correct use of volatile reads/writes) – canton7 Nov 24 '22 at 15:05
  • 1
    Thanks for clearing that up... I now understand the answer is: yes we are defeating a safety latch since the async code may be running on a different thread and will not get the same lock. I'll have to see if SemaphoreSlim will work in my more complex real-world code, I don't think the same lock would be used inside nested methods but now at least I know what to check for. – Louis Somers Nov 24 '22 at 15:26