0

I read the "Little Book Of Semaphores" by Allen B. Downey and realized that I have to implement a semaphore in C++ first, as they appear as apart of the standard library only in C++20.

I used the definition from the book:

A semaphore is like an integer, with three differences:

  1. When you create the semaphore, you can initialize its value to any integer, but after that the only operations you are allowed to perform are increment (increase by one) and decrement (decrease by one). You cannot read the current value of the semaphore.
  2. When a thread decrements the semaphore, if the result is negative, the thread blocks itself and cannot continue until another thread increments the semaphore.
  3. When a thread increments the semaphore, if there are other threads wait- ing, one of the waiting threads gets unblocked.

I also used the answers to the question C++0x has no semaphores? How to synchronize threads?

My implementation is a bit different from those by the link, as I unlock my mutex before notifying a thread on signalling and also the definition by the book is a bit different.

So I implemented the semaphore and now I've realized that I don't know how I can really properly test it, except for the simplest case like e.g. sequentializing two calls for two threads. Is there any way to test the implementation like kinda using 100 threads or something like this and having no deadlock? I mean what test I should write to check the implementation? Or if the only way to check is to look through the code attentively, could you, maybe, please check?

My implementation:

// semaphore.h
#include <condition_variable>
#include <mutex>

namespace Semaphore
{

class CountingSemaphore final
{
public:
    explicit CountingSemaphore(int initialValue = 0);

    void signal();

    void wait();

private:
    std::mutex _mutex{};
    std::condition_variable _conditionVar{};
    int _value;
};

} // namespace Semaphore
// semaphore.cpp
#include "semaphore.h"

namespace Semaphore
{

CountingSemaphore::CountingSemaphore(const int initialValue) : _value(initialValue) {}

void CountingSemaphore::signal()
{
    std::unique_lock<std::mutex> lock{_mutex};

    ++_value;

    if (0 == _value)
    {
        lock.unlock();
        _conditionVar.notify_one();
    }
}

void CountingSemaphore::wait()
{
    std::unique_lock<std::mutex> lock{_mutex};

    --_value;

    if (0 > _value)
    {
        _conditionVar.wait(lock, [this]() { return (0 <= this->_value); });
    }
}

} // namespace Semaphore
JenyaKh
  • 2,040
  • 17
  • 25
  • Why on earth would you create your own (other than as a learning exercise)? Just use what experts in the field have already created for you. – Jesper Juhl Jul 28 '22 at 14:33
  • @JesperJuhl: If you're sticking to standard C++ without third-party libraries, there's no other option pre-C++20? It's a decent learning exercise anyway. – ShadowRanger Jul 28 '22 at 14:34
  • 1
    You would need to mimic those situations that are relevant for your semaphore, you could try by letting threads sleep appropriately, e.g. start two threads, let first one acquire the semaphore immediately, sleep for 1000 ms and then print some output right before releasing it; let second one sleep 500 ms immediately, then acquire the mutex and output another text. If you see second thread's output before first one's then something went wrong... – Aconcagua Jul 29 '22 at 10:47
  • @Aconcagua, yes, I am currently considering what I am going to do with it. Probably, I should consider the cases when the number of resources are less than, equal, and grater than those of threads. And then also I need some permutation on the order in which they signal/wait. Not sure yet, if it's feasible, but will give it a try. Thank you for your comment! – JenyaKh Jul 29 '22 at 13:18
  • 1
    You might consider a unit-testing frame work like e.g. [cxx-test](http://cxxtest.com/) and automate the entire process. At the same time you'd implicitly document what you're actually testing... – Aconcagua Jul 29 '22 at 13:24
  • Have just discovered that this is an acceptable test for a semaphore: https://en.cppreference.com/w/cpp/thread/counting_semaphore/acquire (i.e. example section) -- for those interested in the future. – JenyaKh Jul 30 '22 at 14:21

1 Answers1

3

This code is broken. Your current state machine uses negative numbers to indicate a number of waiters, and non-negative indicates remaining capacity (with 0 being no availability, but no waiters either).

Problem is, you only notify waiters when the count becomes zero. So if you had a semaphore with initial value 1 (basically a logical mutex), and five threads try to grab it, one gets it, and four others wait, at which point value is -4. When the thread that grabbed it finishes and signals, value rises to -3, but that's not 0, so notify_one is not called.

In addition, you have some redundant code. The predicate-based form of std::condition_variable's wait is equivalent to:

while (!predicate()) {
    wait(lock);
}

so your if check is redundant (wait will check the same information before it actually waits, even once, anyway).

You could also condense the code a bit by having the increment and decrement on the same line you test them (it's not necessary since you're using mutexes to protect the whole block, not relying on atomics, but I like writing threaded code in a way that would be easier to port to atomics later, mostly to keep in the habit when I write actual atomics code). Fixing the bug and condensing the code gets this end result:

void CountingSemaphore::signal()
{
    std::unique_lock<std::mutex> lock{_mutex};

    if (0 >= ++_value) // If we were negative, had at least one waiter, notify one of them; personally, I'd find if (value++ < 0) clearer as meaning "if value *was* less than 0, and also increment it afterwards by side-effect, but I stuck to something closer to your design to avoid confusion
    {
        lock.unlock();
        _conditionVar.notify_one();
    }
}

void CountingSemaphore::wait()
{
    std::unique_lock<std::mutex> lock{_mutex};

    --_value;
    _conditionVar.wait(lock, [this]() { return 0 <= this->_value; });
}

The alternative approach would be adopting a design that doesn't drop the count below 0 (so 0 means "has waiters" and otherwise the range of values is just 0 to initialValue). This is safer in theoretical circumstances (you can't trigger wraparound by having 2 ** (8 * sizeof(int) - 1) waiters). You could minimize that risk by making value a ssize_t (so 64 bit systems would be exponentially less likely to hit the bug), or by changing the design to stop at 0:

