Rico Suter's blog.
 


This blog post is outdated and the code should not be used anymore! Just use SemaphoreSlim.WaitAsync instead.

Have you ever tried to await a task inside a lock() block? It is not possible, because you can only synchronize synchronous code with the lock keyword. However, in today’s .NET development, async/await is used everywhere and the need to synchronize asynchronous code blocks is coming up quite often. This is why I wrote a simple class which can be used to synchronize asynchronous code blocks.

As an example for this article, we have a simple synchronous method which is synchronized using a lock() block:

private object _lock = new object();

public void Run(int i)
{
    lock (_lock)
    {
        Console.WriteLine("Before: " + i);
        Thread.Sleep(500);  
        Console.WriteLine("After: " + i);
    }
}

Now, we want to reimplement this method so that it uses the Task-based method Task.Delay(). Just putting a lock() block around the awaited Task.Delay() yields a compiler error:

private object _lock = new object();

public async Task RunAsync(int i)
{
    lock (_lock)
    {
        Console.WriteLine("Before: " + i);
        await Task.Delay(500); // <== Compiler error: CS1996 Cannot await in the body of a lock statement
        Console.WriteLine("After: " + i);
    }
}

The problem is that the lock cannot be held for the whole code block. This is because the continuation code after the await keyword may be executed on another thread or is redispatched on the same thread at a later time, but the lock must be acquired and released on the same thread.

Of course, we can use a semaphore instead which can acquire and release a lock on different threads. However, the problem is that the _semaphore.WaitOne() call may synchronously block the method caller and thus the whole method is not asynchronous anymore:

private Semaphore _semaphore = new Semaphore(1, 1);

public async Task RunAsync(int i)
{
    _semaphore.WaitOne(); // <== may synchronously block
    await Task.Delay(500);
    _semaphore.Release();
}

The trick to asynchronously hold a lock is to use the TaskCompletionSource class together with lock() statements. The final solution is encapsulated in the TaskSynchronizationScope class which can be used in the following way:

private TaskSynchronizationScope _lock = new TaskSynchronizationScope();

public Task RunAsync(int i)
{
    return _lock.RunAsync(async () =>
    {
        Console.WriteLine("Before: " + i);
        await Task.Delay(new Random().Next(300, 500));
        Console.WriteLine("After: " + i);
    });
}

The implementation of the TaskSynchronizationScope and TaskSynchronizationScope<T> classes look as follows:

public class TaskSynchronizationScope
{
    private Task _currentTask;
    private readonly object _lock = new object();

    public Task RunAsync(Func<Task> task)
    {
        return RunAsync<object>(async () =>
        {
            await task();
            return null;
        });
    }

    public Task<T> RunAsync<T>(Func<Task<T>> task)
    {
        lock (_lock)
        {
            if (_currentTask == null)
            {
                var currentTask = task();
                _currentTask = currentTask;
                return currentTask;
            }
            else
            {
                var source = new TaskCompletionSource<T>();
                _currentTask.ContinueWith(t =>
                {
                    var nextTask = task();
                    nextTask.ContinueWith(nt =>
                    {
                        if (nt.IsCompleted)
                            source.SetResult(nt.Result);
                        else if (nt.IsFaulted)
                            source.SetException(nt.Exception);
                        else
                            source.SetCanceled();

                        lock (_lock)
                        {
                            if (_currentTask.Status == TaskStatus.RanToCompletion)
                                _currentTask = null;
                        }
                    });
                });
                _currentTask = source.Task;
                return source.Task;
            }
        }
    }
}

The Run method works as follows: If there is no running task, the method immediately instantiates a new task and sets the current task (_currentTask) to this new instance. If a task is already running, then the method creates a task only after the current task has been completed and sets the current task to a task which waits until both tasks have completed. Using this wrapping technique, the currently running task is replaced by a composition of the current task and the next task. In the inner continuation (nextTask.ContinueWith()), the current task is set to null if the outermost wrapper task has been completed and thus all tasks have been completed.

A sample project can be found in this GitHub repository.



Discussion