Introduction
In .NET, we tend to have asynchronous tasks that can be fired off concurrently, such as in a web API or a Discord App, where an arbitrary number of users can trigger commands at any time. Sometimes, we might have a command that’s too expensive to allow concurrent execution. What can we do if we want to limit the number of concurrent executions of a task without blocking the calling thread?
The Problem
For this example, we’ll say we have a Discord App (for simplicity’s sake, we’ll assume this Discord App will only exist in a single Discord Server.) This app has an expensive, long-running slash command called “sync_all.” Admins of the Discord Server are able to execute this command from the chat interface at any time. This command will gather every user inside the Discord Server and sync their profile information with an internal database. The operations involved in doing this will be costly, as it will require potentially thousands of records to be synced between Discord and a hosted database. There will be no need for it to run more than once, so we want to forbid concurrent executions of this command. Maybe we can achieve this by creating a Run History of our command in the database. Whenever this command is invoked, we first check to see if it has run recently by pulling the most recent record from the Run History table. If it has run recently, we can give the user some feedback and exit gracefully. This works well to enforce a cooldown on the command. But, we have a big problem remaining:
What if the user spams the command? What if multiple users fire it off simultaneously?
Like all good things in modern .NET applications, the command system in the NetCord library operates asynchronously. This allows our application to remain responsive to any other interactions it needs to process concurrently while working on a task in the background. For instance, our Discord App can process any number of slash commands invoked by users at any time. If our App is also an ASP.NET Core web application, it will need to be ready to respond to HTTP requests at any given moment. This poses a serious problem with our Run History idea. If multiple executions of this task are fired simultaneously, they’ll never see the Run History from the first execution, as it hasn’t been completed yet. So, we’ll need something else in addition to this audit check.
One thing’s for certain: we never want to block task execution on the current thread. Doing so would hang the application and prevent it from responding to further events. Avoiding this outcome is exactly why we are using the async/await idiom in the first place. So what can we do?
The Solution
Should we use C#’s lock keyword? The problem with a lock is that it isn’t designed for the async/await world we live in. A lock will synchronously block the calling thread until execution is complete and the lock is subsequently released. So what we will do instead is reach for a semaphore. More specifically, the SemaphoreSlim type. This type was designed with async/await in mind and solves our problem perfectly. We can use the SemaphoreSlim to restrict command execution to a singular execution context without blocking. This will give us full control over how many concurrent executions we will allow (in this case, 1.)
A semaphore is a synchronization primitive used to control access to a shared resource by multiple concurrent threads or tasks. It maintains a count that represents the number of tasks that can simultaneously access the protected resource. When a thread or task attempts to enter the “critical section,” it must first acquire the semaphore. If the semaphore's count is greater than zero, the task is allowed to enter. Otherwise, it must wait until another thread/task releases it. Once a task releases the semaphore, the count increases, allowing the waiting thread/task to proceed.
I’m using the fantastic library NetCord to interact with Discord in .NET. This library gives us everything we need to develop our slash command in an async context.
public class AdminCommands(ApplicationDbContext context) : ApplicationCommandModule<ApplicationCommandContext>
{
[SlashCommand("sync_all", "Attempts to update all users in database. (Expensive)",
DefaultGuildUserPermissions = Permissions.Administrator)]
public async Task SyncAllUsersAsync()
{
}
}
This command will only be accessible to Admin users and can be invoked by typing “/sync_all” in the chat window inside Discord. Let’s define our semaphore as a static field in our class like so:
public class AdminCommands(ApplicationDbContext context) : ApplicationCommandModule<ApplicationCommandContext>
{
private static readonly SemaphoreSlim commandLock = new(1, 1);
[SlashCommand("sync_all", "Attempts to update all users in database. (Expensive)",
DefaultGuildUserPermissions = Permissions.Administrator)]
public async Task SyncAllUsersAsync()
{
}
}
Making it static will ensure that we have the same instance across all execution contexts. In the constructor new(1, 1), the first 1 represents the “initial count,” which is the number of threads (or tasks) that can immediately acquire the semaphore without waiting. The second 1 is the “max count,” which is the total number of threads (or tasks) that are allowed to enter the critical section (our expensive operation) at any given moment concurrently. By setting both values to 1, we’re limiting the semaphore to only one task at a time. This will effectively prohibit any other concurrent executions.
In our command, the first thing we want to do is attempt to acquire the semaphore by calling WaitAsync(). If we can acquire it, then we are free to continue. If not, another task has acquired it and we will exit gracefully.
public class AdminCommands(ApplicationDbContext context) : ApplicationCommandModule<ApplicationCommandContext>
{
private static readonly SemaphoreSlim commandLock = new(1, 1);
[SlashCommand("sync_all", "Attempts to update all users in database. (Expensive)",
DefaultGuildUserPermissions = Permissions.Administrator)]
public async Task SyncAllUsersAsync()
{
var acquired = await commandLock.WaitAsync(0);
if (!acquired)
{
await SendMessageAsync("This operation is already running.");
return;
}
try
{
// Expensive operations…
}
finally
{
commandLock.Release();
}
}
}
(SendMessageAsync() is a helper method I defined off-screen to make things easier to read. It is a shortcut for Context.Channel.SendMessageAsync())
As you can see, this is very simple. I’ll break down what we’re doing here. First, we are awaiting a call to .WaitAsync() and pass 0 to the method. This means that we are not going to wait for any amount of time to see if we can acquire the semaphore. If we have successfully acquired the semaphore, the method will return true. However, if this operation is already in progress (we failed to acquire the semaphore), we want to exit immediately. If we do get past this check and perform our expensive operation, we’ll want to release the semaphore when we are finished. We release it by calling .Release(). If we don’t, it will not be possible to acquire it later by another thread (or task).
Note
SemaphoreSlim implements IDisposable, which is a very important thing to keep in mind. Typically, we need to make sure we call .Dispose() for any IDisposable resource we allocate. This is usually done by using a statement, or by manually invoking .Dispose().
But, in this use case, we are keeping this SemaphoreSlim instance alive for the entire lifetime of our application as it must persist across every call to this command. We do not need to dispose of it because it will be cleaned up by the runtime (or OS) when the application terminates. Keep this in mind if your use case differs, as you’ll need to make sure it gets disposed of properly at the appropriate time.
Conclusion
This is based on a real-world scenario I’ve encountered recently, so I hope this inspires you if you find yourself in a similar predicament somewhere along your travels. Or, maybe this is the first time you’ve seen a SemaphoreSlim in action and can make use of it elsewhere, maybe moving legacy code to the async world or otherwise.
Stay safe, and don’t forget to release your semaphores! 😁