Semaphoren in C++20
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.
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.
Wer hat's erfunden?
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.
ZĂ€hlende Semaphoren in C++20
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:
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).
Nun möchte ich einen Performanztest mit einem Ping-Pong-Spiel durchfĂŒhren.
Ein Ping-Pong-Spiel
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.
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.
Alle Zahlen
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.
Mein C++20 Buch
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.
Wie geht's weiter?
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
Copyright © 2021 Heise Medien