zurück zum Artikel

Semaphoren in C++20

Rainer Grimm

Semaphoren bieten sich an, um den gemeinsamen Zugriff auf geteilte Ressourcen zu koordinieren. ZusÀtzlich lÀsst sich mit ihnen Ping-Pong spielen.

Semaphoren bieten sich an, um den gemeinsamen Zugriff auf geteilte Ressourcen zu koordinieren. ZusÀtzlich lÀsst sich mit ihnen Ping-Pong spielen.

Semphoren in C++20

Eine zĂ€hlende Semaphore ist eine spezielle Semaphore, die einen ZĂ€hler grĂ¶ĂŸer Null besitzt. Dieser ZĂ€hler wird im Konstruktor initialisiert. Das Anfordern der Semaphore dekrementiert und das ihr Freigeben inkrementiert den ZĂ€hler. Wenn ein Thread versucht, die Semaphore anzufordern, falls der ZĂ€hler den Wert Null besitzt, wird dieser Thread blockiert, bis ein anderer Thread die Semaphore freigibt, indem er den ZĂ€hler inkrementiert.

Der niederlĂ€ndische Informatiker Edsger W. Dijkstra [1]stellte 1965 das Konzept einer Semaphore vor. Eine Semaphore ist eine Datenstruktur mit einer Queue und einem ZĂ€hler. Der ZĂ€hler wird auf einen Wert grĂ¶ĂŸer oder gleich Null initialisiert. Sie unterstĂŒtzt die zwei Operationen wait und signal. Erstere fordert die Semaphore an und dekrementiert den ZĂ€hler. In diesem Fall wird der ausfĂŒhrende Thread blockiert, falls die Semaphore den Wert Null besitzt. signal gibt die Semaphore frei und inkrementiert den ZĂ€hler. Blockierte Threads werden zur Queue hinzugefĂŒgt, um Starvation [2] zu vermeiden.

Der Begriff Semaphore steht ursprĂŒnglich fĂŒr ein Eisenbahnsignal.

C++20 unterstĂŒtzt eine std::binary_semaphore, die ein Alias auf eine std::counting_semaphore<1> ist. In diesem Fall ist der maximale Wert fĂŒr den ZĂ€hler 1. Eine std::binary_semaphore kann dazu verwendet werden, einen Lock [3] zu implementieren:

using binary_semaphore = std::counting_semaphore<1>;

Im Gegensatz zu einem std::mutex ist eine std::counting_semaphore nicht an einen Thread gebunden. Das heißt, dass das Anfordern und Freigeben der Semaphore in verschiedenen Threads stattfinden kann. Die folgende Tabelle stellt das Interface einer std::counting_semaphore vor:

Semphoren in C++20

Der Aufruf des Konstruktors std::counting_semaphore<10> sem(5) erzeugt die Semaphore sem, die maximal den Wert 10 annehmen kann. Der ZĂ€hler besitzt den Wert 5. sem.max() gibt den maximalen Wert fĂŒr den ZĂ€hler zurĂŒck.try_aquire_for(relTime) benötigt eine Zeitdauer, die Funktion sem.try_acquire_until(absTime) einen Zeitpunkt. In meinen Artikeln zur Zeitbibliothek lassen sich die Details zu Zeitdauern und Zeitpunkten genauer nachlesen: time [4]. Die drei Aufrufe sem.try_acquire, sem.try_acquire_for und sem.try_acquire_until geben einen Wahrheitswert zurĂŒck, der den Erfolg der Operation anzeigt.

Semaphoren werden typischerweise in Sender-EmpfĂ€nger-AblĂ€ufen verwendet. Wird zum Beispiel eine Semaphore auf 0 initialisiert, blockiert der EmpfĂ€nger-Aufruf sem.acquire(), bis der Sender sem.release() ausfĂŒhrt. Damit wartet der EmpfĂ€nger auf die Benachrichtigung des Senders. Die einmalige Synchronisation von Threads lĂ€sst sich einfach mit Semaphoren umsetzen:

// threadSynchronizationSemaphore.cpp

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

std::vector<int> myVec{};

std::counting_semaphore<1> prepareSignal(0); // (1)

void prepareWork() {

myVec.insert(myVec.end(), {0, 1, 0, 3});
std::cout << "Sender: Data prepared." << '\n';
prepareSignal.release(); // (2)
}

void completeWork() {

std::cout << "Waiter: Waiting for data." << '\n';
prepareSignal.acquire(); // (3)
myVec[2] = 2;
std::cout << "Waiter: Complete the work." << '\n';
for (auto i: myVec) std::cout << i << " ";
std::cout << '\n';

}

int main() {

std::cout << '\n';

std::thread t1(prepareWork);
std::thread t2(completeWork);

t1.join();
t2.join();

std::cout << '\n';

}

