C++20 Concurrency: <semaphore>

An efficient mechanism for managing access to a limited resource

CMP
4 min readSep 25

--

In one of my previous articles, I discussed a few locking mechanisms that provide mutual exclusion — a synchronization concept where exclusive access to a single shared resource is provided to exactly one thread at a time. This is achieved by allowing threads to acquire and release ownership of a lock, thus locking down the critical section while the owning thread has ownership of the lock.

Using a real-world analogy, imagine a public restroom (a gross example, I know, but bear with me) where there is a single stall for everyone to use. The key to this stall (lock) is held (acquired) by one person (thread) at a time, granting the person exclusive access to the stall (single, shared resource) until the person is done and gives (releases) the key to the next person.

What if there are multiple stalls (let’s say four) instead of just one? Now, it would not make sense to have just one key. Instead, we could have four keys (one for each stall), allowing up to four people to use the stalls at the same time. Each time someone grabs a key and occupies a stall, the number of available keys decreases by one, and increases by one each time someone finishes and leaves their stall.

From a coding perspective, does this mean that we should have four locks to provide mutual exclusion to each individual resource? We could, but this seems awfully inefficient because each lock is an object — what if we had a hundred shared resources?

Going back to the public restroom analogy, are keys even necessary? We still only want one person in a stall at a time (obviously), but what if each person simply signals to the people waiting in line that they’re done? The number of available stalls still needs to be tracked, but the overhead of using keys is reduced.

std::counting_semaphore and std::binary_semaphore

This signaling mechanism is called a semaphore. In C++20, the <semaphore> header was added, allowing programmers to easily synchronize thread access to a shared pool of resources. Let’s look at a simple example:

#include <chrono>
#include <iostream>
#include <thread>
#include <semaphore>
#include <vector>

static constexpr int c_maxNumberOfResources{ 4 };
std::counting_semaphore resourcePool{ c_maxNumberOfResources };

void useResource(int jobId) {
resourcePool.acquire();

std::cout << "Job " << jobId << " is running." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2));

resourcePool.release();
}

int main() {
const int numberOfJobs{ 10 };
std::vector<std::jthread> jobs(numberOfJobs);
for (int i = 0; i < numberOfJobs; i++) {
jobs.push_back(std::jthread(useResource, i));
}

return 0;
}

In this example, we construct a std::counting_semaphore with four resources, meaning that up to four threads can access the resourcePool at a time.

Then, ten threads are created in main, which run the useResource function to access the resourcePool. Each time .acquire() is called, the number of available resources decreases by one. Each time .release() is called, that number increases by one, and a signal is sent to the threads that notifies them that a resource is available to use.

In addition to std::counting_semaphore, C++20 also introduced std::binary_semaphore, which is essentially an alias for std::counting_semaphore semaphore(1). For example,

#include <chrono>
#include <iostream>
#include <thread>
#include <semaphore>
#include <vector>

// Semaphores are initialized to un-signaled state
std::binary_semaphore signalMainToThread{ 0 };
std::binary_semaphore signalThreadToMain{ 0 };

void work() {
// Wait to start until main thread sends signal
signalMainToThread.acquire();

std::cout << "Received signal from main thread" << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(2));

std::cout << "Sending signal to main thread" << std::endl;

// Signal main thread that worker is done
signalThreadToMain.release();
}

int main() {
std::jthread worker(work);

std::cout << "Sending signal to worker thread" << std::endl;

// Signal worker thread to start working by increasing semaphore count to 1
signalMainToThread.release();

// Wait until worker thread is done by trying to decrease semaphore count
signalThreadTomain.acquire();

std::cout << "Received signal from worker thread" << std::endl;

return 0;
}

In this modified example from the C++ reference, binary semaphores are used in the main and worker threads to signal to each other when work should be started and when work is finished.

But wait, didn’t I imply that semaphores are used instead of mutexes when there are more than one resource? So if std::binary_semaphore only uses one resource, then how is this different than a mutex? The answer is that their purposes are completely different — sure, you can use a mutex to achieve a similar result but it doesn’t really make sense.

Conclusion

As mentioned previously, a mutex provides exclusive access to a single shared resource by giving a thread ownership of a lock, thus protecting the critical section. So, if you need multiple threads to write to a shared resource, then mutual exclusion is necessary to prevent data races. However, if you don’t need to protect a critical section, then consider light-weight semaphores for thread synchronization. If a thread simply needs to wait for a signal from another thread, then consider std::binary_semaphore. If a thread is waiting to access any resource from a limited pool of resources, then consider using std::counting_semaphore.

--

--