- Verwendete Werkzeuge
- Allgemeines
- Runtime versus Compile-Time
- Wann sollte dieses Muster verwendet werden?
- Entwurf des APIs
- Hüllenklasse
StrategizedLocking - Mögliche Realisierungen der
ILock-Schnittstelle - Ein Beispiel: Klasse
ThreadsafeStack<T> - Vier Anwendungsbeispiele der Klasse
ThreadsafeStack<T> - Literaturhinweise
- Klassen
std::mutexundstd::recursive_mutex - Klasse
std::thread - Klasse
std::shared_lock
ILock.h.
StrategizedLock.h.
ThreadsafeStack.h.
PrimeCalculator.h.
StrategizedLock.cpp.
PrimeNumbers.cpp.
Das Strategized Locking–Entwurfsmuster schützt den kritischen Abschnitt einer Komponente vor gleichzeitigem (konkurrierendem) Zugriff.
Mit anderen Worten: Wenn ein Objekt Daten benötigt, die in einem anderen Thread erstellt werden, müssen die kritischen Abschnitte für den Datenzugriff gesperrt werden.
Hierbei können sich allerdings Unterschiede in der Notwendigkeit des Datenschutzes ergeben.
Laufen die kritischen Routinen (Abschnitte) in einer Single-Threaded-Umgebung, ist ein Datenschutz überhaupt nicht erforderlich und es entsteht ein Performanzproblem, wenn laufzeitintensive Synchronisationsmechanismen zum Einsatz kämen, die hier unnötig wären.
Werden die kritischen Abschnitte hingegen im Kontext mehrerer Threads ausgeführt (Multi-Threaded-Umgebung), müssen diese geschützt werden.
An dieser Stelle bietet sich das Strategized Locking–Entwurfsmuster an:
Die Strategie des Lockings wird in einem Objekt untergebracht, das Programm selbst greift nur über eine definierte Schnittstelle auf das Strategie-Objekt zu. Das Strategie-Objekt hat Kenntnisse über den Kontext, in dem das Programm abläuft.
Das Strategized Locking–Entwurfsmuster kann sowohl in einer Runtime-Ausprägung (Vererbung, Polymorphismus) als auch in einer Compile-Time-Ausprägung (Templates) umgesetzt werden.
Verwenden Sie dieses Muster, wenn
- Sie einen kritischen Abschnitt schützen müssen.
- Sie konsistent zur Verwendung eines
std::mutex-Objekssein möchten. - das Programm sowohl in Single-Threaded- als auch in Multi-Threaded-Umgebungen performant ablaufen soll.
Die Herstellung der Konsistenz in Bezug auf die Verwendung eines std::mutex-Objekts
erfolgt durch eine Schnittstelle ILock:
01: class ILock
02: {
03: public:
04: ~ILock() {}
05:
06: virtual void lock() const = 0;
07: virtual void unlock() const = 0;
08: };Die kritischen Abschnitte schützen sich nicht direkt mit einem std::mutex-Objekt,
sondern einer Wrapper-Klasse StrategizedLocking:
01: class StrategizedLocking
02: {
03: private:
04: const ILock& m_lock;
05:
06: public:
07: StrategizedLocking(const ILock&) : m_lock{ lock } {
08: m_lock.lock();
09: }
10:
11: ~StrategizedLocking() {
12: m_lock.unlock();
13: }
14: };Wir erkennen, dass Aufrufe einer lock- und unlock-Methode im Konstruktor bzw. Destruktor der
StrategizedLocking-Klasse durchgeführt werden.
Wer führt diese Methodenaufrufe konkret durch?
Das entscheidet der Ersteller eines StrategizedLocking-Objekts, in dem er bei der Objekterzeugung (Konstruktor)
eine Realisierung der ILock-Schnittstelle bereitstellt.
Wir betrachten drei mögliche Realisierungen der ILock-Schnittstelle:
01: class NoLock : public ILock
02: {
03: public:
04: void lock() const override {};
05: void unlock() const override {};
06: };
07:
08: class ExclusiveLock : public ILock
09: {
10: private:
11: mutable std::mutex m_mutex;
12:
13: public:
14: void lock() const override { m_mutex.lock(); };
15: void unlock() const override { m_mutex.unlock(); };
16: };
17:
18: class RecursiveLock : public ILock
19: {
20: private:
21: mutable std::recursive_mutex m_recursive_mutex;
22:
23: public:
24: void lock() const override { m_recursive_mutex.lock(); };
25: void unlock() const override { m_recursive_mutex.unlock(); };
26: };Ein Beispiel: Klasse ThreadsafeStack<T>
Betrachten wir eine Anpassung der Klasse ThreadsafeStack<T> an
das Strategized Locking–Entwurfsmuster:
01: template<typename T>
02: class ThreadsafeStack
03: {
04: private:
05: std::stack<T> m_data;
06: const ILock& m_lock;
07:
08: public:
09: // c'tors
10: ThreadsafeStack(const ILock& lock) : m_lock{lock} {}
11:
12: // public interface
13: void push(const T& value)
14: {
15: StrategizedLocking guard{ m_lock };
16: m_data.push(value);
17: }
18:
19: void pop(T& value)
20: {
21: StrategizedLocking guard{ m_lock };
22: if (m_data.empty()) throw std::out_of_range{ "Stack is empty!" };
23: value = m_data.top();
24: m_data.pop();
25: }
26:
27: T tryPop()
28: {
29: StrategizedLocking guard{ m_lock };
30: if (m_data.empty()) throw std::out_of_range{ "Stack is empty!" };
31: T value = m_data.top();
32: m_data.pop();
33: return value;
34: }
35:
36: size_t size() const
37: {
38: StrategizedLocking guard{ m_lock };
39: return m_data.size();
40: }
41:
42: bool empty() const
43: {
44: StrategizedLocking guard{ m_lock };
45: return m_data.empty();
46: }
47: };Wir betrachten eine Instanz der Klasse ThreadsafeStack<size_t>.
Zum Schutze des konkurrierenden Zugriffs werden die beiden Klassen NoLock bzw. ExclusiveLock herangezogen.
Wir messen im Release-Modus die Laufzeit mit folgendem Code-Fragment und einem Wert von 1'000'000 für MaxIterations:
for (size_t i = 0; i != MaxIterations; ++i) {
stack.push(i);
size_t value{};
stack.pop(value);
}Ergebnis:
Verwendung der Klasse NoLock:
[1]: Calling push 1000000 times:
[1]: Done: 7.006 msecs.
Verwendung der Klasse ExclusiveLock:
[1]: Calling push 1000000 times:
[1]: Done: 39.0376 msecs.
Wir simulieren einen Vergleich der beiden Klassen RecursiveLock und ExclusiveLock.
Hierzu bedarf es einer Änderung an der pop-Methode –
beispielsweise auf folgende Weise:
void pop(T& value)
{
StrategizedLocking guard{ m_lock };
// in case of testing recursive lock
if (empty()) { // <== simulating nested locked call (!)
std::cout << "Emtpy Stack !";
}
if (m_data.empty()) {
throw std::out_of_range{ "Stack is empty!" };
}
value = m_data.top();
m_data.pop();
}Welche Beobachtungen machen Sie bei der Ausführung des Programms?
Wir führen das folgende Beispiel aus:
01: void test_strategized_locking_03()
02: {
03: NoLock lock;
04: // vs.
05: // ExclusiveLock lock;
06:
07: ThreadsafeStack<size_t> primes{ lock };
08:
09: PrimeCalculator<size_t> calc{ primes, Globals::LowerLimit, Globals::UpperLimit + 1 };
10:
11: const auto startTime{ std::chrono::high_resolution_clock::now() };
12:
13: std::thread calculator(calc);
14: calculator.join();
15: }Es ist offensichtlich, welche der beiden Klassen RecursiveLock bzw. NoLock
zum Einsatz kommen sollte und welche nicht:
Nun betrachten wir das folgende Beispiel:
01: void test_strategized_locking_04()
02: {
03:
04: NoLock lock;
05: // vs.
06: // ExclusiveLock lock;
07:
08: ThreadsafeStack<size_t> primes{ lock };
09:
10: std::vector<std::thread> threads;
11: threads.reserve(Globals::NumThreads);
12:
13: size_t range = (Globals::UpperLimit - Globals::LowerLimit) / Globals::NumThreads;
14: size_t start = Globals::LowerLimit;
15: size_t end = start + range;
16:
17: const auto startTime{ std::chrono::high_resolution_clock::now() };
18:
19: // setup threads
20: for (size_t i{}; i != Globals::NumThreads - 1; ++i) {
21:
22: PrimeCalculator<size_t> calc{ primes, start, end };
23: threads.emplace_back(calc);
24:
25: start = end;
26: end = start + range;
27: }
28:
29: // setup last thread
30: end = Globals::UpperLimit;
31: PrimeCalculator<size_t> calc{ primes, start, end + 1 };
32: threads.emplace_back(calc);
33:
34: // wait for end of all threads
35: for (size_t i{}; i != Globals::NumThreads; ++i) {
36: threads[i].join();
37: }
38: }Wiederum stellt sich die Frage, welche der beiden Klassen
NoLock bzw. ExclusiveLock
zum Einsatz kommen sollte.
Welche Beobachtung können Sie machen, wenn die falsche Klasse verwendet wird?
Die Anregungen zu diesen Erläuterungen stammen im Wesentlichen aus der Unterlage
Concurrency with Modern C++ von Rainer Grimm.