Die std::counting_semaphore prepareSignal (Zeile 1) kann die Werte 0 oder 1 besitzen. Im konkreten Anwendungsfall wird sie auf 0 (Zeile 1) initialisiert. Das heißt, dass der Aufruf prepareSignal.release() den Wert auf 1 (Zeile 2) setzt und den Aufruf prepareSignal.acquire() entblockt (Zeile 3).

Semphoren in C++20

Nun möchte ich einen Performanztest mit einem Ping-Pong-Spiel durchfĂŒhren.

In meinem letzten Artikel "Performanzvergleich von Bedingungsvariablen und Atomics in C++20 [5]" implementierte ich ein Ping-Pong-Spiel. Das war die zentrale Idee des Spiels: Ein Thread fĂŒhrt eine ping- und ein anderer eine pong-Funktion aus. Der Ping-Thread wartet bei dem Spiel auf die Benachrichtigung des Pong-Threads. Der Pong-Thread schickt wiederum die Benachrichtigung zurĂŒck. Das Spiel soll nach 1.000.000 Ballwechseln beendet sein. Jedes Spiel fĂŒhre ich fĂŒnfmal aus, um vergleichbare Performanzzahlen zu erhalten. Los geht das Spiel:

// pingPongSemaphore.cpp

#include <iostream>
#include <semaphore>
#include <thread>

std::counting_semaphore<1> signal2Ping(0); // (1)
std::counting_semaphore<1> signal2Pong(0); // (2)

std::atomic<int> counter{};
constexpr int countlimit = 1'000'000;

void ping() {
while(counter <= countlimit) {
signal2Ping.acquire(); // (5)
++counter;
signal2Pong.release();
}
}

void pong() {
while(counter < countlimit) {
signal2Pong.acquire();
signal2Ping.release(); // (3)
}
}

int main() {

auto start = std::chrono::system_clock::now();

signal2Ping.release(); // (4)
std::thread t1(ping);
std::thread t2(pong);

t1.join();
t2.join();

std::chrono::duration<double> dur = std::chrono::system_clock::now() - start;
std::cout << "Duration: " << dur.count() << " seconds" << '\n';

}

Das Programm pingPongsemaphore.cpp verwendet zwei Semaphoren: signal2Ping und signal2Pong (1 und 2). Beide können die Werte 0 und 1 annehmen und werden auf 0 initialisiert. Das bedeutet, wenn der Wert des ZĂ€hlers von signal2Ping 0 ist, dass der Aufruf signal2Ping.release() (3 und 4) den ZĂ€hler auf 1 setzt und damit eine Benachrichtigung darstellt. Der Aufruf signal2Ping.acquire() (5) blockiert, bis der ZĂ€hler den Wert 1 hat. Die entsprechende Argumentation gilt fĂŒr die zweite Semaphore signal2Pong.

Semphoren in C++20

Im Durchschnitt benötigt die AusfĂŒhrung des Spiels 0,33 Sekunden.

Gerne möchte ich die Performanzzahlen aus meinem letzten Artikel "Performanzvergleich von Bedingungsvariablen und Atomics in C++20 [6]" ins Spiel bringen.

std::condition_variable stellt die langsamste und std::atomic_flag die schnellste Art dar, Threads zu synchronisieren. Die Performanz von std::atomic liegt dazwischen. std::atomic besitzt einen großen Nachteil. Der Standard sichert nicht zu, dass diese lock-frei implementiert sind. std::atomic_flag ist die einzige, garantiert lock-freie atomare Variable. Semaphoren haben mich am meisten beeindruckt, denn sie sind fast so schnell wie std::atomic_flag.

Dieser Artikel basiert auf AuszĂŒgen meines englischen Buchs zu C++20. Es ist mittlerweile zu 70 Prozent abgeschlossen und auf LeanPub [7] in mehreren digitalen Formaten erhĂ€ltlich. NatĂŒrlich erhĂ€ltst du automatisch jedes Update des Buchs.

Semphoren in C++20

Mit Latches und Barriers erhalten wir mehr Koordinationsdatentypen in C++20. In meinem nÀchsten Artikel schaue ich mir diese genauer an. ( [8])


URL dieses Artikels:
https://www.heise.de/-5026449

Links in diesem Artikel:
[1] https://de.wikipedia.org/wiki/Edsger_W._Dijkstra
[2] https://en.wikipedia.org/wiki/Starvation_(computer_science)
[3] https://en.cppreference.com/w/cpp/named_req/BasicLockable
[4] https://www.grimm-jaud.de/index.php/blog/tag/time
[5] https://heise.de/-5019237
[6] https://heise.de/-5019237
[7] https://leanpub.com/c20
[8] mailto:rainer@grimm-jaud.de