Demystifying SemaphoreSlim

Whenever I go to utilise SemaphoreSlim, I stare at the constructor parameters in bewilderment. What exactly is the difference between initial count and max count, do I need both and why? By way of analogy lets break it down.

Analogy: The Bouncer

Imagine owning a nightclub that can safely contain a number of people at any one time. To control this, you’ve enlisted a bouncer that hands out tickets. If the bouncer has a ticket, it’s handed to the next person in the queue and they’re allowed in. If the bouncer has no tickets, the queue waits until more are available.

The bouncer is given 30 tickets and the doors open.

var bouncer = new SemaphoreSlim(30);
while (true) {
  // If a ticket is available, give it to a patron, otherwise they must wait. 
  await bouncer.WaitAsync();
  Task.Run(async () => {
    // Signifies a patron being inside the club.
    await DanceTheNightAway();
  });
}
C#

30 tickets are handed out immediately, but there’s a problem, only 30 people ever enter. After the 30th ticket is handed out, the bouncer is provided no additional tickets, and the queue waits indefinitely, even when all patrons have left.

Let’s have it so when a patron leaves, they hand their ticket back.

var bouncer = new SemaphoreSlim(30);
while (true) {
  // If a ticket is available, give it to a patron, otherwise they must wait. 
  await bouncer.WaitAsync();
  Task.Run(async () => {
    // Signifies a patron being inside the club.
    await DanceTheNightAway();
    // Give the ticket back to the bouncer.
    bouncer.Release()
  });
}
C#

What if a person injures themselves and is carried out via ambulance and thus their ticket is never given back. Fewer people can enter the club until the bouncer has no tickets to be handed out, and the queue waits indefinitely. In this stretched analogy this equates to an exception within the task (the while loop would also exit but let’s ignore that for now).

We should make it such that the ticket is always handed back when the patron leaves.

var bouncer = new SemaphoreSlim(30);
while (true) {
  // If a ticket is available, give it to a patron, otherwise they must wait. 
  await bouncer.WaitAsync();
  Task.Run(async () => {
    try {
      // Signifies a patron being inside the club.
      await DanceTheNightAway();
    }
    finally {
      // Regardless of how the night goes, always give the ticket back to the bouncer.
      bouncer.Release();
    }
  });
}
C#

This provides a constant stream of 30 tickets, there’s no way in this simple setup for the bouncer to hold more than 30 at any time.

Let’s say we want to increase capacity as the night progresses. Start with 5 available tickets (not a very lively nightclub mind) and an additional 5 people are allowed to enter every 5 minutes.

// 5 tickets to begin with.
var bouncer = new SemaphoreSlim(5);
var lastHanded = DateTime.Now;
while (true) {
  // Every 5 minutes...
  if (DateTime.Now - lastHanded > TimeSpan.FromMinutes(5))
  {
    lastHanded = DateTime.Now;
    // ...provide the bouncer 5 more tickets.
    bouncer.Release(5);
  }
  // If a token is available, give it to a patron, otherwise they must wait. 
  await bouncer.WaitAsync();
  Task.Run(async () => {
    try {
      // Signifies a patron being inside the club.
      await DanceTheNightAway();
    }
    finally {
      // Regardless of how the night goes, always give the token back to the bouncer.
      bouncer.Release();
    }
  });
}
C#

At the start we have capacity for 5 people, half an hour later 10, then 15 and so forth. A problem emerges that we might allocate an infinite number of tickets, well beyond the safe capacity for our club!

This is where the second parameter comes in, this is the max count for how many tickets can exist at once. It provides a fail safe to over-allocating, preventing too many people entering at once.

// 5 tickets to begin with, with a hard limit of 30 in total.
var bouncer = new SemaphoreSlim(5, 30);
var lastHanded = DateTime.Now;
while (true) {
  // Every 5 minutes...
  if (DateTime.Now - lastHanded > TimeSpan.FromMinutes(5))
  {
    lastHanded = DateTime.Now;
    // ...provide the bouncer 5 more tickets.
    TryRelease(bouncer, 5);
  }
  // If a token is available, give it to a patron, otherwise they must wait. 
  await bouncer.WaitAsync();
  Task.Run(() => {
    try {
      // Signifies a patron being inside the club.
      await DanceTheNightAway();
    }
    finally {
      // Regardless of how the night goes, always give the token back to the bouncer.
      TryRelease(bouncer)
    }
  });
}

private void TryRelease(SemaphoreSlim semaphore, int num = 1) {
  try {
    semaphore.Release(num);
    // You can also be clever and release only up to the max.
    // For some reason the max count isn't public so you'll need to store it somewhere.
  } catch (SemaphoreFullException e) {
    // Unable to create any more tickets.
  }
}
C#

A SemaphoreFullException will be thrown if more than the max count is exceeded. The system should ideally be designed to allocate based on the max and the number requested rather than just silently falling over.

For basic concurrency throttling, it’s common practice to set both values to the number of threads you want to run concurrently. This prevents accidentally spawning a higher number of concurrent threads than a pre-determined safe limit.

Using the max limit parameter allows for implementing more complicated concurrency strategies like fixed or sliding time windows, safe in the knowledge you will never over allocate.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *