Synchronizing the Async! (.Net)

As the .Net eco system is growing we are noticing more and more usage on for Tasks with async methods.
With the Task (class) and ValueTask (struct) available it made it easier and faster to develop multi threaded applications while most of the work for switching between threads is being taken care of in the framework.
As the Peter Parker principle says:
Trying to make our code use multiple threads comes at great risk and may break or get into dead locks if not handled correctly.
We also may have some scoped async functions but do access (read and write) shared static resources.
All that will raise unexpected errors that would make our code unusable.
Lets go through the options we have.
1- Using Lock!
Visual Studio C# complier has some great sugar coat syntax to deal with threads trying to access shared resources, such as the keyword Lock.
By decorating the code inside a lock statement it will make sure it gets accessed by one thread at a time while any others will wait until the resource is unlocked.
We can demonstrate that with this simple console app.
class Program
{
static void Main(string[] args)
{
Thread thread1 = new Thread(() =>
{
SharedFunction(100);
})
{
Name = "Thread 1"
};
Thread thread2 = new Thread(() =>
{
thread1.Start();
SharedFunction(100);
})
{
Name = "Thread 2"
};
Thread thread3 = new Thread(() =>
{
thread2.Start();
SharedFunction(100);
})
{
Name = "Thread 3"
};
thread3.Start();
Console.ReadKey();
}
public static int _globalValue = 1;
public static void SharedFunction(int inc)
{
for (int i = 1; i < inc; i++)
{
Task.WaitAll(Task.Delay(i));
_globalValue++;
}
Console.WriteLine($"Current Thread Name {System.Threading.Thread.CurrentThread.Name} with _globalValue {_globalValue}");
}
}
In the sample we define 3 threads each call the other while trying to access some shared resources to update (increases the counter 100 count each call).
Running the code would result in the following result.

As we see the final result will be different each time it is run! that is because all threads are trying to update in an async way.
For many application we should not allow this behavior as the results are unexpected.
To solve that we simple need to lock the part of code that access the data.
private static readonly object _lockHolder = new object();
public static int _globalValue = 1;
public static void SharedFunction(int inc)
{
lock (_lockHolder)
{
for (int i = 1; i < inc; i++)
{
Task.WaitAll(Task.Delay(i));
_globalValue++;
}
Console.WriteLine($"Current Thread Name {System.Threading.Thread.CurrentThread.Name} with _globalValue {_globalValue}");
}
}
Our update function will look like that, we define a lock object advised not to be used anywhere as it only used here to flag that some thread is accessing the resource.
Now we run!

we see that the update operation was done in a synchronies way, as per what we need.
2- Using Semaphore!
Using locks for non async methods is easy.
BUT, for Task and Await / Async it can not be used the same way 🙁 .
For Task and Await our code executes until it meets an await on which it switches to another spot until the awaited function is done executing and for a Task it may or may not create a thread.
Using await inside a lock will not work and will lead to compilation error.
We can do a work around as we know Lock is only a sugar coat syntax and under the hood it calls Monitor.TryEntry, Monitor.ExitDispose… so by using those function we can compile our code!
But now if we run it will get into a dead lock.
Now that Lock is not an option we have to figure out another way.
The solution is to use a Semaphore, as per wiki
So by definition we see that it should solve our problem.
Lets see how to use in our code.
static void Main(string[] args)
{
Task.WaitAll(
Task.Run(() => SharedFunctionAsync(100)),
Task.Run(() => SharedFunctionAsync(100)),
Task.Run(() => SharedFunctionAsync(100))
);
Console.ReadKey();
}
private static readonly SemaphoreSlim _semaphoreSlim = new SemaphoreSlim(1, 1);
public static async Task SharedFunctionAsync(int inc)
{
try
{
await _semaphoreSlim.WaitAsync();
for (int i = 1; i < inc; i++)
{
_globalValue++;
}
Console.WriteLine($"Current Thread Name {System.Threading.Thread.CurrentThread.Name} with _globalValue {_globalValue}");
}
finally
{
_semaphoreSlim.Release();
}
}
Defining a SemaphoreSlim with max capacity of 1 mean only one Thread / Task can access the resources.
By this way we can await any operation inside SharedFunctionAsync which could be getting data from network or any long running operation while having our code run async and keeping our data integrity in sync.
Hope you enjoyed the read and found it helpful!
Thanks for reading.
No Comments