// value's declaration in header and argument passed to constructor may be changed
// to an unsigned type if you like

void CountingSemaphore::signal()
{
    // Just for fun, a refactoring to use lock_guard rather than unique_lock
    // since the logical unlock is unconditionally after the value read/modify
    int oldvalue;  // Type should match that of value
    {
        std::lock_guard<std::mutex> lock{_mutex};
        oldvalue = value++;
    }
    if (0 == oldvalue) _conditionVar.notify_one();
}

void CountingSemaphore::wait()
{
    std::unique_lock<std::mutex> lock{_mutex};

    // Waits only if current count is 0, returns with lock held at which point
    // it's guaranteed greater than 0
    _conditionVar.wait(lock, [this]() { return 0 != this->_value; });

    --value;  // We only decrement when it's positive, just before we return to caller
}

This second design has a minor flaw, in that it calls notify_one when signaled with no available resources, but no available resources might mean "has waiters" or it might mean "all resources consumed, but no one waiting". This isn't actually a problem logically speaking though; calling notify_one with no waiters is legal and does nothing, though it may be slightly less efficient. Your original design may be preferable on that basis (it doesn't notify_one unless there were definitely waiters).

ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • I don't agree. First, afaiu from the book, the binary semaphore here has a value 0. – JenyaKh Jul 28 '22 at 14:43
  • Well, with the rest I probably agree. Should think about it. Do you have an idea on how the code should be modified? Maybe, I should remove the if altogether? Just unlock and notify? – JenyaKh Jul 28 '22 at 14:46
  • Just don't understand if any thread should notify or under a condition. – JenyaKh Jul 28 '22 at 14:47
  • And even if I notify, none of the sleeping threads will wake up, as their predicates will not be satisfied, as the value is still negative – JenyaKh Jul 28 '22 at 14:54
  • I think the mistake is that all other threads, except for one (i mean binary case here) decrement. They should not. – JenyaKh Jul 28 '22 at 15:09
  • @JenyaKh: If the initial value is `0`, then your code, as written, will block on all `wait`s until `signal` is called at least once. That's fine, but `0` or less means "must wait" and less than `0` means "has waiters" in that design. The book's approach is clearly different from yours, which is fine, but I can't address the book's perspective. Semaphores can distinguish maximum count from initial count (so the book might be saying "initial value is zero" which means "`wait` always blocks until `signal` called at least once", meaning there is no "initial capacity" at all). – ShadowRanger Jul 28 '22 at 15:14
  • @JenyaKh (cont.) To make a logical mutex (one that is initially "unheld"), your design would in fact require `initialValue` of `1`). On dropping the condition, don't; the condition is good and saves some work (`notify_one` is legal even when noone is waiting, but best to avoid it if you know it's pointless; it likely has *some* overhead). ; If performance doesn't matter, go ahead and drop the condition (you can simplify the body of `signal` to: `{ std::lock_guard lock{_mutex}; ++value; } _conditionVar.notify_one();`), but it's best to avoid it. – ShadowRanger Jul 28 '22 at 15:15
  • Thank you. I will try to improve the solution. I want it to be like in the book. And will try to come up with a solution that does not always call notify_one. Will write an update or a new question. – JenyaKh Jul 28 '22 at 15:24
  • @JenyaKh: I posted some example fixes, one that keeps the negative == "has abs(value) waiters", non-negative == "has value available resources and no waiters" (0 means no resources, but no waiters either), one that simplifies to "0 == no available resources (maybe has waiters), positive == value available resources). The decrement has to happen, but if you want the value to always represent how many resources are available, you decrement *after* the condition is satisfied (meaning you have at least one resource, which you are now taking), rather than before, that's all. – ShadowRanger Jul 28 '22 at 15:24
  • @JenyaKh: The one in the book is almost certainly the version where a semaphore has no initial resources to hand out (so the binary semaphore with initial value `0` is equivalent to a mutex created in a *held* state, not an unheld state); in a producer/consumer pattern for instance, this is reasonable (until a producer has produced something and signaled, all consumers must wait). In other cases (a pool of network connections for reuse for instance), you'd want a > 0 initial value (`wait`s can satisfy immediately until all connections are in use). – ShadowRanger Jul 28 '22 at 15:29
  • C++20's `std::binary_semaphore` supports both; it's templated on the maximum value (signaling it enough to increase the value above that maximum is undefined behavior), but it's constructed with a specific desired value (pass it `0` to make like a held mutex, 1 to make it like an unheld mutex). – ShadowRanger Jul 28 '22 at 15:32
  • What do you mean by "signaling it enough to increase the value above that maximum is undefined behavior"? Is your implementation only for an unheld mutex? – JenyaKh Jul 28 '22 at 15:36
  • @JenyaKh: I'm talking about the C++20 built-in. `std::counting_semaphore` is templated on a `ptrdiff_t LeastMaxValue`. `std::binary_semaphore` is an alias to `std::counting_semaphore<1>` (which may be specialized to optimize for that specific threshold). If you did `std::binary_semaphore sem{1};` (has one available resource) and `std::binary_semaphore::max` was in fact `1` (it's allowed to be any value `>= 1`), and immediately called `sem.release()` before anyone had called `sem.acquire()` (and family), that would raise the value to `2`, violating the max value of `1`; bam, nasal demons. – ShadowRanger Jul 28 '22 at 15:56
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/246863/discussion-between-shadowranger-and-jenyakh). – ShadowRanger Jul 28 '22 at 15:59