Problem The problem can arise in multiple common scenarios:
- You press a button and want to be sure if the button handler logic is only executed once while the button handler is running. As soon as the button handler has finished, the button can be pressed again and will start the button handler logic again. The button handler logic must never be executed concurrently.
- Imagine you have to send a bitmap image to a very slow display (e.g. a waveshare eink display which has a refresh rate of 2s per full refresh cycle) and you have hardware push buttons which cause the display to show a new bitmap. You can press buttons in a mich faster rate than the display will be able to refresh. For this purpose, I need a locking around the refresh handler of the display. I want to avoid concurrent calls to the display refresh routine if the user presses the pus buttons in faster rate than the display can process the images.
Attempt 1: Using SemaphoreSlim My first attempt looks like follows: I'm using a SemaphoreSlim for which I created an extension method "ExecuteOnceAsync" which has a parameter "action" (which is essentially a task creator factory for the task we only want to run once).
public static class SemaphoreSlimExtensions
{
/// <summary>
/// Executes a task within the context of a a SemaphoreSlim.
/// The task is started only if no <paramref name="action"/> is currently running.
/// </summary>
/// <param name="semaphoreSlim">The semaphore instance.</param>
/// <param name="action">The function to execute as a task.</param>
public static async Task ExecuteOnceAsync(this SemaphoreSlim semaphoreSlim, Func<Task> action)
{
if (semaphoreSlim.CurrentCount == 0)
{
return;
}
try
{
await semaphoreSlim.WaitAsync();
await action();
}
finally
{
try
{
semaphoreSlim.Release();
}
catch (SemaphoreFullException)
{
// Ignored
}
}
}
}
Following unit test should show a sample usage where a call to a task with a shared resource is done 100 times in parallel but we want to make sure it is only executed once:
[Fact]
public async Task ShouldExecuteOnceAsync()
{
// Arrange
var counter = 0;
var parallelTasks = 100;
var semaphoreSlim = new SemaphoreSlim(1, 1);
Func<Task> action = () => Task.Run(() =>
{
counter++;
this.testOutputHelper.WriteLine($"Run: counter={counter}");
return counter;
});
// Act
var tasks = Enumerable.Range(1, parallelTasks).Select(i => semaphoreSlim.ExecuteOnceAsync(action));
await Task.WhenAll(tasks);
// Assert
counter.Should().Be(1);
}
This solution is not thread-safe since CurrentCount is not implemented thread-safe. So, forget about this attempt!
Attempt 2: Using AsyncLazy AsyncLazy allows to run initialization code once and in a thread-safe manner.
The problem with AsyncLazy is that it will only run once per instance of AsyncLazy. My button clicks will occur multiple times. If one handler finishes, another button handler must be run. They must just not run simultaneously.
Attempt 3: Using Interlocked.CompareExchange With the help of other Stackoverflow questions and some github searches, I was able to put following code together. This code atomically sets a flag (currentState) to either 0 (NotRunning) or 1 (Running). CompareExchange checks and updates the currentState. The finally section ensures that the flag is reset from Running to NotRunning after the task is finished.
internal class SyncHelper
{
private const int NotRunning = 0;
private const int Running = 1;
private int currentState;
public async Task RunOnceAsync(Func<Task> task)
{
if (Interlocked.CompareExchange(ref this.currentState, Running, NotRunning) == NotRunning)
{
// The given task is only executed if we pass this atomic CompareExchange call,
// which switches the current state flag from 'not running' to 'running'.
var id = $"{Guid.NewGuid():N}".Substring(0, 5).ToUpperInvariant();
Debug.WriteLine($"RunOnceAsync: Task {id} started");
try
{
await task();
}
finally
{
Debug.WriteLine($"RunOnceAsync: Task {id} finished");
Interlocked.Exchange(ref this.currentState, NotRunning);
}
}
// All other method calls which can't make it into the critical section
// are just returned immediately.
}
}