Don't lock on async tasks

23 Aug 2020

Applications running with multiple threads updating a shared data or resource requires additional care, to avoid data corruption or data integrity issues. So typical solution for this scenario, is to create a mutex lock that allows single thread to access the shared data i.e., one at a time. This solution comes up with a bottleneck during update of shared data, and also leads to resource contention and deadlocks in improper implementations.

public class LockTest{
    private readonly object objLock = new object();

    public void RecordSuccess(int batchId){
        lock(objLock){
            // Record a success in database
            var success = GetCurrentSuccessCountFromDB();
            SaveSuccessCountToDB(success+1);
        }
    }

    public void RecordFailure(int batchId){
        lock(objLock){
            // Record a failure in database
            var success = GetCurrentFailureCountFromDB();
            SaveFailureCountToDB(success+1);
        }
    }
}

A mutex lock makes sure that at any point of time, maximum of single thread from your application can do that shared resource modification. So all other threads has to wait for the lock to be released and their turn to access the shared data and modify it. So, an application with 10 threads trying to update its progress i.e., success or failure counts of a huge process, will sequentially acquire the lock on the database update code and perform success or failure increments without data inconsistency.

Now, let us see if the locks can help us with C# async tasks.

Locks in Async/Await context

Async/Await pattern makes use of system ThreadPool to quickly perform all small chunks of work/tasks in your application.

Consider that your database update code is within an asynchronous method, and you use your lock keyword. 10 threads requires exclusive access to the shared resource or database update. First task/thread will acquire the exclusive lock and proceeds without any problem. But, all other remaining ThreadPool threads will be put on wait (It ain't async over here). You're simply blocking 9 precious threads from Threadpool and blocking it doing nothing, which can actually do more work for your application or for the underlying operating system.

In above code example, when RecordSuccess() has acquired a lock. Then all subsequent requests to RecordSuccess() or RecordFailure() from multiple threads in ThreadPool has to wait till the exclusive lock is released & available.

How do we wait async?

System.Threading assembly comes with Semaphore and SemaphoreSlim that helps you to control concurrent access to the shared resource.

Semaphore - Supports local semaphore for an application and System semaphore for synchronization across multiple process (IPC - inter-process communications).
SemaphoreSlim - Just supports local semaphore that is at an application level

For a standalone application or web applications, SemaphoreSlim is more that enough to handle all concurrency controls. With SemaphoreSlim, you can asynchronously await for the lock to be released.

So in same scenario, now a thread from the Threadpool will be performing the shared write operation on that batchId,.. while other 9 threads with the need for shared access will be suspended till the first thread IO Completion and SemaphoreSlim release.

public class LockTest{
    private readonly SemaphoreSlim _lock= new SemaphoreSlim(1, 1);

    public async Task RecordSuccess(int batchId){
        await _lock.WaitAsync();
        try{
            // Record a success in database
            var success = GetCurrentSuccessCountFromDB();
            SaveSuccessCountToDB(success+1);
        }
        finally{
            _lock.Release();
        }
    }

    public async Task RecordFailure(int batchId){
        await _lock.WaitAsync();
        try{
            // Record a failure in database
            var success = GetCurrentFailureCountFromDB();
            SaveFailureCountToDB(success+1);
        }
        finally{
            _lock.Release();
        }
    }
}

This way we can keep our ThreadPool threads healthy & free for other tasks and also make a proper use of system resource.

Hope this article helps you to unravel the mystery behind under utilized CPU cases - even-though lot of jobs are queued up & available for your application.

Share your experience!