Język C++ i przetwarzanie współbieżne w akcji 9788324671403, 9788324650866, 8324650865, 8324671404

Współbieżne przetwarzanie danych to największe wyzwanie dla programisty. Na każdym kroku czyhają na niego najbardziej wy

158 100 5MB

Polish Pages 576 Year 2013

Report DMCA / Copyright

DOWNLOAD PDF FILE

Table of contents :
Spis treści
Słowo wstępne
Podziękowania
O tej książce
Plan lektury
Kto powinien przeczytać tę książkę
Jak korzystać z tej książki
Konwencje stosowane w kodzie i pliki do pobrania
Wymagane oprogramowanie
Author Online
Rozdział 1. Witaj, świecie współbieżności w C++!
1.1. Czym jest współbieżność?
1.1.1. Współbieżność w systemach komputerowych
1.1.2. Modele współbieżności
1.2. Dlaczego warto stosować współbieżność?
1.2.1. Stosowanie współbieżności do podziału zagadnień
1.2.2. Stosowanie współbieżności do podniesienia wydajności
1.2.3. Kiedy nie należy stosować współbieżności
1.3. Współbieżność i wielowątkowość w języku C++
1.3.1. Historia przetwarzania wielowątkowego w języku C++
1.3.2. Obsługa współbieżności w nowym standardzie
1.3.3. Efektywność biblioteki wątków języka C++
1.3.4. Mechanizmy związane z poszczególnymi platformami
1.4. Do dzieła!
1.4.1. „Witaj świecie współbieżności”
1.5. Podsumowanie
Rozdział 2. Zarządzanie wątkami
2.1. Podstawowe zarządzanie wątkami
2.1.1 Uruchamianie wątku
2.1.2. Oczekiwanie na zakończenie wątku
2.1.3. Oczekiwanie w razie wystąpienia wyjątku
2.1.4. Uruchamianie wątków w tle
2.2. Przekazywanie argumentów do funkcji wątku
2.3. Przenoszenie własności wątku
2.4. Wybór liczby wątków w czasie wykonywania
2.5. Identyfikowanie wątków
2.6. Podsumowanie
Rozdział 3. Współdzielenie danych przez wątki
3.1. Problemy związane ze współdzieleniem danych przez wątki
3.1.1. Sytuacja wyścigu
3.1.2. Unikanie problematycznych sytuacji wyścigu
3.2. Ochrona współdzielonych danych za pomocą muteksów
3.2.1. Stosowanie muteksów w języku C++
3.2.2. Projektowanie struktury kodu z myślą o ochronie współdzielonych danych
3.2.3. Wykrywanie sytuacji wyścigu związanych z interfejsami
3.2.4. Zakleszczenie: problem i rozwiązanie
3.2.5. Dodatkowe wskazówki dotyczące unikania zakleszczeń
3.2.6. Elastyczne blokowanie muteksów za pomocą szablonu std::unique_lock
3.2.7. Przenoszenie własności muteksu pomiędzy zasięgami
3.2.8. Dobór właściwej szczegółowości blokad
3.3. Alternatywne mechanizmy ochrony współdzielonych danych
3.3.1. Ochrona współdzielonych danych podczas inicjalizacji
3.3.2. Ochrona rzadko aktualizowanych struktur danych
3.3.3. Blokowanie rekurencyjne
3.4. Podsumowanie
Rozdział 4. Synchronizacja współbieżnych operacji
4.1. Oczekiwanie na zdarzenie lub inny warunek
4.1.1. Oczekiwanie na spełnienie warunku za pomocą zmiennych warunkowych
4.1.2. Budowa kolejki gwarantującej bezpieczne przetwarzanie wielowątkowe przy użyciu zmiennych warunkowych
4.2. Oczekiwanie na jednorazowe zdarzenia za pomocą przyszłości
4.2.1. Zwracanie wartości przez zadania wykonywane w tle
4.2.2. Wiązanie zadania z przyszłością
4.2.3. Obietnice (szablon std::promise)
4.2.4. Zapisywanie wyjątku na potrzeby przyszłości
4.2.5. Oczekiwanie na wiele wątków
4.3. Oczekiwanie z limitem czasowym
4.3.1. Zegary
4.3.2. Okresy
4.3.3. Punkty w czasie
4.3.4. Funkcje otrzymujące limity czasowe
4.4. Upraszczanie kodu za pomocą technik synchronizowania operacji
4.4.1. Programowanie funkcyjne przy użyciu przyszłości
4.4.2. Synchronizacja operacji za pomocą przesyłania komunikatów
4.5. Podsumowanie
Rozdział 5. Model pamięci języka C++ i operacje na typach atomowych
5.1. Podstawowe elementy modelu pamięci
5.1.1. Obiekty i miejsca w pamięci
5.1.2. Obiekty, miejsca w pamięci i przetwarzanie współbieżne
5.1.3. Kolejność modyfikacji
5.2. Operacje i typy atomowe języka C++
5.2.1. Standardowe typy atomowe
5.2.2. Operacje na typie std::atomic_flag
5.2.3. Operacje na typie std::atomic
5.2.4. Operacje na typie std::atomic — arytmetyka wskaźników
5.2.5. Operacje na standardowych atomowych typach całkowitoliczbowych
5.2.6. Główny szablon klasy std::atomic
5.2.7. Wolne funkcje dla operacji atomowych
5.3. Synchronizacja operacji i wymuszanie ich porządku
5.3.1. Relacja synchronizacji
5.3.2. Relacja poprzedzania
5.3.3. Porządkowanie pamięci na potrzeby operacji atomowych
5.3.4. Sekwencje zwalniania i relacja synchronizacji
5.3.5. Ogrodzenia
5.3.6. Porządkowanie operacji nieatomowych
za pomocą operacji atomowych
5.4. Podsumowanie
Rozdział 6. Projektowanie współbieżnych struktur danych przy użyciu blokad
6.1. Co oznacza projektowanie struktur danych pod kątem współbieżności?
6.1.1. Wskazówki dotyczące projektowania
współbieżnych struktur danych
6.2. Projektowanie współbieżnych struktur danych przy użyciu blokad
6.2.1. Stos gwarantujący bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad
6.2.2. Kolejka gwarantująca bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad i zmiennych warunkowych
6.2.3. Kolejka gwarantująca bezpieczeństwo przetwarzania wielowątkowego przy użyciu szczegółowych blokad i zmiennych warunkowych
6.3. Projektowanie złożonych struktur danych przy użyciu blokad przy użyciu blokad
6.3.1. Implementacja tablicy wyszukiwania gwarantującej bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad
6.3.2. Implementacja listy gwarantującej bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad
6.4. Podsumowanie
Rozdział 7. Projektowanie współbieżnych struktur bez blokad
7.1. Definicje i ich praktyczne znaczenie
7.1.1. Rodzaje nieblokujących struktur danych
7.1.2. Struktury danych bez blokad
7.1.3. Struktury danych bez oczekiwania
7.1.4. Zalety i wady struktur danych bez blokad
7.2. Przykłady struktur danych bez blokad
7.2.1. Implementacja stosu gwarantującego bezpieczeństwo przetwarzania wielowątkowego bez blokad
7.2.2. Eliminowanie niebezpiecznych wycieków — zarządzanie pamięcią w strukturach danych bez blokad
7.2.3. Wykrywanie węzłów, których nie można odzyskać, za pomocą wskaźników ryzyka
7.2.4. Wykrywanie używanych węzłów metodą zliczania referencji
7.2.5. Zmiana modelu pamięci używanego przez operacje na stosie bez blokad
7.2.6. Implementacja kolejki gwarantującej bezpieczeństwo przetwarzania wielowątkowego bez blokad
7.3. Wskazówki dotyczące pisania struktur danych bez blokad
7.3.1. Wskazówka: na etapie tworzenia prototypu należy stosować tryb std::memory_order_seq_cst
7.3.2. Wskazówka: należy używać schematu odzyskiwania pamięci bez blokad
7.3.3 Wskazówka: należy unikać problemu ABA
7.3.4. Wskazówka: należy identyfikować pętle aktywnego oczekiwania i wykorzystywać czas bezczynności na wspieranie innego wątku
7.4. Podsumowanie
Rozdział 8. Projektowanie współbieżnego kodu
8.1. Techniki dzielenia pracy pomiędzy wątki
8.1.1. Dzielenie danych pomiędzy wątki przed rozpoczęciem przetwarzania
8.1.2. Rekurencyjne dzielenie danych
8.1.3. Dzielenie pracy według typu zadania
8.2. Czynniki wpływające na wydajność współbieżnego kodu
8.2.1. Liczba procesorów
8.2.2. Współzawodnictwo o dane i ping-pong bufora
8.2.3. Fałszywe współdzielenie
8.2.4. Jak blisko należy rozmieścić dane?
8.2.5. Nadsubskrypcja i zbyt intensywne przełączanie zadań
8.3. Projektowanie struktur danych pod kątem wydajności przetwarzania wielowątkowego
8.3.1. Podział elementów tablicy na potrzeby złożonych operacji
8.3.2. Wzorce dostępu do danych w pozostałych strukturach
8.4. Dodatkowe aspekty projektowania współbieżnych struktur danych
8.4.1. Bezpieczeństwo wyjątków w algorytmach równoległych
8.4.2. Skalowalność i prawo Amdahla
8.4.3. Ukrywanie opóźnień za pomocą wielu wątków
8.4.4. Skracanie czasu reakcji za pomocą technik
przetwarzania równoległego
8.5. Projektowanie współbieżnego kodu w praktyce
8.5.1. Równoległa implementacja funkcji std::for_each
8.5.2. Równoległa implementacja funkcji std::find
8.5.3. Równoległa implementacja funkcji std::partial_sum
8.6. Podsumowanie
Rozdział 9. Zaawansowane zarządzanie wątkami
9.1. Pule wątków
9.1.1. Najprostsza możliwa pula wątków
9.1.2. Oczekiwanie na zadania wysyłane do puli wątków
9.1.3. Zadania oczekujące na inne zadania
9.1.4. Unikanie współzawodnictwa w dostępie do kolejki zadań
9.1.5. Wykradanie zadań
9.2. Przerywanie wykonywania wątków
9.2.1. Uruchamianie i przerywanie innego wątku
9.2.2. Wykrywanie przerwania wątku
9.2.3. Przerywanie oczekiwania na zmienną warunkową
9.2.4. Przerywanie oczekiwania na zmienną typu std::condition_variable_any
9.2.5. Przerywanie pozostałych wywołań blokujących
9.2.6. Obsługa przerwań
9.2.7. Przerywanie zadań wykonywanych w tle podczas zamykania aplikacji
9.3. Podsumowanie
Rozdział 10. Testowanie i debugowanie aplikacji wielowątkowych
10.1. Rodzaje błędów związanych z przetwarzaniem współbieżnym
10.1.1. Niechciane blokowanie
10.1.2. Sytuacje wyścigu
10.2. Techniki lokalizacji błędów związanych z przetwarzaniem współbieżnym
10.2.1. Przeglądanie kodu w celu znalezienia ewentualnych błędów
10.2.2. Znajdowanie błędów związanych z przetwarzaniem współbieżnym poprzez testowanie kodu
10.2.3. Projektowanie kodu pod kątem łatwości testowania
10.2.4. Techniki testowania wielowątkowego kodu
10.2.5. Projektowanie struktury wielowątkowego kodu testowego
10.2.6. Testowanie wydajności wielowątkowego kodu
10.3. Podsumowanie
Dodatek A. Krótki przegląd wybranych elementów języka C++11
A.1. Referencje do r-wartości
A.1.1. Semantyka przenoszenia danych
A.1.2. Referencje do r-wartości i szablony funkcji
A.2. Usunięte funkcje
A.3. Funkcje domyślne
A.4. Funkcje constexpr
A.4.1. Wyrażenia constexpr i typy definiowane przez użytkownika
A.4.2. Obiekty constexpr
A.4.3. Wymagania dotyczące funkcji constexpr
A.4.4. Słowo constexpr i szablony
A.5. Funkcje lambda
A.5.1. Funkcje lambda odwołujące się do zmiennych lokalnych
A.6. Szablony zmiennoargumentowe
A.6.1. Rozwijanie paczki parametrów
A.7. Automatyczne określanie typu zmiennej
A.8. Zmienne lokalne wątków
A.9. Podsumowanie
Dodatek B. Krótkie zestawienie bibliotek przetwarzania współbieżnego
Dodatek C. Framework przekazywania komunikatów i kompletny przykład implementacji systemu bankomatu
Dodatek D. Biblioteka wątków języka C++
D.1. Nagłówek
D.1.1. Szablon klasy std::chrono::duration
D.1.2. Szablon klasy std::chrono::time_point
D.1.3. Klasa std::chrono::system_clock
D.1.4. Klasa std::chrono::steady_clock
D.1.5. Definicja typu std::chrono::high_resolution_clock
D.2. Nagłówek
D.2.1. Klasa std::condition_variable
D.2.2. Klasa std::condition_variable_any
D.3. Nagłówek
D.3.1. Definicje typów std::atomic_xxx
D.3.2. Makra ATOMIC_xxx_LOCK_FREE
D.3.3. Makro ATOMIC_VAR_INIT
D.3.4. Typ wyliczeniowy std::memory_order
D.3.5. Funkcja std::atomic_thread_fence
D.3.6. Funkcja std::atomic_signal_fence
D.3.7. Klasa std::atomic_flag
D.3.8. Szablon klasy std::atomic
D.3.9. Specjalizacje szablonu std::atomic
D.3.10. Specjalizacje szablonu std::atomic
D.4. Nagłówek
D.4.1. Szablon klasy std::future
D.4.2. Szablon klasy std::shared_future
D.4.3. Szablon klasy std::packaged_task
D.4.4. Szablon klasy std::promise
D.4.5. Szablon funkcji std::async
D.5. Nagłówek
D.5.1. Klasa std::mutex
D.5.2. Klasa std::recursive_mutex
D.5.3. Klasa std::timed_mutex
D.5.4. Klasa std::recursive_timed_mutex
D.5.5. Szablon klasy std::lock_guard
D.5.6. Szablon klasy std::unique_lock
D.5.7. Szablon funkcji std::lock
D.5.8. Szablon funkcji std::try_lock
D.5.9. Klasa std::once_flag
D.5.10. Szablon funkcji std::call_once
D.6. Nagłówek
D.6.1. Szablon klasy std::ratio
D.6.2. Alias szablonu std::ratio_add
D.6.3. Alias szablonu std::ratio_subtract
D.6.4. Alias szablonu std::ratio_multiply
D.6.5. Alias szablonu std::ratio_divide
D.6.6. Szablon klasy std::ratio_equal
D.6.7. Szablon klasy std::ratio_not_equal
D.6.8. Szablon klasy std::ratio_less
D.6.9. Szablon klasy std::ratio_greater
D.6.10. Szablon klasy std::ratio_less_equal
D.6.11. Szablon klasy std::ratio_greater_equal
D.7. Nagłówek
D.7.1. Klasa std::thread
D.7.2. Przestrzeń nazw std::this_thread
Materiały dodatkowe
Materiały drukowane
Materiały dostępne w internecie
Skorowidz
Recommend Papers

Język C++ i przetwarzanie współbieżne w akcji
 9788324671403, 9788324650866, 8324650865, 8324671404

  • 0 0 0
  • Like this paper and download? You can publish your own PDF file online for free in a few minutes! Sign Up
File loading please wait...
Citation preview

Tytuł oryginału: C++ Concurrency in Action: Practical Multithreading Tłumaczenie: Mikołaj Szczepaniak Projekt okładki: Anna Mitka Materiały graficzne na okładce zostały wykorzystane za zgodą Shutterstock Images LLC. ISBN: 978-83-246-7140-3 Original edition copyright © 2012 by Manning Publications Co. All rights reserved. Polish edition copyright © 2013 by HELION SA. All rights reserved. All rights reserved. No part of this book may be reproduced or transmitted in any form or by any means, electronic or mechanical, including photocopying, recording or by any information storage retrieval system, without permission from the Publisher. Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym lub innym powoduje naruszenie praw autorskich niniejszej publikacji. Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi ich właścicieli. Autor oraz Wydawnictwo HELION dołożyli wszelkich starań, by zawarte w tej książce informacje były kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Wydawnictwo HELION nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z wykorzystania informacji zawartych w książce. Wydawnictwo HELION ul. Kościuszki 1c, 44-100 GLIWICE tel. 32 231 22 19, 32 230 98 63 e-mail: [email protected] WWW: http://helion.pl (księgarnia internetowa, katalog książek) Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres http://helion.pl/user/opinie/jcpppw_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia, recenzję. Printed in Poland.

• Poleć książkę na Facebook.com

• Księgarnia internetowa

• Kup w wersji papierowej

• Lubię to! » Nasza społeczność

• Oceń książkę

Dla Kim, Hugh i Erin.

Spis treści Słowo wstępne .......................................................................................................................................... 11 Podziękowania .......................................................................................................................................... 13 O tej książce .............................................................................................................................................. 15

Rozdział 1. Witaj, świecie współbieżności w C++! 1.1. 1.2.

1.3.

1.4. 1.5.

Czym jest współbieżność? ........................................................................................................... 20 1.1.1. Współbieżność w systemach komputerowych ................................................................. 20 1.1.2. Modele współbieżności ..................................................................................................... 22 Dlaczego warto stosować współbieżność? ................................................................................. 25 1.2.1. Stosowanie współbieżności do podziału zagadnień ......................................................... 25 1.2.2. Stosowanie współbieżności do podniesienia wydajności ................................................. 26 1.2.3. Kiedy nie należy stosować współbieżności ....................................................................... 27 Współbieżność i wielowątkowość w języku C++ .................................................................... 28 1.3.1. Historia przetwarzania wielowątkowego w języku C++ ................................................ 29 1.3.2. Obsługa współbieżności w nowym standardzie ............................................................... 30 1.3.3. Efektywność biblioteki wątków języka C++ .................................................................. 30 1.3.4. Mechanizmy związane z poszczególnymi platformami ................................................... 32 Do dzieła! ...................................................................................................................................... 32 1.4.1. „Witaj świecie współbieżności” ......................................................................................... 32 Podsumowanie .............................................................................................................................. 34

Rozdział 2. Zarządzanie wątkami 2.1.

2.2. 2.3. 2.4. 2.5. 2.6.

3.2.

35

Podstawowe zarządzanie wątkami ............................................................................................. 36 2.1.1 Uruchamianie wątku .......................................................................................................... 36 2.1.2. Oczekiwanie na zakończenie wątku .................................................................................. 39 2.1.3. Oczekiwanie w razie wystąpienia wyjątku ....................................................................... 39 2.1.4. Uruchamianie wątków w tle .............................................................................................. 42 Przekazywanie argumentów do funkcji wątku ......................................................................... 43 Przenoszenie własności wątku .................................................................................................... 46 Wybór liczby wątków w czasie wykonywania ........................................................................... 49 Identyfikowanie wątków ............................................................................................................. 52 Podsumowanie .............................................................................................................................. 54

Rozdział 3. Współdzielenie danych przez wątki 3.1.

19

55

Problemy związane ze współdzieleniem danych przez wątki ................................................. 56 3.1.1. Sytuacja wyścigu ................................................................................................................ 58 3.1.2. Unikanie problematycznych sytuacji wyścigu .................................................................. 59 Ochrona współdzielonych danych za pomocą muteksów ........................................................ 60 3.2.1. Stosowanie muteksów w języku C++ ............................................................................. 60 3.2.2. Projektowanie struktury kodu z myślą o ochronie współdzielonych danych ................. 62

6

Spis treści

3.3.

3.4.

3.2.3. Wykrywanie sytuacji wyścigu związanych z interfejsami ................................................ 63 3.2.4. Zakleszczenie: problem i rozwiązanie .............................................................................. 71 3.2.5. Dodatkowe wskazówki dotyczące unikania zakleszczeń ................................................. 73 3.2.6. Elastyczne blokowanie muteksów za pomocą szablonu std::unique_lock ...................... 79 3.2.7. Przenoszenie własności muteksu pomiędzy zasięgami .................................................... 80 3.2.8. Dobór właściwej szczegółowości blokad .......................................................................... 82 Alternatywne mechanizmy ochrony współdzielonych danych ................................................ 84 3.3.1. Ochrona współdzielonych danych podczas inicjalizacji .................................................. 84 3.3.2. Ochrona rzadko aktualizowanych struktur danych .......................................................... 88 3.3.3. Blokowanie rekurencyjne .................................................................................................. 90 Podsumowanie .............................................................................................................................. 91

Rozdział 4. Synchronizacja współbieżnych operacji 4.1.

4.2.

4.3.

4.4. 4.5.

Rozdział 5. Model pamięci języka C++ i operacje na typach atomowych 5.1.

5.2.

5.3.

93

Oczekiwanie na zdarzenie lub inny warunek ........................................................................... 94 4.1.1. Oczekiwanie na spełnienie warunku za pomocą zmiennych warunkowych .................. 95 4.1.2. Budowa kolejki gwarantującej bezpieczne przetwarzanie wielowątkowe przy użyciu zmiennych warunkowych .............................................................................. 97 Oczekiwanie na jednorazowe zdarzenia za pomocą przyszłości ........................................... 102 4.2.1. Zwracanie wartości przez zadania wykonywane w tle ................................................... 103 4.2.2. Wiązanie zadania z przyszłością ...................................................................................... 106 4.2.3. Obietnice (szablon std::promise) ..................................................................................... 109 4.2.4. Zapisywanie wyjątku na potrzeby przyszłości ................................................................ 111 4.2.5. Oczekiwanie na wiele wątków ........................................................................................ 112 Oczekiwanie z limitem czasowym ............................................................................................ 115 4.3.1. Zegary ............................................................................................................................... 115 4.3.2. Okresy ............................................................................................................................... 117 4.3.3. Punkty w czasie ................................................................................................................ 118 4.3.4. Funkcje otrzymujące limity czasowe .............................................................................. 120 Upraszczanie kodu za pomocą technik synchronizowania operacji ..................................... 121 4.4.1. Programowanie funkcyjne przy użyciu przyszłości ....................................................... 122 4.4.2. Synchronizacja operacji za pomocą przesyłania komunikatów ..................................... 127 Podsumowanie ............................................................................................................................ 131

133

Podstawowe elementy modelu pamięci ................................................................................... 134 5.1.1. Obiekty i miejsca w pamięci ............................................................................................ 134 5.1.2. Obiekty, miejsca w pamięci i przetwarzanie współbieżne ............................................ 135 5.1.3. Kolejność modyfikacji ...................................................................................................... 136 Operacje i typy atomowe języka C++ .................................................................................... 137 5.2.1. Standardowe typy atomowe ............................................................................................ 138 5.2.2. Operacje na typie std::atomic_flag .................................................................................. 141 5.2.3. Operacje na typie std::atomic ............................................................................ 143 5.2.4. Operacje na typie std::atomic — arytmetyka wskaźników ................................. 146 5.2.5. Operacje na standardowych atomowych typach całkowitoliczbowych ........................ 147 5.2.6. Główny szablon klasy std::atomic ............................................................................. 147 5.2.7. Wolne funkcje dla operacji atomowych .......................................................................... 150 Synchronizacja operacji i wymuszanie ich porządku ............................................................ 151 5.3.1. Relacja synchronizacji ...................................................................................................... 152 5.3.2. Relacja poprzedzania ....................................................................................................... 154 5.3.3. Porządkowanie pamięci na potrzeby operacji atomowych ............................................ 155 5.3.4. Sekwencje zwalniania i relacja synchronizacji ............................................................... 175

Spis treści

5.4.

5.3.5. Ogrodzenia ....................................................................................................................... 178 5.3.6. Porządkowanie operacji nieatomowych za pomocą operacji atomowych ..................... 180 Podsumowanie ............................................................................................................................ 182

Rozdział 6. Projektowanie współbieżnych struktur danych przy użyciu blokad 6.1. 6.2.

6.3.

6.4.

7.2.

7.3.

7.4.

219

Definicje i ich praktyczne znaczenie ....................................................................................... 220 7.1.1. Rodzaje nieblokujących struktur danych ........................................................................ 220 7.1.2. Struktury danych bez blokad ........................................................................................... 221 7.1.3. Struktury danych bez oczekiwania ................................................................................. 222 7.1.4. Zalety i wady struktur danych bez blokad ...................................................................... 222 Przykłady struktur danych bez blokad .................................................................................... 223 7.2.1. Implementacja stosu gwarantującego bezpieczeństwo przetwarzania wielowątkowego bez blokad ............................................................................................ 224 7.2.2. Eliminowanie niebezpiecznych wycieków — zarządzanie pamięcią w strukturach danych bez blokad .................................................................................... 228 7.2.3. Wykrywanie węzłów, których nie można odzyskać, za pomocą wskaźników ryzyka ........ 233 7.2.4. Wykrywanie używanych węzłów metodą zliczania referencji .......................................... 242 7.2.5. Zmiana modelu pamięci używanego przez operacje na stosie bez blokad ................... 247 7.2.6. Implementacja kolejki gwarantującej bezpieczeństwo przetwarzania wielowątkowego bez blokad ............................................................................................ 252 Wskazówki dotyczące pisania struktur danych bez blokad ................................................... 264 7.3.1. Wskazówka: na etapie tworzenia prototypu należy stosować tryb std::memory_order_seq_cst ............................................................................................ 265 7.3.2. Wskazówka: należy używać schematu odzyskiwania pamięci bez blokad .................... 265 7.3.3 Wskazówka: należy unikać problemu ABA .................................................................... 266 7.3.4. Wskazówka: należy identyfikować pętle aktywnego oczekiwania i wykorzystywać czas bezczynności na wspieranie innego wątku ............................................................. 267 Podsumowanie ............................................................................................................................ 267

Rozdział 8. Projektowanie współbieżnego kodu 8.1.

183

Co oznacza projektowanie struktur danych pod kątem współbieżności? ............................ 184 6.1.1. Wskazówki dotyczące projektowania współbieżnych struktur danych ........................ 185 Projektowanie współbieżnych struktur danych przy użyciu blokad .................................... 186 6.2.1. Stos gwarantujący bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad ........................................................................................................... 187 6.2.2. Kolejka gwarantująca bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad i zmiennych warunkowych ............................................................. 190 6.2.3. Kolejka gwarantująca bezpieczeństwo przetwarzania wielowątkowego przy użyciu szczegółowych blokad i zmiennych warunkowych .................................... 194 Projektowanie złożonych struktur danych przy użyciu blokad ............................................. 207 6.3.1. Implementacja tablicy wyszukiwania gwarantującej bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad ...................................................... 207 6.3.2. Implementacja listy gwarantującej bezpieczeństwo przetwarzania wielowątkowego przy użyciu blokad .............................................................................. 213 Podsumowanie ............................................................................................................................ 218

Rozdział 7. Projektowanie współbieżnych struktur danych bez blokad 7.1.

7

269

Techniki dzielenia pracy pomiędzy wątki ............................................................................... 270 8.1.1. Dzielenie danych pomiędzy wątki przed rozpoczęciem przetwarzania ....................... 271 8.1.2. Rekurencyjne dzielenie danych ...................................................................................... 272 8.1.3. Dzielenie pracy według typu zadania ............................................................................. 276

8

Spis treści 8.2.

8.3. 8.4.

8.5.

8.6.

Czynniki wpływające na wydajność współbieżnego kodu ..................................................... 279 8.2.1. Liczba procesorów ........................................................................................................... 280 8.2.2. Współzawodnictwo o dane i ping-pong bufora .............................................................. 281 8.2.3. Fałszywe współdzielenie ................................................................................................. 284 8.2.4. Jak blisko należy rozmieścić dane? ................................................................................. 285 8.2.5. Nadsubskrypcja i zbyt intensywne przełączanie zadań ................................................. 285 Projektowanie struktur danych pod kątem wydajności przetwarzania wielowątkowego ..... 286 8.3.1. Podział elementów tablicy na potrzeby złożonych operacji .......................................... 287 8.3.2. Wzorce dostępu do danych w pozostałych strukturach ................................................. 289 Dodatkowe aspekty projektowania współbieżnych struktur danych ................................... 291 8.4.1. Bezpieczeństwo wyjątków w algorytmach równoległych .............................................. 291 8.4.2. Skalowalność i prawo Amdahla ....................................................................................... 298 8.4.3. Ukrywanie opóźnień za pomocą wielu wątków .............................................................. 300 8.4.4. Skracanie czasu reakcji za pomocą technik przetwarzania równoległego .................... 301 Projektowanie współbieżnego kodu w praktyce ..................................................................... 303 8.5.1. Równoległa implementacja funkcji std::for_each ........................................................... 304 8.5.2. Równoległa implementacja funkcji std::find .................................................................. 306 8.5.3. Równoległa implementacja funkcji std::partial_sum ..................................................... 312 Podsumowanie ............................................................................................................................ 322

Rozdział 9. Zaawansowane zarządzanie wątkami 9.1.

9.2.

9.3.

323

Pule wątków ................................................................................................................................ 324 9.1.1. Najprostsza możliwa pula wątków .................................................................................. 324 9.1.2. Oczekiwanie na zadania wysyłane do puli wątków ........................................................ 327 9.1.3. Zadania oczekujące na inne zadania ............................................................................... 330 9.1.4. Unikanie współzawodnictwa w dostępie do kolejki zadań ............................................ 333 9.1.5. Wykradanie zadań ............................................................................................................ 335 Przerywanie wykonywania wątków .......................................................................................... 340 9.2.1. Uruchamianie i przerywanie innego wątku .................................................................... 340 9.2.2. Wykrywanie przerwania wątku ....................................................................................... 342 9.2.3. Przerywanie oczekiwania na zmienną warunkową ........................................................ 343 9.2.4. Przerywanie oczekiwania na zmienną typu std::condition_variable_any ..................... 346 9.2.5. Przerywanie pozostałych wywołań blokujących ............................................................ 348 9.2.6. Obsługa przerwań ............................................................................................................ 349 9.2.7. Przerywanie zadań wykonywanych w tle podczas zamykania aplikacji ........................ 350 Podsumowanie ............................................................................................................................ 352

Rozdział 10. Testowanie i debugowanie aplikacji wielowątkowych

353

10.1. Rodzaje błędów związanych z przetwarzaniem współbieżnym ............................................ 354 10.1.1. Niechciane blokowanie ............................................................................................... 354 10.1.2. Sytuacje wyścigu ......................................................................................................... 355 10.2. Techniki lokalizacji błędów związanych z przetwarzaniem współbieżnym ........................ 357 10.2.1. Przeglądanie kodu w celu znalezienia ewentualnych błędów .................................. 357 10.2.2. Znajdowanie błędów związanych z przetwarzaniem współbieżnym poprzez testowanie kodu ................................................................................................. 359 10.2.3. Projektowanie kodu pod kątem łatwości testowania ................................................. 361 10.2.4. Techniki testowania wielowątkowego kodu .............................................................. 363 10.2.5. Projektowanie struktury wielowątkowego kodu testowego ...................................... 366 10.2.6. Testowanie wydajności wielowątkowego kodu ......................................................... 369 10.3. Podsumowanie ............................................................................................................................ 370

Spis treści

Dodatek A Krótki przegląd wybranych elementów języka C++11 A.1. A.2. A.3. A.4.

A.5. A.6. A.7. A.8. A.9.

9

371

Referencje do r-wartości ........................................................................................................... 371 A.1.1. Semantyka przenoszenia danych ..................................................................................... 372 A.1.2. Referencje do r-wartości i szablony funkcji .................................................................... 375 Usunięte funkcje ......................................................................................................................... 376 Funkcje domyślne ...................................................................................................................... 377 Funkcje constexpr ...................................................................................................................... 381 A.4.1. Wyrażenia constexpr i typy definiowane przez użytkownika ........................................ 382 A.4.2. Obiekty constexpr ............................................................................................................ 385 A.4.3. Wymagania dotyczące funkcji constexpr ........................................................................ 385 A.4.4. Słowo constexpr i szablony .............................................................................................. 386 Funkcje lambda .......................................................................................................................... 386 A.5.1. Funkcje lambda odwołujące się do zmiennych lokalnych ............................................. 388 Szablony zmiennoargumentowe ............................................................................................... 391 A.6.1. Rozwijanie paczki parametrów ....................................................................................... 392 Automatyczne określanie typu zmiennej ................................................................................. 395 Zmienne lokalne wątków ........................................................................................................... 396 Podsumowanie ............................................................................................................................ 397

Dodatek B Krótkie zestawienie bibliotek przetwarzania współbieżnego

399

Dodatek C Framework przekazywania komunikatów i kompletny przykład implementacji systemu bankomatu

401

Dodatek D Biblioteka wątków języka C++

419

D.1. Nagłówek ................................................................................................................. 419 D.1.1. Szablon klasy std::chrono::duration ............................................................................ 420 D.1.2. Szablon klasy std::chrono::time_point ....................................................................... 429 D.1.3. Klasa std::chrono::system_clock ................................................................................. 431 D.1.4. Klasa std::chrono::steady_clock .................................................................................. 433 D.1.5. Definicja typu std::chrono::high_resolution_clock ................................................... 435 D.2. Nagłówek ............................................................................................ 435 D.2.1. Klasa std::condition_variable ...................................................................................... 436 D.2.2. Klasa std::condition_variable_any .............................................................................. 444 D.3. Nagłówek ................................................................................................................. 452 D.3.1. Definicje typów std::atomic_xxx ................................................................................ 454 D.3.2. Makra ATOMIC_xxx_LOCK_FREE ........................................................................ 454 D.3.3. Makro ATOMIC_VAR_INIT ..................................................................................... 455 D.3.4. Typ wyliczeniowy std::memory_order ....................................................................... 455 D.3.5. Funkcja std::atomic_thread_fence ............................................................................. 456 D.3.6. Funkcja std::atomic_signal_fence .............................................................................. 457 D.3.7. Klasa std::atomic_flag .................................................................................................. 457 D.3.8. Szablon klasy std::atomic ............................................................................................ 460 D.3.9. Specjalizacje szablonu std::atomic ............................................................................. 471 D.3.10. Specjalizacje szablonu std::atomic ................................. 472 D.4. Nagłówek .................................................................................................................. 489 D.4.1. Szablon klasy std::future ............................................................................................. 490 D.4.2. Szablon klasy std::shared_future ................................................................................ 495 D.4.3. Szablon klasy std::packaged_task ............................................................................... 501 D.4.4. Szablon klasy std::promise .......................................................................................... 507 D.4.5. Szablon funkcji std::async ........................................................................................... 513

10

Spis treści D.5. Nagłówek .................................................................................................................. 514 D.5.1. Klasa std::mutex .......................................................................................................... 515 D.5.2. Klasa std::recursive_mutex ......................................................................................... 518 D.5.3. Klasa std::timed_mutex ............................................................................................... 520 D.5.4. Klasa std::recursive_timed_mutex ............................................................................. 524 D.5.5. Szablon klasy std::lock_guard ..................................................................................... 529 D.5.6. Szablon klasy std::unique_lock ................................................................................... 530 D.5.7. Szablon funkcji std::lock ............................................................................................. 540 D.5.8. Szablon funkcji std::try_lock ....................................................................................... 541 D.5.9. Klasa std::once_flag ..................................................................................................... 541 D.5.10. Szablon funkcji std::call_once .................................................................................... 542 D.6. Nagłówek ..................................................................................................................... 543 D.6.1. Szablon klasy std::ratio ................................................................................................ 544 D.6.2. Alias szablonu std::ratio_add ...................................................................................... 544 D.6.3. Alias szablonu std::ratio_subtract ............................................................................... 545 D.6.4. Alias szablonu std::ratio_multiply .............................................................................. 545 D.6.5. Alias szablonu std::ratio_divide .................................................................................. 546 D.6.6. Szablon klasy std::ratio_equal ..................................................................................... 547 D.6.7. Szablon klasy std::ratio_not_equal ............................................................................. 547 D.6.8. Szablon klasy std::ratio_less ........................................................................................ 547 D.6.9. Szablon klasy std::ratio_greater .................................................................................. 548 D.6.10. Szablon klasy std::ratio_less_equal ............................................................................ 548 D.6.11. Szablon klasy std::ratio_greater_equal ....................................................................... 548 D.7. Nagłówek ................................................................................................................. 549 D.7.1. Klasa std::thread .......................................................................................................... 549 D.7.2. Przestrzeń nazw std::this_thread ................................................................................ 558

Materiały dodatkowe

561

Skorowidz

563

Słowo wstępne Pierwszy kontakt z kodem wielowątkowym miałem już w swojej pierwszej pracy po skończeniu college’u. Pisaliśmy aplikację przetwarzającą dane, która musiała umieszczać przychodzące rekordy w bazie danych. Aplikacja otrzymywała mnóstwo danych, jednak każdy rekord był niezależny od pozostałych i wymagał czasochłonnego przetworzenia przed ostatecznym dodaniem do bazy danych. Aby w pełni wykorzystać moc obliczeniową dziesięcioprocesorowego komputera UltraSPARC, kod aplikacji był wykonywany w wielu wątkach, z których każdy przetwarzał własny podzbiór przychodzących rekordów. Kod aplikacji napisaliśmy w języku C++, stosując wątki standardu POSIX i popełniając przy tym całkiem sporo błędów — dla wszystkich członków zespołu przetwarzanie wielowątkowe było czymś zupełnie nowym. Mimo trudności aplikacja ostatecznie została ukończona. Podczas realizacji tego samego projektu dowiedziałem się o istnieniu komitetu standaryzacyjnego C++ i nowo wydanego standardu tego języka. Od tamtego czasu uważnie śledzę wszelkie informacje dotyczące przetwarzania wielowątkowego i algorytmów współbieżnych. O ile większość programistów traktowała te techniki jako coś trudnego, złożonego i potencjalnie kłopotliwego, o tyle ja koncentrowałem się przede wszystkim na potencjale tego rozwiązania jako narzędzia umożliwiającego szybsze wykonywanie kodu na dostępnym sprzęcie. Z czasem zrozumiałem także, jak można używać metod przetwarzania wielowątkowego do skracania czasu odpowiedzi i poprawy wydajności aplikacji działających na komputerach jednowątkowych. Zastosowanie wielu wątków pozwala skutecznie ukrywać opóźnienia związane z takimi czasochłonnymi działaniami jak operacje wejścia-wyjścia. Zrozumiałem też, jak odpowiednie rozwiązania działają na poziomie systemu operacyjnego i jak zaimplementowano mechanizm przełączania zadań w procesorach firmy Intel. W międzyczasie zainteresowania związane z językiem C++ skłoniły mnie do nawiązania kontaktu z organizacją programistów tego języka ACCU, z komitetem standaryzacyjnym języka C++ w ramach Brytyjskiego Instytutu Normalizacyjnego (BSI) oraz z twórcami biblioteki Boost. Z uwagą śledziłem początki prac nad biblioteką wątków (Boost Thread Library), a kiedy programista zaangażowany w ten projekt zrezygnował z udziału w pracach, skorzystałem z okazji i zająłem jego miejsce. Od tamtej pory jestem głównym programistą odpowiedzialnym za rozwój biblioteki Boost Thread Library. Kiedy komitet standaryzacyjny zakończył poprawianie usterek i niedociągnięć w dotychczasowym standardzie języka C++ i przystąpił do dokumentowania propozycji dotyczących nowego standardu (początkowo nazwanego C++0x w nadziei, że uda się zakończyć prace do końca 2009, i ostatecznie nazwanego C++11 w związku z faktycznym ukończeniem prac w roku 2011), zaangażowałem się w prace instytutu BSI i zacząłem formułować własne propozycje. Kiedy tylko stało się jasne, że przetwarzanie wielowątkowe zostanie uwzględnione w agendzie, od razu wziąłem się do pracy, i w rezultacie opracowałem (także jako współautor) wiele propozycji dotyczących

12

Słowo wstępne

przetwarzania wielowątkowego i współbieżnego w nowym standardzie. Czuję się zaszczycony i wyróżniony w związku z możliwością bezpośredniego uczestnictwa w rozwoju dwóch szczególnie interesujących mnie obszarów, czyli języka C++ i przetwarzania wielowątkowego. Ta książka jest efektem całego mojego doświadczenia w programowaniu w języku C++ i stosowaniu technik przetwarzania wielowątkowego. Jej celem jest nauczenie innych programistów, jak bezpiecznie i efektywnie korzystać z biblioteki wątków standardu C++11. Mam także nadzieję, że uda mi się zaszczepić w czytelnikach entuzjazm dla prezentowanych rozwiązań.

Podziękowania Chciałbym na początku skierować wielkie „dziękuję” do mojej żony Kim, której jestem wdzięczny za miłość i wsparcie okazywane mi podczas pisania tej książki. Praca nad tą książką zajęła znaczną część mojego wolnego czasu w ostatnich czterech latach. Bez cierpliwości, wsparcia i zrozumienia ze strony żony nie mógłbym sobie na to pozwolić. Chciałbym też podziękować członkom zespołu wydawnictwa Manning, bez których powstanie tej książki byłoby niemożliwe: Marjanowi Bace’owi (redaktorowi naczelnemu), Michaelowi Stephensowi (zastępcy redaktora naczelnego), Cynthii Kane (redaktorce mojej książki), Karen Tegtmeyer (redaktorce językowej), Lindzie Recktenwald i Katie Tennant (korektorkom językowym) oraz Mary Piergies (menedżer produktu). Bez ich zaangażowania ta książka z pewnością nie trafiłaby w Twoje ręce. Na podziękowania zasługują również pozostali członkowie komitetu standaryzacyjnego języka C++, którzy przygotowywali rozmaite dokumenty poświęcone mechanizmom przetwarzania wielowątkowego: Andrei Alexandrescu, Pete Becker, Bob Blainer, Hans Boehm, Beman Dawes, Lawrence Crowl, Peter Dimov, Jeff Garland, Kevlin Henney, Howard Hinnant, Ben Hutchings, Jan Kristofferson, Doug Lea, Paul McKenney, Nick McLaren, Clark Nelson, Bill Pugh, Raul Silvera, Herb Sutter, Detlef Vollmann i Michael Wong. Jestem wdzięczny także wszystkim osobom, które komentowały te dokumenty, omawiały je na spotkaniach członków komitetu lub w dowolny inny sposób pomagały wypracować mechanizmy przetwarzania wielowątkowego i współbieżnego w standardzie C++11. I wreszcie chciałbym podziękować następującym osobom, których sugestie pozwoliły znacznie udoskonalić tę książkę: drowi Jamiemu Allsopowi, Peterowi Dimovowi, Howardowi Hinnantowi, Rickowi Molloyowi, Jonathanowi Wakely oraz drowi Russelowi Winderowi. Specjalne podziękowania kieruję do dra Russela Windera za jego szczegółową analizę tekstu oraz do Jonathana Wakely, który jako korektor techniczny dokładnie sprawdził całą treść pod kątem błędów. (Wszystkie błędy, które mimo jego starań pozostały w tekście, są oczywiście wyłącznie moją winą). Na podziękowania zasługuje także grupa recenzentów: Ryan Stephens, Neil Horlock, John Taylor Jr., Ezra Jivan, Joshua Heyer, Keith S. Kim, Michele Galli, Mike Tian-Jian Jiang, David Strong, Roger Orr, Wagner Rick, Mike Buksas i Bas Vodde. Chciałbym także podziękować czytelnikom wczesnego wydania tej książki (tzw. wydania MEAP), którzy poświęcili swój czas, aby wskazać usterki i miejsca wymagające uściślenia.

14

Podziękowania

O tej książce Książka jest szczegółowym przewodnikiem po mechanizmach przetwarzania współbieżnego i technikach programowania wielowątkowego dostępnych w nowym standardzie języka C++ Standard — od podstawowych zastosowań klas std::thread i std::mutex oraz funkcji std::async po złożone zagadnienia związane z operacjami atomowymi i modelem pamięci.

Plan lektury W pierwszych czterech rozdziałach wprowadzę rozmaite elementy biblioteki i zaprezentuję sposoby ich stosowania. W rozdziale 5. omówię najważniejsze niskopoziomowe aspekty modelu pamięci i operacji atomowych. Zaprezentuję między innymi sposoby wymuszania stosowania ograniczeń kolejnościowych za pomocą operacji atomowych. Rozdział 5. zakończy część wprowadzającą podstawowe rozwiązania. W rozdziałach 6. i 7. zacznę analizę zagadnień wyższego poziomu. Omówię kilka przykładów używania podstawowych mechanizmów do budowy bardziej złożonych struktur danych (struktur z blokadami w rozdziale 6. i struktur bez blokad w rozdziale 7.). W rozdziale 8. rozwinę zagadnienia związane ze stosowaniem rozwiązań wysokopoziomowych. W rozdziale można znaleźć między innymi wskazówki dotyczące projektowania wielowątkowego kodu, analizę czynników wpływających na wydajność oraz przykład implementacji wybranych algorytmów równoległych. W rozdziale 9. omówię techniki zarządzania wątkami, w tym pule wątków, kolejki zadań i metody przerywania wykonywania operacji. W rozdziale 10. omówię metody testowania i diagnozowania (debugowania) kodu wielowątkowego. W rozdziale można znaleźć także analizę rodzajów błędów, technik ich lokalizowania, metod testowania kodu pod kątem poszczególnych błędów itp. W dodatkach można znaleźć krótki opis wybranych elementów języka programowania wprowadzonych w nowym standardzie i związanych z przetwarzaniem wielowątkowym. Omówię też szczegóły implementacji biblioteki przekazywania komunikatów (wspomnianej w rozdziale 4.) oraz wszystkie elementy standardowej biblioteki wątków języka C++11.

Kto powinien przeczytać tę książkę Tę książkę powinien przeczytać każdy, kto pisze kod wielowątkowy w języku C++. Dla programistów korzystających z nowych mechanizmów programowania wielowątkowego dostępnych w bibliotece standardowej języka C++ ta książka będzie cennym

16

O tej książce

źródłem wiedzy. Także programiści używający alternatywnych bibliotek wątków mogą skorzystać na lekturze tej książki, w szczególności wskazówek i technik opisanych w dalszych rozdziałach. Zakładam, że czytelnik dysponuje dobrą praktyczną znajomością języka C++, ale nie oczekuję umiejętności biegłego posługiwania się nowymi elementami tego języka — odpowiednie rozwiązania zostaną omówione w dodatku A. Czytelnik nie musi mieć doświadczenia w programowaniu wielowątkowym, choć znajomość pewnych zagadnień będzie sporym ułatwieniem.

Jak korzystać z tej książki Czytelnikom, którzy nigdy wcześniej nie pisali wielowątkowego kodu, sugeruję lekturę tej książki od początku do końca (z ewentualnym pominięciem najbardziej szczegółowych fragmentów rozdziału 5.). Materiał w rozdziale 7. w dużej mierze bazuje na treści rozdziału 5., zatem czytelnicy, którzy pominęli rozdział 5., powinni wstrzymać się z lekturą rozdziału 7. do czasu przeczytania wprowadzenia z rozdziału 5. Czytelników, którzy wcześniej nie korzystali z nowych elementów języka C++11, zachęcam choćby do pobieżnego przejrzenia dodatku A przed przystąpieniem do analizy przykładów zawartych we właściwych rozdziałach tej książki. Zastosowania nowych elementów języka C++ zostały odpowiednio wyróżnione w tekście, jednak zachęcam do zaglądania do dodatku A przy okazji napotkania każdego elementu, z którym czytelnik nie zetknął się wcześniej. Nawet czytelnicy, którzy mają spore doświadczenie w pisaniu wielowątkowego kodu w innych środowiskach, powinni przynajmniej przejrzeć początkowe rozdziały, aby przekonać się, czym rozwiązania dostępne w języku C++ różnią się od mechanizmów, z którymi mieli do czynienia do tej pory. Lektura rozdziału 5. jest absolutnie niezbędna w przypadku czytelników planujących stosowanie niskopoziomowych operacji na zmiennych atomowych. Z treścią rozdziału 8. warto się zapoznać choćby po to, aby opanować zagadnienia związane z bezpieczeństwem wyjątków w wielowątkowym kodzie języka C++. Czytelnicy poszukujący informacji na temat konkretnych zadań mogą skorzystać z indeksu i spisu treści, aby szybko znaleźć właściwy rozdział lub punkt. Nawet po opanowaniu technik stosowania elementów biblioteki wątków języka C++ warto wracać do dodatku D, w którym można znaleźć szczegółowe omówienie poszczególnych klas i funkcji tej biblioteki. Zachęcam też do zaglądania do właściwych rozdziałów i odświeżania swojej wiedzy na temat konkretnych konstrukcji lub ponownego analizowania przykładowego kodu.

Konwencje stosowane w kodzie i pliki do pobrania Kod źródłowy prezentowany na listingach i w samym tekście sformatowano przy użyciu czcionki o stałej szerokości, tak aby można go było łatwo odróżnić od zwykłego tekstu. Wiele listingów uzupełniono o dodatkowe adnotacje, które wskazują szczególnie ważne elementy. W wielu przypadkach kod zawiera ponumerowane adnotacje, które ułatwiają odwoływanie się do konkretnych konstrukcji w tekście następującym po listingu. Kod źródłowy wszystkich działających (kompletnych) przykładów opisanych w tej książce jest dostępny pod adresem ftp://ftp.helion.pl/przyklady/jcpppw.zip.

Wymagane oprogramowanie

17

Wymagane oprogramowanie Warunkiem stosowania kodu prezentowanego w tej książce w niezmienionej postaci jest dysponowanie nowym kompilatorem języka C++, który obsługuje opisane elementy standardu C++11 (patrz dodatek A), oraz kopią standardowej biblioteki wątków języka C++. W czasie, gdy powstawała ta książka, jedynym znanym mi kompilatorem, który był dostępny wraz z implementacją standardowej biblioteki wątków, był g++, jednak także firma Microsoft zapowiadała dołączenie takiej implementacji do środowiska programowania Visual Studio 2011. Implementację biblioteki wątków języka C++ po raz pierwszy wprowadzono do kompilatora g++ w wersji 4.3, ale oferowana implementacja była rozszerzana w kolejnych wydaniach. W kompilatorze g++ 4.3 wprowadzono dodatkowo obsługę niektórych nowych elementów języka C++11; pozostałe nowe rozwiązania zaimplementowano w kolejnych wersjach. Szczegółowe informacje na ten temat można znaleźć na stronie statusu projektu g++ C++111. Środowisko programowania Microsoft Visual Studio 2010 udostępnia niektóre spośród nowych elementów języka C++11, w tym obsługę referencji do r-wartości i funkcji lambda, ale nie oferuje implementacji standardowej biblioteki wątków. Moja firma (Just Software Solutions Ltd) sprzedaje kompletne implementacje standardowej biblioteki wątków języka C++11 dla środowisk Microsoft Visual Studio 2005, Microsoft Visual Studio 2008 i Microsoft Visual Studio 2010 oraz różnych wersji kompilatora g++2. Właśnie ta implementacja była używana do testowania przykładów prezentowanych w tej książce. Biblioteka Boost Thread Library3 udostępnia interfejs API zaprojektowany na bazie propozycji dotyczących standardowej biblioteki wątków języka C++11 i jednocześnie gwarantuje przenośność pomiędzy wieloma platformami. Większość przykładów zawartych w tej książce można przystosować do współpracy z biblioteką Boost Thread Library poprzez zwykłe zastąpienie przedrostka std:: przedrostkiem boost:: i użycie odpowiednich dyrektyw #include. Warto przy tym pamiętać, że nieliczne elementy albo nie są obsługiwane (jak w przypadku funkcji std::async), albo mają inne nazwy (jak w przypadku szablonu boost::unique_future) w bibliotece Boost Thread Library.

Author Online Zakup książki Język C++ i przetwarzanie współbieżne w akcji zapewnia darmowy dostęp do prywatnego forum internetowego utrzymywanego przez wydawnictwo Manning Publications. Wspomniane forum jest miejscem komentowania książek, zadawania pytań technicznych oraz uzyskiwania pomocy od autora i pozostałych użytkowników. Aby uzyskać dostęp do tego forum i zarejestrować konto użytkownika, należy odwiedzić stronę http://www.manning.com/williams/. Na stronie wyjaśniono, jak uzyskać dostęp do forum po zakończeniu rejestracji, jakiego rodzaju pomoc można uzyskać za pośrednictwem tego forum i jakich reguł powinni przestrzegać użytkownicy forum. 1

Strona statusu GNU Compiler Collection C++0x/C++11, http://gcc.gnu.org/projects/cxx0x.html.

2

Implementacja standardowej biblioteki wątków języka C++ just::thread, http://www.stdthread.co.uk.

3

Kolekcja bibliotek Boost C++, http://www.boost.org.

18

O tej książce

Wydawnictwo Manning postanowiło udostępnić swoim czytelnikom miejsce, w którym będą mogli prowadzić rozmowy zarówno pomiędzy sobą, jak i z autorami dyskutowanych książek. Wydawnictwo nie deklaruje jednak konkretnego poziomu zaangażowania ze strony autora, którego udział jest dobrowolny (i nie podlega wynagrodzeniu). Warto więc spróbować zadać autorowi jakieś wymagające pytanie, aby przekonać się, czy przypadkiem nie odwiedza tego forum! Forum programu Author Online i archiwa dotychczasowych dyskusji będą dostępne na stronie wydawcy tak długo, jak długo książka będzie w dystrybucji4.

4

Materiały są w języku angielskim — przyp. tłum.

Witaj, świecie współbieżności w C++!

W tym rozdziale zostaną omówione następujące zagadnienia: Q

Q

Q

Q

co rozumiemy przez współbieżność, a co przez wielowątkowość; dlaczego warto stosować techniki przetwarzania współbieżnego i wielowątkowego w aplikacjach; historia obsługi współbieżności w języku programowania C++; analiza prostego wielowątkowego programu napisanego w języku C++.

Nadeszły wspaniałe czasy dla programistów języka C++. Trzynaście lat po opublikowaniu specyfikacji standardu C++ w 1998 roku Komitet C++ przeprowadził prawdziwą rewolucję zarówno w samym języku C++, jak i w jego bibliotece. Nowa wersja standardu C++ (oznaczana jako C++11 lub C++0x), którą opublikowano w 2011 roku, wprowadza mnóstwo zmian ułatwiających pracę w tym języku i podnoszących efektywność programistów. Jednym z najważniejszych nowych elementów wprowadzonych w standardzie C++11 jest obsługa programów wielowątkowych. Wraz z wydaniem tego standardu po raz pierwszy oficjalnie potwierdzono możliwość tworzenia aplikacji wielowątkowych w języku C++ i udostępniono w ramach biblioteki komponenty niezbędne do pisania takich aplikacji. Oznacza to, że programiści mogą teraz pisać wielowątkowe

20

ROZDZIAŁ 1. Witaj, świecie współbieżności w C++!

programy języka C++ bez konieczności stosowania rozszerzeń ściśle związanych z określonymi platformami — dopiero wersja C++11 gwarantuje możliwość pisania przenośnego kodu wielowątkowego. Wydanie tej wersji nastąpiło w samą porę — programiści coraz częściej wykazują zainteresowanie współbieżnością, w szczególności programowaniem wielowątkowym jako techniką znacznie podnoszącą wydajność tworzonych programów. Tematem tej książki jest właśnie pisanie programów w języku C++ przy użyciu wielu wątków umożliwiających przetwarzanie współbieżne. W książce zostaną zaprezentowane elementy języka C++ i jego biblioteki, które umożliwiają tworzenie takich programów. Zaczniemy od wyjaśnienia, co rozumiemy przez współbieżność i przetwarzanie wielowątkowe oraz dlaczego warto stosować te techniki we współczesnych aplikacjach. Po krótkim omówieniu argumentów na rzecz stosowania tych technik w tworzonych aplikacjach przystąpimy do analizy mechanizmów obsługi współbieżności w języku C++. Na końcu tego rozdziału omówimy prosty przykład programu w języku C++ stosującego techniki przetwarzania współbieżnego. Czytelnicy, którzy mają doświadczenie w pracy z aplikacjami wielowątkowymi, mogą pominąć pierwsze rozdziały tej książki. W dalszych rozdziałach omówię bardziej rozbudowane przykłady i szczegółowo zaprezentuję odpowiednie elementy biblioteki języka C++. Na końcu książki można znaleźć dokładne omówienie komponentów biblioteki standardowej C++ opracowanych z myślą o przetwarzaniu wielowątkowym i obsłudze współbieżności. Co właściwie rozumiem przez współbieżność i przetwarzanie wielowątkowe?

1.1.

Czym jest współbieżność? Na najprostszym, najbardziej podstawowym poziomie współbieżność polega na jednoczesnym wykonywaniu co najmniej dwóch czynności. Ze współbieżnością mamy do czynienia w codziennym życiu — możemy jednocześnie iść i rozmawiać lub wykonywać różne czynności dwiema rękami. Co więcej, każdy z nas może wykonywać czynności niezależnie od innych ludzi — w czasie gdy czytelnik idzie pograć w piłkę, ja mogę jechać na przykład na basen.

1.1.1.

Współbieżność w systemach komputerowych

Mówiąc o współbieżności w kontekście komputerów, mamy zwykle na myśli pojedynczy system, który równolegle wykonuje wiele niezależnych operacji (zamiast wykonywać je sekwencyjnie, jedną po drugiej). Koncepcja współbieżności nie jest niczym nowym w świecie komputerów — wielozadaniowe systemy operacyjne, które umożliwiają jednoczesne uruchamianie wielu aplikacji na jednym komputerze (dzięki mechanizmowi przełączania zadań), są znane od wielu lat. Jeszcze wcześniej istniały wysokiej klasy serwery dysponujące wieloma procesorami i tym samym oferujące prawdziwą współbieżność. Nowością jest powszechna dostępność komputerów zdolnych do równoległego wykonywania wielu zadań zamiast stwarzania iluzji wielozadaniowości. W przeszłości większość komputerów dysponowała jednym procesorem z pojedynczą jednostką przetwarzania (tzw. rdzeniem). Także dzisiaj znaczna część komputerów biurkowych zawiera procesory jednordzeniowe. Komputery tego typu w rzeczywistości mogą wykonywać tylko jedno zadanie jednocześnie, ale w ciągu sekundy mogą wielokrotnie przełączać realizowane zadania. Wykonywanie niewielkiej części jednego

1.1.

Czym jest współbieżność?

21

zadania, niewielkiej części innego zadania itd. stwarza wrażenie współbieżnej realizacji wszystkich tych zadań. Ta technika jest określana mianem przełączania zadań (ang. task switching). Nawet w przypadku tych systemów można mówić o współbieżności, ponieważ przełączanie zadań przebiega na tyle szybko, że nie sposób stwierdzić, w którym momencie wykonywanie jednego zadania zostanie wstrzymane w związku z przyznaniem czasu procesora dla innego zadania. Przełączanie zadań stwarza iluzję współbieżności zarówno dla użytkownika, jak i dla samych aplikacji. Ponieważ mamy do czynienia zaledwie z iluzją współbieżności, zachowanie aplikacji może być nieco inne w jednoprocesorowym środowisku z przełączaniem zadań i w środowisku oferującym prawdziwą współbieżność. W szczególności w środowisku wielordzeniowym mogą występować problemy związane z przyjętymi przez programistę nieuprawnionymi założeniami dotyczącymi modelu pamięci (omówionego w rozdziale 5.). Ten problem zostanie szczegółowo omówiony w rozdziale 10. Komputery zawierające wiele procesorów były przez wiele lat stosowane tylko w serwerach i najbardziej wydajnych komputerach. Od pewnego czasu komputery z procesorami złożonymi z wielu rdzeni w ramach jednego układu (z tzw. procesorami wielordzeniowymi) zyskują popularność także w świecie zwykłych komputerów biurkowych. Niezależnie od tego, czy wspomniane komputery dysponują wieloma procesorami, czy wieloma rdzeniami w ramach jednego procesora (czy też wieloma procesorami wielordzeniowymi), mogą równolegle wykonywać wiele zadań w naturalny sposób, a więc bez ich „sztucznego” przełączania. Ten rodzaj przetwarzania określa się mianem współbieżności sprzętowej. Na rysunku 1.1 pokazano wyidealizowany scenariusz, w którym komputer wykonuje dwa zadania, każde podzielone na dziesięć równych fragmentów. W przypadku komputera dwurdzeniowego (czyli takiego, który dysponuje dwoma rdzeniami przetwarzającymi) każde zadanie może być wykonywane przez osobny, własny rdzeń. W przypadku komputera jednordzeniowego z funkcją przełączania zadań kolejne fragmenty obu tych zadań są wykonywane naprzemiennie. Fragmenty zadań są jednak od siebie oddzielone (na diagramie przerwy pomiędzy tymi naprzemiennymi fragmentami są reprezentowane przez szare paski, które w przypadku procesora jednordzeniowego są dużo grubsze niż w przypadku komputera dwurdzeniowego), ponieważ przełączanie kontekstu w związku ze zmianą aktualnie wykonywanego zadania wymaga trochę czasu. Aby przełączyć kontekst, system operacyjny musi zapisać stan procesora i wskaźnik do bieżącego rozkazu aktualnie wykonywanego zadania, wybrać docelowe zadanie operacji przełączania i ponownie załadować zapisany wcześniej stan procesora tego zadania. Procesor, który najprawdopodobniej będzie musiał załadować do pamięci podręcznej rozkazy i dane nowego zadania, nie będzie mógł w tym czasie wykonywać żadnych innych operacji, co spowoduje dodatkowe opóźnienie. Mimo że możliwość przetwarzania współbieżnego jest zwykle kojarzona z systemami wieloprocesorowymi i wielordzeniowymi, istnieją procesory zdolne do wykonywania wielu wątków w jednym rdzeniu. Ważnym parametrem współczesnych procesorów jest liczba wątków sprzętowych, czyli liczba niezależnych zadań, które te procesory mogą wykonywać w pełni równolegle (bez przełączania). Co ciekawe, nawet systemy oferujące sprzętową współbieżność mogą równolegle wykonywać większą liczbę zadań dzięki mechanizmowi przełączania kontekstu stosowanemu na poziomie poszczególnych rdzeni. Na przykład typowy komputer biurkowy może wykonywać setki zadań

22

ROZDZIAŁ 1. Witaj, świecie współbieżności w C++!

Rysunek 1.1. Dwa modele współbieżności: równoległe wykonywanie zadań na komputerze dwurdzeniowym oraz przełączanie zadań na komputerze jednordzeniowym

składających się na operacje wykonywane w tle (nawet jeśli komputer nominalnie znajduje się w stanie bezczynności). Wykonywanie tych operacji w tle jest możliwe dzięki mechanizmowi przełączania zadań — temu samemu, który umożliwia jednoczesne korzystanie z edytora tekstu, kompilatora i przeglądarki internetowej (lub dowolnej innej kombinacji aplikacji). Na rysunku 1.2 pokazano proces przełączania czterech zadań na komputerze dwurdzeniowym (także tym razem przyjęto wyidealizowany scenariusz, w którym każde zadanie dzieli się na równe fragmenty). W rzeczywistości, wskutek rozmaitych problemów i utrudnień, podział zadań na równe fragmenty jest zwykle niemożliwy, zatem także ich szeregowanie jest mniej regularne. Część tych problemów zostanie omówiona w rozdziale 8. przy okazji prezentowania czynników wpływających na wydajność współbieżnego kodu.

Rysunek 1.2. Przełączanie czterech zadań w systemie dwurdzeniowym

Wszystkie technologie, funkcje i klasy omawiane w tej książce można z powodzeniem stosować niezależnie od tego, czy aplikacja działa na komputerze z jednym procesorem jednordzeniowym, czy na komputerze zawierającym wiele procesorów wielordzeniowych. Co więcej, na działanie prezentowanych rozwiązań nie ma wpływu sposób współbieżnego wykonywania operacji (z wykorzystaniem techniki przełączania zadań lub rzeczywistej współbieżności sprzętowej). Jak nietrudno odgadnąć, o ile wspomniane mechanizmy można stosować niezależnie od konfiguracji sprzętowej, sam sposób ich wykorzystywania w kodzie aplikacji może zależeć od możliwości sprzętu, na którym ta aplikacja będzie uruchamiana. To zagadnienie zostanie bliżej omówione w rozdziale 8. przy okazji analizy problemów związanych z projektowaniem współbieżnego kodu w języku C++. 1.1.2.

Modele współbieżności

Wyobraźmy sobie parę programistów pracujących wspólnie nad jakimś projektem. Jeśli ci programiści pracują w osobnych pokojach, mogą realizować swoje zadania w spokoju, nie przeszkadzając sobie nawzajem (zakładamy, że każdy z nich dysponuje własnym

1.1.

Czym jest współbieżność?

23

zbiorem niezbędnych materiałów). W takim przypadku komunikacja pomiędzy nimi jest jednak utrudniona — zamiast po prostu obrócić się w stronę współpracownika i porozmawiać, muszą użyć telefonu, poczty elektronicznej lub wstać od biurka i iść do pokoju współpracownika. Co więcej, koszty projektu są wyższe w związku z potrzebą utrzymywania dwóch osobnych pomieszczeń oraz zakupu dwóch kopii podręczników i innych niezbędnych materiałów. Wyobraźmy sobie teraz, że ci sami programiści pracują w jednym pokoju. Mogą teraz ze sobą swobodnie rozmawiać, aby wspólnie wypracowywać projekt budowanej aplikacji. Mogą też rysować diagramy na papierze lub tablicy, aby lepiej wyrażać i tłumaczyć swoje pomysły. W takim przypadku koszty projektu są dużo mniejsze, ponieważ programiści zajmują jedno pomieszczenie i korzystają z jednego zbioru zasobów. Do najważniejszych wad tego modelu należą utrudniona koncentracja i ewentualne problemy związane ze współdzieleniem zasobów („Gdzie się podział ten podręcznik?”). Te dwa sposoby organizacji pracy pary programistów dobrze ilustrują podstawowe modele współbieżności. Każdy programista reprezentuje pojedynczy wątek, natomiast pokoje odpowiadają procesom. W pierwszym przypadku mamy do czynienia z wieloma procesami jednowątkowymi (każdy programista pracuje we własnym pokoju); drugi scenariusz reprezentuje wiele wątków składających się na jeden proces (para programistów pracuje w jednym pomieszczeniu). Opisane scenariusze można ze sobą dowolnie łączyć, tak aby system obejmował wiele procesów, z których część będzie wielowątkowa, a część jednowątkowa, jednak ogólne zasady pozostają niezmienione. Przeanalizujmy teraz oba modele współbieżności w kontekście aplikacji. WSPÓŁBIEŻNOŚĆ Z WIELOMA PROCESAMI

Pierwszą formą współbieżności stosowanej w aplikacjach jest dzielenie pojedynczej aplikacji na wiele odrębnych, jednowątkowych procesów, które mogą działać w tym samym czasie (podobnie jak możemy korzystać jednocześnie z edytora tekstu i przeglądarki internetowej). Te odrębne procesy mogą przekazywać sobie komunikaty za pośrednictwem standardowych kanałów komunikacji międzyprocesowej, jak sygnały, gniazda, pliki, potoki itp. (patrz rysunek 1.3). Wadą takiej komunikacji pomiędzy procesami jest skomplikowana procedura konfiguracji lub niska wydajność (zdarza się, że komunikacja jest jednocześnie skomplikowana i nieefektywna), ponieważ systemy operacyjne stosują zwykle mechanizmy ochrony procesów w obawie przed przypadkowym zmodyfikowaniem danych jednego procesu przez inny proces. Inną wadą tego rozwiązania jest koszt utrzymywania wielu procesów — uruchomienie każdego procesu wymaga czasu, zarządzanie procesem wymaga poświęcenia części zasobów systemu operacyjnego itp. Opisany model oczywiście ma też pewne zalety — techniki ochrony używane przez systemy operacyjne do izolowania procesów i mechanizmy komunikacji wyższego poziomu powodują, że pisanie współbieżnego kodu gwarantującego bezpieczeństwo przetwa- Rysunek 1.3. Komunikacja pomiędzy parą równolegle rzania wielowątkowego jest łatwiejsze w przypadku działających procesów

24

ROZDZIAŁ 1. Witaj, świecie współbieżności w C++!

procesów niż w przypadku wątków. Właśnie procesy były z powodzeniem stosowane w roli podstawowego elementu składowego współbieżności w takich językach programowania jak Erlang. Stosowanie modelu współbieżności na bazie odrębnych procesów ma też inną zaletę — takie procesy można uruchamiać na różnych komputerach połączonych za pośrednictwem sieci. Mimo wyższych kosztów komunikacji odpowiednio zaprojektowany system może efektywnie implementować koncepcję przetwarzania równoległego i podnosić wydajność oprogramowania. WSPÓŁBIEŻNOŚĆ Z WIELOMA WĄTKAMI

Alternatywnym modelem współbieżności jest uruchamianie wielu wątków w ramach jednego procesu. Wątki pod wieloma względami przypominają lekkie procesy — każdy wątek działa niezależnie od pozostałych i może wykonywać odmienną sekwencję rozkazów. Inaczej niż w przypadku odrębnych procesów, wszystkie wątki składające się na proces współdzielą tę samą przestrzeń adresową, a dostęp do większości używanych danych można uzyskiwać bezpośrednio z poziomu tych wątków (zmienne globalne pozostają globalne, natomiast wskaźniki lub referencje do obiektów czy danych można przekazywać pomiędzy wątkami). Mimo że współdzielenie pamięci przez procesy także jest możliwe, konfiguracja niezbędnych mechanizmów jest skomplikowana i zwykle dość trudna w zarządzaniu, ponieważ adresy obszarów pamięci zawierających te same dane mogą być różne w różnych procesach. Na rysunku 1.4 pokazano dwa wątki procesu komunikujące się za pośrednictwem pamięci współdzielonej. Współdzielona przestrzeń adresowa i brak ochrony danych należących do wątków oznaczają, że koszty związane ze stosowaniem wielu wątków są dużo mniejsze niż w przypadku wielu procesów, ponieważ system operacyjny jest zwolniony z odpowiedzialności za wiele zadań. Elastyczność oferowana przez model pamięci współdzielonej ma też swoją cenę — jeśli wiele wątków uzyskuje dostęp do tych samych danych, programista aplikacji musi zagwarantować spójność tych danych z perspektywy wszystkich wątków. Problemy związane ze współdzieleniem danych pomiędzy wątkami oraz narzędzia i wskazówki ułatwiające unikanie tych problemów będą omawiane w wielu miejscach tej książki, w szczególności Rysunek 1.4. w rozdziałach 3., 4., 5. i 8. Wszystkie te problemy można w ten czy Komunikacja inny sposób rozwiązać, jeśli tylko programista zachowa należytą pomiędzy parą równolegle ostrożność podczas pisania kodu. Wspomniane utrudnienia poka- działających zują, że każda implementacja komunikacji międzywątkowej musi wątków jednego procesu być dobrze przemyślana. Mniejsze koszty uruchamiania wielu wątków w ramach jednego procesu i komunikacji pomiędzy tymi wątkami (w porównaniu z kosztami uruchamiania i komunikacji wielu jednowątkowych procesów) spowodowały, że właśnie ten model zyskał większe uznanie wśród projektantów najpopularniejszych języków programowania, w tym języka C++, mimo potencjalnych problemów związanych z obsługą pamięci współdzielonej. Co więcej, specyfikacja C++ nie obejmuje wewnętrznych mechanizmów obsługi komunikacji międzyprocesowej, zatem aplikacje stosujące wiele procesów muszą używać do tego celu interfejsów API związanych z określonymi platformami.

1.2.

Dlaczego warto stosować współbieżność?

25

W tej książce skoncentruję się wyłącznie na modelu współbieżności na bazie wątków, zatem od tej pory słowo „współbieżność” będzie używane właśnie w kontekście stosowania wielu wątków. Skoro wyjaśniłem już, co rozumiem pod pojęciem współbieżności, czas zastanowić się, dlaczego warto stosować techniki przetwarzania współbieżnego w aplikacjach.

1.2.

Dlaczego warto stosować współbieżność? Istnieją dwa główne powody, dla których warto stosować metody przetwarzania współbieżnego w aplikacjach: podział zagadnień (odpowiedzialności) i wydajność. Jestem zdania, że wymienione powody są jedynymi argumentami na rzecz współbieżności — bliższa analiza pokazuje, że wszystkie inne zalety pośrednio wynikają z jednego lub drugiego przytoczonego powodu, a nierzadko z obu jednocześnie (wyjątkiem od tej reguły są argumenty typu „bo mam taki kaprys”).

1.2.1.

Stosowanie współbieżności do podziału zagadnień

Podczas tworzenia oprogramowania podział zagadnień (ang. separation of concerns) jest niemal zawsze pożądany — pozwala grupować powiązane fragmenty kodu i oddzielać niezwiązane ze sobą elementy oraz ułatwia tworzenie, rozumienie i testowanie programów, zatem ogranicza ryzyko występowania błędów. Współbieżność można z powodzeniem wykorzystać do oddzielenia odrębnych zbiorów funkcji, nawet jeśli operacje należące do tych zbiorów muszą być wykonywane w tym samym czasie; osiągnięcie tego samego celu bez mechanizmów przetwarzania współbieżnego wymagałoby napisania frameworku przełączania zadań lub zaimplementowania techniki aktywnego wywoływania poszczególnych obszarów kodu w trakcie operacji. Przeanalizujmy aplikację intensywnie przetwarzającą dane i oferującą interfejs użytkownika, na przykład aplikację odtwarzacza DVD dla komputera biurkowego. Aplikacja tego typu obejmuje dwa podstawowe zbiory odpowiedzialności — musi nie tylko odczytywać dane z płyty, dekodować obraz i dźwięk oraz wysyłać je do urządzeń prezentujących obraz i odtwarzających dźwięk (wszystko to musi się odbywać na tyle sprawnie, aby zawartość płyty była wyświetlana w płynny sposób), ale też reagować na ewentualne sygnały od użytkownika, na przykład naciśnięcie przycisku pauzy, powrotu do menu lub wyłączania odtwarzacza. W przypadku pojedynczego wątku aplikacja musi sprawdzać dane wejściowe użytkownika w regularnych odstępach czasu w trakcie odtwarzania, zatem kod odpowiedzialny za odtwarzanie płyty DVD musi przeplatać się z kodem obsługującym interfejs użytkownika. Użycie modelu wielowątkowego do oddzielenia tych zagadnień (obszarów odpowiedzialności) eliminuje konieczność ścisłego wiązania kodu odtwarzającego dźwięk i obraz z kodem interfejsu użytkownika — jeden wątek może obsługiwać odtwarzanie, drugi może obsługiwać ewentualne sygnały od użytkownika. Oba wątki muszą oczywiście jakoś współpracować, tak aby na przykład kliknięcie przycisku pauzy powodowało wstrzymanie odtwarzania, jednak w tym modelu wszelkie interakcje wątków są ściśle związane z konkretnymi zadaniami. Z perspektywy użytkownika opisany model sprawia wrażenie sprawniejszego, szybciej reagującego na polecenia, ponieważ wątek interfejsu użytkownika zwykle może natychmiast odpowiadać na sygnały od użytkownika, nawet jeśli te odpowiedzi ograniczają się do wyświetlania kursora oczekiwania lub komunikatu „Proszę czekać” (w tym

26

ROZDZIAŁ 1. Witaj, świecie współbieżności w C++!

czasie odpowiednie żądanie jest przekazywane do właściwego wątku). Podobnie odrębne wątki często są używane do uruchamiania zadań, które muszą być stale wykonywane w tle, na przykład zadań monitorowania zmian w systemie plików (na potrzeby aplikacji przeszukującej zasoby na dysku). Stosowanie wątków w tej roli zwykle znacznie upraszcza logikę poszczególnych zadań, ponieważ interakcje pomiędzy poszczególnymi wątkami ograniczają się do ściśle określonych punktów (zamiast przeplatać się z logiką różnych zadań). W tym przypadku liczba wątków nie zależy od liczby dostępnych rdzeni procesora, ponieważ podział na wątki wynika raczej z przyjętego projektu oprogramowania niż z prób zwiększenia przepustowości. 1.2.2.

Stosowanie współbieżności do podniesienia wydajności

Systemy wieloprocesorowe istnieją od dziesięcioleci, jednak do niedawna były stosowane tylko w superkomputerach, komputerach mainframe i wielkich systemach serwerowych. Producenci procesorów z czasem wybrali jednak koncepcję układów wielordzeniowych, w których 2, 4, 16 czy więcej procesorów umieszczonych w jednym układzie zapewnia wyższą wydajność niż procesory jednordzeniowe. W efekcie wielordzeniowe komputery biurkowe, a nawet wielordzeniowe urządzania wbudowane zyskują coraz większą popularność. Rosnąca moc obliczeniowa tych komputerów nie wynika z szybszego wykonywania pojedynczego zadania, tylko z możliwości równoległego wykonywania wielu zadań. W przeszłości programiści mogli spokojnie obserwować, jak ich programy działają coraz szybciej wraz z wydawaniem nowych generacji procesorów — wzrost wydajności nie wymagał więc udziału programistów. Dzisiaj sytuacja jest nieco inna — jak napisał Herb Sutter, „The free lunch is over” (dosł. koniec z darmowym lunchem)1. Warunkiem wykorzystywania rosnącej mocy obliczeniowej przez oprogramowanie jest projektowanie programów pod kątem współbieżnego wykonywania wielu zadań. Programiści muszą teraz wykazywać większą czujność, a ci spośród nas, którzy do tej pory ignorowali kwestię współbieżności, prędzej czy później będą musieli włączyć odpowiednie rozwiązania do swojego arsenału. Współbieżność wpływa na wydajność oprogramowania na dwa sposoby. Pierwszym i najbardziej oczywistym jest podział zadania na części i równoległe wykonywanie tych podzadań, tak aby skrócić łączny czas realizacji całego zadania. Tę metodę określa się mianem równoległości lub zrównoleglania zadań (ang. task parallelism). Na pierwszy rzut oka ta metoda sprawia wrażenie dziecinnie prostej, jednak często okazuje się dość skomplikowanym procesem, ponieważ pomiędzy poszczególnymi podzadaniami może występować wiele zależności. Podział może dotyczyć albo przetwarzania (jeden wątek wykonuje jedną część algorytmu, inny wątek wykonuje inną część), albo danych (każdy wątek wykonuje tę samą operację na innym fragmencie danych). Drugi model określa się mianem równoległości danych (ang. data parallelism). Algorytmy, które można łatwo dostosować do tej formy równoległości, często określa się mianem otwartych na zrównoleglanie (ang. embarrassingly parallel). Mimo potencjalnego ryzyka związanego ze zrównoleglaniem kodu, algorytmy tego typu są 1

„The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software”, Herb Sutter, Dr. Dobb’s Journal, 30(3), marzec 2005, http://www.gotw.ca/publications/concurrency-ddj.htm.

1.2.

Dlaczego warto stosować współbieżność?

27

pożądane i bywają nazywane algorytmami naturalnie równoległymi lub przystosowanymi do współbieżności. Algorytmy otwarte na zrównoleglanie cechują się wysoką skalowalnością — wraz ze wzrostem liczby dostępnych wątków sprzętowych rośnie także równoległość tych algorytmów. Algorytmy tego typu dowodzą słuszności powiedzenia „wiele rąk czyni pracę lżejszą” (ang. many hands make light work). Części algorytmu, których zrównoleglanie jest trudniejsze, często można podzielić na stałą (a więc nieskalowalną) liczbę równolegle wykonywanych zadań. Techniki dzielenia zadań pomiędzy wątki zostaną omówione w rozdziale 8. Drugim sposobem używania technik przetwarzania współbieżnego do podnoszenia wydajności jest podział problemu na mniejsze, równolegle rozwiązywane podproblemy — zamiast przetwarzać jeden duży plik można przetwarzać na przykład 2, 10 lub 20 fragmentów tego pliku. Mimo że mamy tutaj do czynienia z pewną formą równoległości danych, istota współbieżnego wykonywania tej samej operacji na wielu zbiorach danych jest nieco inna. Przetworzenie jednego fragmentu danych wciąż zajmuje tyle samo czasu, jednak przetwarzanie współbieżne umożliwia przetworzenie większej ilości danych w tym samym czasie. Także ten model podlega oczywiście pewnym ograniczeniom i nie może być skutecznie stosowany we wszystkich przypadkach, jednak zwiększenie przepustowości dzięki tej formie przetwarzania równoległego umożliwia osiąganie wcześniej nieosiągalnych celów — na przykład przetwarzanie obrazu wideo w wyższej rozdzielczości (różne fragmenty obrazu mogą być przetwarzane równolegle). 1.2.3.

Kiedy nie należy stosować współbieżności

Świadomość, kiedy nie należy stosować technik przetwarzania współbieżnego, jest równie ważna jak wiedza, kiedy powinniśmy to robić. W największym skrócie współbieżności nie należy stosować w sytuacji, gdy korzyści wynikające z tej formy przetwarzania nie są warte kosztów powodowanych przez to rozwiązanie. Kod zawierający elementy przetwarzania współbieżnego jest w większości przypadków trudniejszy do zrozumienia i interpretacji, zatem z pisaniem i konserwacją kodu wielowątkowego wiążą się bezpośrednie koszty intelektualne. Co więcej, dodatkowa złożoność może też przekładać się na większą liczbę błędów. Jeśli potencjalny wzrost wydajności nie będzie wystarczająco duży lub jeśli podział zagadnień nie będzie dość jednoznaczny, aby uzasadnić koszty dłuższego czasu wytwarzania i późniejszej konserwacji kodu wielowątkowego, nie należy stosować technik przetwarzania współbieżnego. Warto też pamiętać, że wzrost wydajności nie zawsze jest tak duży, jak początkowo planowano — uruchomienie nowego wątku wiąże się z pewnymi kosztami, ponieważ system operacyjny musi przydzielić zasoby jądra i przestrzeń stosu, po czym dodać nowy wątek do mechanizmu szeregowania (wszystkie te operacje wymagają czasu). Jeśli wątek szybko wykonuje swoje zadanie, czas potrzebny do realizacji tego zadania może się okazać krótszy niż czas samego uruchamiania odpowiedniego wątku — w takim przypadku wydajność aplikacji będzie niższa niż w przypadku wersji jednowątkowej (bez współbieżności). Co więcej, należy pamiętać, że wątki są ograniczonymi zasobami. Zbyt wiele jednocześnie działających wątków zajmuje zasoby systemu operacyjnego, powodując jego ogólne spowolnienie. Stosowanie zbyt dużej liczby wątków może też prowadzić do wyczerpania pamięci lub przestrzeni adresowej dostępnej dla procesu, ponieważ każdy

28

ROZDZIAŁ 1. Witaj, świecie współbieżności w C++!

wątek potrzebuje odrębnej przestrzeni stosu. Problem dotyczy przede wszystkim procesów 32-bitowych z tzw. płaską architekturą, gdzie przestrzeń adresowa obejmuje maksymalnie 4 GB danych: jeśli każdy wątek dysponuje stosem wielkości 1 MB (to dość typowy rozmiar w wielu systemach), przestrzeń adresowa może zostanie w całości wypełniona przez 4096 wątków (oczywiście jeśli przyjąć, że nie potrzebujemy miejsca dla kodu, danych statycznych ani danych na stercie). Mimo że w systemach 64-bitowych ograniczenie przestrzeni adresowej nie stanowi problemu, także w tych systemach zasoby są ograniczone, zatem uruchamianie zbyt dużej liczby wątków może prowadzić do spadku wydajności. Istnieje co prawda mechanizm puli wątków (patrz rozdział 9.), który ogranicza liczbę wątków, jednak nie należy traktować tego rozwiązania jako leku na całe zło, zwłaszcza że same pule wątków rodzą pewne problemy. Jeśli strona serwera aplikacji klient-serwer uruchamia osobny wątek dla każdego połączenia, wszystko działa prawidłowo dla niewielkiej liczby połączeń, ale już w przypadku mocno obciążonego serwera, który musi obsługiwać mnóstwo połączeń, zbyt duża liczba wątków może doprowadzić do błyskawicznego wyczerpania zasobów systemu. W opisanym scenariuszu optymalną wydajność można zapewnić, stosując odpowiednio zaprojektowaną, przemyślaną pulę wątków (patrz rozdział 9.). I wreszcie im więcej wątków uruchomi nasza aplikacja, tym więcej operacji przełączania kontekstu będzie musiał wykonywać system operacyjny. Każda taka operacja wymaga czasu, który można by poświęcić na realizację właściwych zadań, zatem od pewnego punktu dodawanie kolejnych wątków zaczyna obniżać wydajność aplikacji (zamiast ją podnosić). Właśnie dlatego programista próbujący uzyskać możliwie najwyższą wydajność systemu powinien dostosować liczbę stosowanych wątków do możliwości komputera w zakresie obsługi współbieżności (lub jego braku). Ze współbieżnością rozumianą jako metoda podnoszenia wydajności jest jak ze wszystkimi strategiami optymalizacji — wielowątkowość może co prawda znacznie przyspieszyć działanie aplikacji, ale też może skomplikować jej kod, utrudniając jego rozumienie i narażając na dodatkowe błędy. Techniki przetwarzania współbieżnego należy więc stosować tylko w tych częściach aplikacji, które muszą zapewniać najwyższą wydajność i które w wyniku tej optymalizacji mogą istotnie przyspieszyć działanie programu. Potencjalny wzrost wydajności nie jest oczywiście jedynym czynnikiem — lepszy projekt czy podział zagadnień (obszarów odpowiedzialności) często jest ważniejszy i w pełni uzasadnia stosowanie wielu wątków. Przyjmijmy, że ostatecznie zdecydowaliśmy się zastosować techniki przetwarzania współbieżnego w tworzonej aplikacji (ze względu na wydajność, podział zagadnień lub cokolwiek innego, choćby rzut monetą). Co ta decyzja oznacza dla programisty C++?

1.3.

Współbieżność i wielowątkowość w języku C++ Standardowa obsługa współbieżności poprzez podział programu na wątki jest nowością w języku C++. Wraz z wydaniem standardu C++11 wprowadzono możliwość pisania wielowątkowego kodu bez konieczności stosowania rozszerzeń związanych z poszczególnymi platformami. Aby zrozumieć przyczyny wielu decyzji podjętych przez twórców biblioteki Standard C++ Thread Library, warto zapoznać się z historią obsługi wątków w tym języku programowania.

1.3.

1.3.1.

Współbieżność i wielowątkowość w języku C++

29

Historia przetwarzania wielowątkowego w języku C++

W wydaniu specyfikacji C++ Standard z 1998 roku w ogóle nie uwzględniono wątków, zatem wiele elementów języka C++ zaimplementowano z myślą o modelu abstrakcyjnej maszyny sekwencyjnej. Co więcej, w standardzie z 1998 roku formalnie nie zdefiniowano modelu pamięci, przez co pisanie aplikacji wielowątkowych bez odpowiednich rozszerzeń kompilatora nie było możliwe. Producenci kompilatorów mogą oczywiście wzbogacać ten język programowania o dowolne rozszerzenia, jednak popularność interfejsów API języka C obsługujących przetwarzanie wielowątkowe (na przykład interfejsów w ramach standardu POSIX C oraz interfejsu Microsoft Windows API) spowodowała, że wielu twórców kompilatorów języka C++ zaimplementowało obsługę wątków w formie rozszerzeń ściśle związanych z konkretnymi platformami. Obsługa na poziomie kompilatora zwykle ograniczała się do możliwości stosowania odpowiedniego interfejsu API języka C dla danej platformy i zapewnieniu prawidłowego działania biblioteki uruchomieniowej języka C++ (na przykład mechanizmu obsługi wyjątków) podczas wykonywania aplikacji wielowątkowej. Mimo że bardzo niewielu producentów kompilatorów udostępniało formalne modele pamięci przystosowane do przetwarzania wielowątkowego, faktyczne zachowanie tych kompilatorów i procesorów było na tyle zadowalające, że powstało mnóstwo wielowątkowych programów napisanych w języku C++. Programiści języka C++, którym nie wystarczały interfejsy API języka C związane z poszczególnymi platformami, oczekiwali raczej bibliotek klas z obiektowymi mechanizmami przetwarzania wielowątkowego. Frameworki aplikacji, jak MFC, oraz uniwersalne biblioteki języka C++, jak Boost czy ACE, oferowały między innymi zbiory klas języka C++, które pełniły funkcję opakowań dla interfejsów API i udostępniały mechanizmy wyższego poziomu, co znacznie uprościło tworzenie aplikacji wielowątkowych. Mimo zasadniczych różnic dzielących szczegółowe rozwiązania stosowane w poszczególnych bibliotekach sam sposób uruchamiania nowych wątków oraz klasy używane do tego celu miały wiele cech wspólnych. Z perspektywy programisty szczególnie ważnym elementem, który zastosowano w wielu bibliotekach klas języka C++, był wzorzec projektowy Resource Acquisition Is Initialization (RAII). Zgodnie z tym wzorcem blokady nakładane na zasoby są automatycznie zwalniane w momencie opuszczania odpowiedniego zasięgu. W wielu przypadkach obsługa wielowątkowości oferowana przez istniejące kompilatory języka C++ w połączeniu z dostępnością interfejsów API dla konkretnych platform i bibliotek klas niezależnych od platform (jak Boost czy ACE) stanowi solidną, wystarczającą podstawę dla pisania wielowątkowego kodu w języku C++. W efekcie powstały miliony wierszy kodu tego języka w ramach licznych aplikacji wielowątkowych. Brak obsługi przetwarzania wielowątkowego na poziomie standardu (w szczególności brak odpowiedniego modelu pamięci) powodował jednak pewne problemy. W najtrudniejszej sytuacji znajdowali się programiści, którzy próbowali zwiększyć wydajność, korzystając z wiedzy na temat budowy procesora, oraz programiści tworzący kod dla wielu platform (w tym przypadku źródłem problemów było odmienne działanie kompilatorów dostępnych dla różnych platform).

30 1.3.2.

ROZDZIAŁ 1. Witaj, świecie współbieżności w C++!

Obsługa współbieżności w nowym standardzie

Sytuacja zmieniła się wraz z wydaniem wersji C++11 Standard. Oprócz zupełnie nowego modelu pamięci przystosowanego do przetwarzania wielowątkowego biblioteka standardowa została rozszerzona o klasy umożliwiające zarządzanie wątkami (patrz rozdział 2.), ochronę współdzielonych danych (patrz rozdział 3.), synchronizację operacji wykonywanych przez wątki (patrz rozdział 4.) oraz wykonywanie niskopoziomowych operacji atomowych (patrz rozdział 5.). Nowa biblioteka wątków (C++ Thread Library) została zaprojektowana w dużej mierze na bazie doświadczeń zebranych przy okazji stosowania wspomnianych wcześniej bibliotek klas języka C++. W szczególności nową bibliotekę zbudowano na bazie modelu znanego z biblioteki Boost Thread Library, stąd wiele klas nowego standardu jest tak samo nazwanych i ma taką samą strukturę jak ich odpowiedniki w bibliotece Boost. Zmiany wprowadzane w nowym standardzie z czasem zostały uwzględnione także przez twórców biblioteki Boost Thread Library, którzy w wielu aspektach starają się dostosowywać swój produkt do rozwiązań standardowego języka C++. Oznacza to, że programiści, którzy do tej pory stosowali bibliotekę Boost, nie powinni mieć najmniejszych problemów z opanowaniem standardowych rozwiązań. Obsługa współbieżności to tylko jedna ze zmian wprowadzonych w nowej wersji standardu C++ — jak już wspomniano na początku tego rozdziału, sam język doczekał się wielu udoskonaleń, które mają na celu ułatwienie pracy programisty. Chociaż analiza tych zmian wykraczałaby poza zakres tematyczny tej książki, niektóre z nich mają bezpośredni wpływ na bibliotekę wątków i sposoby stosowania jej elementów w kodzie aplikacji. Krótkie wprowadzenie do zmian wprowadzonych w nowym standardzie można znaleźć w dodatku A. Bezpośrednia obsługa operacji atomowych w języku C++ umożliwia programistom pisanie efektywnego kodu bez konieczności stosowania języka asemblera związanego z określoną platformą. Nowe rozwiązanie jest więc sporym ułatwieniem dla programistów próbujących pisać efektywny, przenośny kod — to kompilator odpowiada teraz za obsługę różnic dzielących poszczególne operacje, natomiast mechanizm optymalizacji może dodatkowo uwzględniać semantykę tych operacji, dzięki czemu optymalizacja programu jako całości jest skuteczniejsza. 1.3.3.

Efektywność biblioteki wątków języka C++

Jednym z problemów utrudniających pracę programistów języka C++ zainteresowanych implementowaniem oprogramowania gwarantującego wysoką wydajność jest niedostateczna efektywność klas opakowujących mechanizmy niskopoziomowe (w tym klas nowej biblioteki wątków standardowego języka C++). Programiści zainteresowani przede wszystkim najwyższą wydajnością powinni zdawać sobie sprawę z kosztów związanych ze stosowaniem mechanizmów wysokopoziomowych (w porównaniu z bezpośrednim używaniem odpowiednich rozwiązań niskopoziomowych). Dodatkowe koszty wynikają ze stosowania dodatkowych warstw abstrakcji. Komitet odpowiedzialny za standard C++ uwzględnił ten problem podczas projektowania biblioteki standardowej języka C++, w szczególności biblioteki wątków. Jednym z przyjętych celów projektowych było ograniczenie (lub wręcz wyeliminowa-

1.3.

Współbieżność i wielowątkowość w języku C++

31

nie) różnic w wydajności niskopoziomowych interfejsów API i odpowiednich rozwiązań wysokopoziomowych. Realizacja tego celu wymagała zaprojektowania biblioteki pod kątem efektywności (z minimalnymi kosztami stosowania warstwy abstrakcji) dla najważniejszych platform. Innym celem komitetu C++ Standards Committee było udostępnienie wszystkich niezbędnych mechanizmów niskopoziomowych języka C++ dla programistów zainteresowanych najwyższą wydajnością. W tym celu oprócz nowego modelu pamięci opracowano rozbudowaną bibliotekę operacji atomowych zapewniających bezpośrednią kontrolę nad poszczególnymi bitami i bajtami, synchronizację działań wielu wątków oraz mechanizmy odpowiedzialne za widoczność wprowadzanych zmian. Atomowe typy danych i odpowiednie operacje mogą teraz być stosowane w wielu miejscach, w których do tej pory programiści musieli używać języków asemblera związanych z konkretnymi platformami. Oznacza to, że kod na bazie nowych typów standardowych jest bardziej przenośny i łatwiejszy w konserwacji. Biblioteka standardowa języka C++ dodatkowo udostępnia abstrakcje i mechanizmy wyższego poziomu, które znacznie ułatwiają pisanie wielowątkowego kodu i ograniczają ryzyko błędów. W niektórych przypadkach stosowanie tych mechanizmów wiąże się z pewnym spadkiem wydajności z powodu konieczności wykonywania dodatkowego kodu. Wspomniane koszty nie zawsze wynikają z wyższego poziomu abstrakcji. Koszt nie powinien być wyższy niż w przypadku samodzielnie opracowanych mechanizmów realizujących te same zadania, jednak w przypadku standardowych rozwiązań kompilator może skutecznie osadzać dodatkowy kod. W pewnych przypadkach rozwiązania wysokopoziomowe oferują dodatkowe funkcje, które nie we wszystkich zastosowaniach są niezbędne. Dla większości programistów nadmiar funkcji nie stanowi problemu, ponieważ nieużywane elementy nie generują żadnych kosztów. Zdarza się jednak, że nieużywane funkcje wpływają na wydajność pozostałego kodu. Programiści oczekujący najwyższej wydajności, dla których wspomniane koszty są zbyt wysokie, mogą samodzielnie opracowywać niezbędne funkcje, posługując się mechanizmami niższego poziomu. W zdecydowanej większości przypadków niewielki wzrost wydajności nie jest jednak wart dodatkowej złożoności i ryzyka popełnienia błędów. Nawet jeśli w wyniku profilowania zidentyfikowano funkcje biblioteki standardowej języka C++ jako wąskie gardło programu, przyczyną niedostatecznej wydajności może być nieprzemyślany projekt aplikacji, nie implementacja biblioteki. Na wydajność aplikacji może mieć wpływ na przykład sytuacja, w której wiele wątków konkuruje o dostęp do jednego muteksu. Zamiast podejmować próby ograniczania kodu chronionego przez ten muteks, warto rozważyć przebudowę aplikacji, tak aby o dostęp do tego kodu nie współzawodniczyło tyle wątków jednocześnie — taka zmiana prawdopodobnie będzie miała większy wpływ na wydajność. Techniki projektowania aplikacji pod kątem ograniczania współzawodnictwa zostaną omówione w rozdziale 8. W rzadkich przypadkach, w których biblioteka standardowa języka C++ nie oferuje oczekiwanej wydajności lub niezbędnych zachowań, programista może stanąć przed koniecznością użycia rozwiązań związanych z określoną platformą.

32 1.3.4.

ROZDZIAŁ 1. Witaj, świecie współbieżności w C++!

Mechanizmy związane z poszczególnymi platformami

Mimo że biblioteka wątków (C++ Thread Library) oferuje rozbudowane mechanizmy przetwarzania wielowątkowego i współbieżnego, dla każdej platformy istnieją dodatkowe związane z tą platformą rozwiązania, które oferują szersze możliwości od funkcji wspomnianej biblioteki. Aby umożliwić łatwy dostęp do tych mechanizmów bez konieczności rezygnowania z zalet standardowej biblioteki wątków, część typów tej biblioteki oferuje funkcję składową native_handle(), dzięki której programista może bezpośrednio operować na dostępnej implementacji za pomocą interfejsu API właściwego danej platformie. Warto jednak pamiętać, że wszystkie operacje wykonywane przy użyciu funkcji native_handle() są ściśle uzależnione od platformy, zatem ich omawianie (tak jak prezentacja samej biblioteki standardowej języka C++) wykraczałoby poza zakres tematyczny tej książki. Przed rozważeniem stosowania mechanizmów związanych z konkretną platformą warto oczywiście zapoznać się z rozwiązaniami dostępnymi w bibliotece standardowej. Przeanalizujmy zatem pewien prosty przykład.

1.4.

Do dzieła! Przyjmijmy, że otrzymaliśmy wreszcie wyczekiwany kompilator zgodny ze standardem C++11. Co dalej? Jak właściwie ma wyglądać wielowątkowy program napisany w języku C++? Odpowiedni kod bardzo przypomina zwykły program języka C++ obejmujący doskonale znane zmienne, klasy i funkcje. Jedyną istotną różnicą jest możliwość współbieżnego wykonywania tych samych funkcji — oznacza to, że musimy zagwarantować bezpieczeństwo współdzielonych danych, które mają być dostępne dla równolegle działających wątków (patrz rozdział 3.). Warunkiem współbieżnego wykonywania tych funkcji jest oczywiście stosowanie obiektów i funkcji zarządzających różnymi wątkami.

1.4.1.

„Witaj świecie współbieżności”

Zacznijmy od klasycznego przykładu, czyli programu wyświetlającego pozdrowienie Witaj świecie. Poniżej pokazano bardzo prosty program wyświetlający ten komunikat w jednym wątku — potraktujemy ten kod jako punkt wyjścia dla programu złożonego z wielu wątków: #include int main() { std::coutm); if(p(*next->data)) { std::unique_ptr old_next=std::move(current->next); current->next=std::move(next->next); next_lk.unlock(); } else { lk.unlock(); current=next; lk=std::move(next_lk); } } };

}

Typ threadsafe_list zdefiniowany na listingu 6.13 implementuje listę jednokierunkową, w której każdy element ma postać struktury typu node . Domyślnie konstruowany węzeł jest używany w roli głowy listy, a jego wskaźnik next ma wartość NULL . Nowe węzły są dodawane do listy za pomocą funkcji push_front(); każdy nowy węzeł jest najpierw konstruowany , co wymaga zaalokowania przestrzeni na stercie (na tym etapie wskaźnik next ma wartość NULL). Musimy następnie zablokować muteks dla węzła head, aby uzyskać odpowiednią wartość next , po czym wstawić nowy węzeł na początku listy, tak ustawiając wartość head.next, aby wskazywała nowy węzeł . Ponieważ dodanie nowego elementu do listy wymaga zablokowania tylko jednego muteksu, opisana operacja na pewno nie spowoduje zakleszczenia. Co więcej, kosztowna (czasochłonna) operacja alokacji pamięci jest wykonywana poza zasięgiem blokady, zatem ta blokada chroni tylko kilka aktualizacji wartości wskaźników, które nie mogą się nie udać. Przejdźmy teraz do funkcji iteracyjnych. Przeanalizujmy najpierw funkcję for_each() . Operacja otrzymuje na wejściu instancję typu Function reprezentującą funkcję, która ma być stosowana dla każdego elementu listy. Tak jak w większości algorytmów biblioteki standardowej, funkcja jest przekazywana przez wartość i ma postać albo zwykłej funkcji, albo obiektu dowolnego typu definiującego operator wywołania funkcji. W tym przypadku funkcja musi otrzymywać wartość typu T za pośrednictwem swojego jedynego parametru. Właśnie na tym etapie należy stosować następujące po sobie blokady. Na początku musimy zablokować muteks dla węzła head . Oznacza to, że uzyskanie wskaźnika do węzła next jest bezpieczne (używamy do tego celu funkcji get(), ponieważ nie przejmujemy własności tego wskaźnika). Jeśli ten wskaźnik jest różny od NULL , należy zablokować muteks tego węzła , aby przetworzyć zawarte w nim dane. Po zablokowaniu dostępu do węzła i wywołać wskazaną funkcję . możemy zwolnić blokadę poprzedniego węzła Po zakończeniu działania przez tę funkcję możemy zaktualizować wskaźnik current do właśnie przetworzonego węzła i przenieść własność blokady z next_lk do lk . Ponieważ funkcja for_each przekazuje każdy element danych bezpośrednio do wskazanej funkcji (instancji typu Function), możemy to wykorzystać do ewentualnego aktualizowania elementów, kopiowania ich do innego kontenera lub dowolnych innych nie-

6.3.

Projektowanie złożonych struktur danych przy użyciu blokad

217

zbędnych działań. Opisane rozwiązanie jest w pełni bezpieczne, pod warunkiem że stosowana funkcja nie podejmuje ryzykownych działań, ponieważ odpowiedni muteks blokuje węzeł zawierający element danych przez całe wywołanie. Funkcja find_first_if() pod wieloma względami przypomina funkcję for_each(); zasadnicza różnica polega na tym, że przekazany predykat (instancja typu Predicate) musi zwracać wartość true w przypadku istnienia dopasowania i wartość false w razie braku dopasowania . Po wykryciu dopasowania wystarczy zwrócić znalezione dane zamiast kontynuować wyszukiwanie. Podobne rozwiązanie można by zastosować w funkcji for_each(), jednak wówczas pozostałe elementy listy byłyby przeszukiwane nawet po znalezieniu dopasowania. Funkcja remove_if() działa nieco inaczej, ponieważ musi zaktualizować listę. W tym przypadku nie jest możliwe użycie funkcji for_each(). Jeśli Predicate zwraca wartość true , należy usunąć węzeł z listy, aktualizując wartość current->next . Po zmianie tej wartości możemy zwolnić blokadę muteksu dla węzła next. Węzeł jest usuwany w momencie opuszczenia zasięgu przez przeniesiony wskaźnik typu std::unique_ ´ptr . W tym przypadku nie aktualizujemy wartości current, ponieważ musimy sprawdzić nowy węzeł next. Jeśli Predicate zwraca wartość false, wystarczy — tak jak wcześniej — przejść do następnego elementu . Czy stosowanie wszystkich tych muteksów nie powoduje ryzyka sytuacji wyścigu lub zakleszczeń? Odpowiedź na tak postawione pytanie brzmi: nie, pod warunkiem że przekazywane predykaty i funkcje działają prawidłowo. Iteracja zawsze jest jednokierunkowa, zawsze rozpoczyna się w węźle head i zawsze blokuje następny muteks przed zwolnieniem blokady bieżącego węzła, zatem nie jest możliwe stosowanie różnych porządków blokad przez różne wątki. Jedynym potencjalnym źródłem sytuacji wyścigu jest operacja usuwania już usuniętego węzła w ramach funkcji remove_if() , ponieważ próba usunięcia następuje po odblokowaniu muteksu (próba zniszczenia zablokowanego muteksu prowadzi do niezdefiniowanego zachowania). Wystarczy jednak nieco bliżej przeanalizować to rozwiązanie, aby dojść do przekonania, że w rzeczywistości jest bezpieczne, ponieważ wciąż jest blokowany muteks dla poprzedniego węzła (current), zatem żaden nowy wątek nie będzie próbował zablokować aktualnie usuwanego węzła. Co z potencjałem przetwarzania współbieżnego? Szczegółowe blokady stosuje się przede wszystkim z myślą o zwiększeniu potencjału przetwarzania współbieżnego (w porównaniu z modelem na bazie jednego muteksu), zatem warto odpowiedzieć sobie na pytanie, czy w tym przypadku udało się osiągnąć założony cel? Tak, udało się — różne wątki mogą w tym samym czasie operować na różnych węzłach listy, niezależnie od tego, czy ich działanie polega na przetwarzaniu kolejnych elementów za pomocą funkcji for_each(), na przeszukiwaniu listy za pomocą funkcji find_first_if(), czy na usuwaniu elementów listy za pomocą funkcji remove_if(). Ponieważ jednak muteksy muszą być kolejno blokowane dla poszczególnych węzłów, żaden wątek nie może wyprzedzić w działaniu wcześniejszego wątku. Jeśli jeden wątek traci dużo czasu na przetwarzanie określonego węzła, pozostałe wątki muszą wstrzymać działanie do momentu, aż same będą mogły osiągnąć ten węzeł.

218

6.4.

ROZDZIAŁ 6. Projektowanie współbieżnych struktur danych przy użyciu blokad

Podsumowanie Na początku tego rozdziału wyjaśniłem, co rozumiem przez projektowanie struktur danych pod kątem przetwarzania współbieżnego, i zaproponowałem kilka wskazówek dotyczących tego procesu. Omówiłem następnie kilka popularnych struktur danych (stos, kolejkę, tablicę asocjacyjną i listę jednokierunkową) w kontekście praktycznego stosowania tych wskazówek, czyli implementacji pod kątem współbieżnego dostępu (z wykorzystaniem blokad chroniących dane i zapobiegających wyścigom danych). Po lekturze tego rozdziału czytelnik powinien potrafić oceniać potencjał własnych projektów struktur danych w zakresie przetwarzania współbieżnego, a także wskazywać miejsca narażone na sytuacje wyścigu. W rozdziale 7. omówimy sposoby całkowitego unikania blokad przy użyciu niskopoziomowych operacji atomowych, które umożliwiają wymuszanie ograniczeń kolejnościowych i pozwalają projektować kod zgodnie z zaleceniami.

Projektowanie współbieżnych struktur danych bez blokad

W tym rozdziale zostaną omówione następujące zagadnienia: Q

Q

Q

przykłady implementacji struktur danych zaprojektowanych pod kątem przetwarzania współbieżnego bez stosowania blokad; techniki zarządzania pamięcią w strukturach danych bez blokad; proste wskazówki ułatwiające tworzenie struktur danych bez blokad.

W poprzednim rozdziale przeanalizowaliśmy ogólne aspekty projektowania struktur danych pod kątem przetwarzania współbieżnego, w tym wskazówki dotyczące zapewniania bezpieczeństwa tych struktur. W tym samym rozdziale omówiłem też kilka popularnych struktur danych wraz z przykładami konkretnych implementacji. We wszystkich tych przykładach współdzielone dane były chronione przez muteksy i blokady. W kilku pierwszych przykładach stosowałem po jednym muteksie chroniącym całą strukturę danych. W implementacjach prezentowanych w dalszej części poprzedniego rozdziału używałem wielu muteksów do ochrony rozmaitych mniejszych części struktur danych — dzięki temu możliwe było wprowadzanie dodatkowych poziomów przetwarzania współbieżnego w kodzie operującym na tych strukturach.

220

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Muteksy to sprawdzone mechanizmy umożliwiania wielu wątkom bezpiecznego dostępu do struktury danych bez ryzyka sytuacji wyścigu czy naruszenia niezmienników. Analiza i interpretacja zachowań kodu stosującego muteksy jest stosunkowo prosta — taki kod albo blokuje muteks chroniący dane, albo nie blokuje tego muteksu. Muteksy nie rozwiązują jednak wszystkich problemów; w rozdziale 3. opisałem przypadki, w których nieprawidłowe stosowanie blokad może prowadzić do zakleszczeń. Co więcej, w rozdziale 6. pokazałem (na przykładzie kolejki i tablicy mieszającej), że szczegółowość blokad może mieć zasadniczy wpływ na potencjał struktur danych w kontekście przetwarzania współbieżnego. Gdybyśmy potrafili projektować struktury danych, które gwarantowałyby bezpieczeństwo współbieżnego dostępu mimo braku blokad, moglibyśmy uniknąć tych problemów. Takie struktury określa się mianem struktur danych bez blokad. W tym rozdziale przeanalizujemy możliwości wykorzystania właściwości porządkowania pamięci dostępne dla operacji atomowych (wprowadzonych w rozdziale 5.) do budowy struktur danych bez blokad. Projektowanie takich struktur wymaga daleko idącej ostrożności, ponieważ wypracowanie prawidłowego rozwiązania jest dość trudne, a warunki powodujące błędy mogą być spełnione wyjątkowo rzadko (i jako takie być trudne do zdiagnozowania). W pierwszym podrozdziale wyjaśnię, co rozumiem przez struktury danych bez blokad. W dalszej części tego rozdziału opiszę argumenty na rzecz stosowania tych struktur i na koniec przedstawię kilka przykładów wraz z ogólnymi wskazówkami, jak projektować tego rodzaju struktury.

7.1.

Definicje i ich praktyczne znaczenie Algorytmy i struktury danych używające muteksów, zmiennych warunkowych i przyszłości do synchronizowania danych określa się mianem struktur i algorytmów blokujących. Aplikacja wywołuje funkcje biblioteki, które wstrzymują wykonywanie wątku do momentu wykonania określonych czynności przez inny wątek. Wywołania tych funkcji określa się mianem wywołań blokujących, ponieważ bieżący wątek nie może kontynuować działania do momentu usunięcia blokady. System operacyjny zwykle całkowicie wstrzymuje wykonywanie zablokowanego wątku (i przydziela jego przedziały czasowe innemu wątkowi) do momentu zwolnienia blokady w wyniku działań wykonanych w innym wątku (na przykład odblokowania muteksu, wysłania sygnału do zmiennej warunkowej lub przejścia obiektu przyszłości w stan gotowości). Struktury danych i algorytmy, które nie używają funkcji blokujących, określa się mianem nieblokujących. Okazuje się jednak, że nie wszystkie takie struktury danych można nazywać strukturami bez blokad — warto więc przeanalizować poszczególne rodzaje nieblokujących struktur danych.

7.1.1.

Rodzaje nieblokujących struktur danych

W rozdziale 5. zaimplementowaliśmy prosty muteks za pomocą typu atomowego std:: ´atomic_flag (użytego w roli tzw. blokady wirującej). Dla przypomnienia kod tego muteksu pokazano na listingu 7.1. Listing 7.1. Implementacja muteksu wirującego przy użyciu typu std::atomic_flag

class spinlock_mutex { std::atomic_flag flag;

7.1.

Definicje i ich praktyczne znaczenie

221

public: spinlock_mutex(): flag(ATOMIC_FLAG_INIT) {} void lock() { while(flag.test_and_set(std::memory_order_acquire)); } void unlock() { flag.clear(std::memory_order_release); } };

Kod muteksu nie wywołuje żadnych funkcji blokujących. Zamiast tego funkcja lock() wykonuje pętlę do momentu zwrócenia wartości false przez wywołanie funkcji test_ ´and_set(). Właśnie dlatego mechanizmy tego typu określa się mianem blokad wirujących — kod „wiruje” w pętli. Jak widać, w kodzie nie zastosowano żadnych wywołań blokujących, zatem kod używający tego muteksu do ochrony danych współdzielonych nie jest kodem blokującym. Nie oznacza to jednak, że ten kod nieblokujący jest kodem bez blokad. Wciąż stosujemy muteks, który może być jednocześnie blokowany tylko przez jeden wątek. Przeanalizujmy definicję struktury danych bez blokad, aby zrozumieć, które rozwiązania mieszczą się w tej kategorii. 7.1.2.

Struktury danych bez blokad

Warunkiem zakwalifikowania struktury danych jako struktury bez blokad jest możliwość uzyskiwania dostępu do tej struktury przez wiele wątków jednocześnie. Wątki nie muszą mieć możliwości wykonywania tych samych operacji — na przykład kolejka bez blokad może umożliwiać jednemu wątkowi dodanie elementu i innemu wątkowi pobranie elementu, ale wykluczać możliwość jednoczesnego dodawania nowych elementów przez te dwa wątki. Co więcej, jeśli jeden z wątków uzyskujących dostęp do struktury danych zostanie zablokowany przez mechanizm szeregowania zadań w trakcie wykonywania bieżącej operacji, pozostałe wątki nadal będą mogły realizować swoje zadania bez konieczności czekania na wstrzymany wątek. Algorytmy wykonujące na strukturach danych operacje porównywania i wymiany często wywołują odpowiednie funkcje w pętlach. Operację porównania i wymiany stosuje się dlatego, że inny wątek może w międzyczasie wprowadzić zmiany w danych — w takim przypadku należy ponowić próbę wykonania całej operacji porównania i wymiany. Taki kod może spełniać warunki struktury bez blokad, jeśli operacja porównania i wymiany ostatecznie zostanie wykonana (jeśli wykonywanie pozostałych wątków zostanie wstrzymane). W przeciwnym razie mielibyśmy do czynienia raczej z blokadą wirującą, czyli strukturą nieblokującą, ale nie strukturą bez blokad. Algorytmy bez blokad obejmujące tego rodzaju pętle mogą prowadzić do tzw. zagłodzenia jednego z wątków. Jeśli inny wątek wykonuje swoje operacje w „niewłaściwym” czasie, może się okazać, że właśnie ten drugi wątek płynnie realizuje swoje zadania, podczas gdy pierwszy wątek stale ponawia próby wykonania jednej operacji. Struktury danych, w których nie występuje ten problem, są nie tylko strukturami bez blokad, ale też strukturami bez oczekiwania.

222 7.1.3.

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Struktury danych bez oczekiwania

Struktura danych bez oczekiwania to struktura bez blokady, która dodatkowo gwarantuje każdemu wątkowi uzyskującemu dostęp do tej struktury możliwość wykonywania operacji w skończonej liczbie kroków (niezależnie od zachowania pozostałych wątków). Oznacza to, że do grupy algorytmów bez oczekiwania nie należą algorytmy, które w niekorzystnych warunkach (z powodu działań innych wątków) mogą ponawiać próby wykonania operacji w nieskończoność. Projektowanie prawidłowych struktur danych bez oczekiwania jest bardzo trudne. Aby zagwarantować możliwość wykonywania operacji w skończonej liczbie kroków przez wszystkie wątki, należy zapewnić możliwość wykonywania tych operacji w pierwszej próbie oraz wyeliminować ryzyko zakłócania operacji wykonywanych w jednym wątku przez działania podejmowane w innym wątku. Realizacja tego celu może znacznie skomplikować algorytmy wykonujące rozmaite operacje. Skoro właściwe projektowanie struktur danych bez blokad i bez oczekiwania jest takie trudne, należy to robić tylko w szczególnych przypadkach, kiedy można z całą pewnością przyjąć, że korzyści przewyższą poniesione koszty. Warto więc przeanalizować punkty, które mają wpływ na ten rachunek zysków i strat. 7.1.4.

Zalety i wady struktur danych bez blokad

Najważniejszym argumentem na rzecz stosowania struktur danych bez blokad jest możliwość osiągnięcia maksymalnej współbieżności. W przypadku kontenerów stosujących blokady zawsze istnieje ryzyko, że jeden wątek zostanie zablokowany i będzie musiał czekać na zakończenie jakiejś operacji przez inny wątek — takie oczekiwanie (czyli istota wzajemnego wykluczania oferowanego przez muteks) uniemożliwia prawdziwie współbieżne działanie. W przypadku struktury danych bez blokad w każdym kroku jakiś wątek przybliża się do końca wykonywanej operacji. W przypadku struktury danych bez oczekiwania każdy wątek notuje postęp w realizacji swoich zadań niezależnie od tego, co robią pozostałe wątki — żaden wątek nie musi czekać na swoją kolej. Ta wyjątkowo pożądana cecha jest bardzo trudna do osiągnięcia. Próby implementacji odpowiednich rozwiązań bardzo często kończą się opracowaniem zwykłej blokady wirującej. Drugim argumentem przemawiającym za stosowaniem struktur danych bez blokad jest niezawodność. Jeśli wątek przerywa działanie w czasie, gdy dysponuje blokadą, przetwarzana struktura danych będzie trwale uszkodzona. Jeśli jednak wątek przerwie działanie w trakcie operacji na strukturze danych bez blokady, zostaną utracone tylko dane tego wątku; pozostałe wątki będą mogły normalnie kontynuować działanie. Skoro jednak nie możemy zapobiegać dostępowi wątków do struktury danych, musimy albo zachować daleko idącą ostrożność, aby nie naruszać istniejących niezmienników, albo sformułować alternatywne niezmienniki, których zachowanie w nowym modelu będzie możliwe. Musimy też mieć na uwadze ograniczenia kolejnościowe zdefiniowane dla poszczególnych operacji. Aby uniknąć niezdefiniowanych zachowań wskutek wyścigu danych, należy wprowadzać modyfikacje przy użyciu operacji atomowych. Okazuje się jednak, że same operacje atomowe nie wystarczą — musimy jeszcze zagwarantować, że zmiany będą widoczne z perspektywy pozostałych wątków we właściwej kolejności. Oznacza to, że budowanie struktur danych gwarantujących bezpieczeństwo przetwa-

7.2.

Przykłady struktur danych bez blokad

223

rzania wielowątkowego bez stosowania blokad jest nieporównanie trudniejsze niż projektowanie struktur z blokadami. Skoro nie istnieją żadne blokady, zakleszczenia są w przypadku tych struktur danych niemożliwe, ale istnieje ryzyko tzw. uwięzienia (nazywanego też błądzeniem — ang. livelock). Uwięzienie ma miejsce w sytuacji, gdy dwa wątki próbują zmienić tę samą strukturę danych i gdy działanie drugiego wątku wymusza rozpoczęcie tej operacji od początku. Oznacza to, że oba wątki stale próbują ponowić te same operacje w pętli. Wyobraźmy sobie dwie osoby próbujące pokonać wąskie przejście. Jeśli obie te osoby spróbują przejść jednocześnie, utkną; w takiej sytuacji wycofają się i po pewnym czasie wrócą do tego samego punktu. Jeśli żadna z tych osób nie wróci w to miejsce pierwsza (w wyniku uzgodnień z drugim zainteresowanym, szybszego wykonania operacji lub zwykłego szczęścia), cały cykl zostanie powtórzony. Tak jak w przytoczonym przykładzie, zjawisko uwięzienia zwykle trwa dość krótko, ponieważ zależy od szczegółowego szeregowania wątków. Uwięzienie prowadzi więc do nieznacznego spadku wydajności, nie do długoterminowych problemów; mimo to warto mieć na uwadze to zjawisko i próbować mu zapobiegać. Zgodnie z definicją zjawisko uwięzienia nie może występować w kodzie bez oczekiwania, ponieważ zawsze istnieje górna granica kroków potrzebnych do wykonania operacji. Okazuje się jednak, że tak zaprojektowany algorytm jest zwykle bardziej skomplikowany od algorytmu stosującego mechanizm oczekiwania. Co więcej, taki algorytm może wymagać większej liczby kroków, jeśli inne wątki nie uzyskują dostępu do tej samej struktury danych. W ten sposób dochodzimy do innej wady kodu bez oczekiwania i bez blokad — mimo że taki kod może zwiększyć potencjał współbieżnego wykonywania operacji na strukturze danych i skrócić czas tracony przez poszczególne wątki w stanie oczekiwania, łączna wydajność aplikacji może być niższa. Po pierwsze, operacje atomowe używane w kodzie bez blokad często są dużo wolniejsze od odpowiednich operacji nieatomowych, a ich liczba w przypadku struktury danych bez blokad często jest większa niż w kodzie na bazie muteksów (stosowanym dla struktur z blokadami). Co więcej, warstwa sprzętowa musi synchronizować dane pomiędzy różnymi wątkami uzyskującymi dostęp do tych samych zmiennych atomowych. W rozdziale 8. opiszę zjawisko ping-pong bufora (ang. cache ping-pong) związane z modelem, w którym wiele wątków uzyskuje dostęp do tych samych zmiennych atomowych. Problem ten może mieć duży negatywny wpływ na wydajność aplikacji. Jak w każdym przypadku, przed podjęciem ostatecznej decyzji warto przeanalizować rozmaite aspekty związane z wydajnością aplikacji (między innymi scenariusz działania aplikacji w najgorszym przypadku, średni czas oczekiwania, łączny czas wykonywania) zarówno w przypadku struktury danych na bazie blokad, jak i struktury danych bez blokad. Przeanalizujmy teraz kilka przykładów.

7.2.

Przykłady struktur danych bez blokad Aby zademonstrować wybrane techniki używane podczas projektowania struktur danych bez blokad, przeanalizujemy odpowiednio zmienione implementacje kilku prostych struktur. Poszczególne przykłady nie tylko będą demonstrowały przydatne struktury danych, ale też zostaną użyte w roli ilustracji wybranych aspektów projektowania struktur danych bez blokad.

224

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Jak już wspomniałem, struktury danych bez blokad wymagają stosowania operacji atomowych i odpowiednich trybów porządkowania pamięci, ponieważ tylko w ten sposób można zagwarantować widoczność danych dla pozostałych wątków w odpowiedniej kolejności. Początkowo dla wszystkich operacji atomowych będziemy stosowali domyślny tryb porządkowania pamięci memory_order_seq_cst memory, ponieważ jego interpretacja jest najprostsza (należy pamiętać, że dla wszystkich operacji wykonywanych w tym trybie jest wyznaczany łączny porządek). W dalszych przykładach podejmiemy próbę złagodzenia ograniczeń kolejnościowych poprzez stosowanie opcji memory_order_acquire, memory_order_release, a nawet memory_order_relaxed. Mimo że w żadnym z tych przykładów nie będziemy bezpośrednio używali mechanizmu blokowania muteksów, warto pamiętać, że tylko w przypadku implementacji typu std::atomic_flag mamy gwarancję braku stosowania blokad. Na niektórych platformach rozwiązania, które na pierwszy rzut oka sprawiają wrażenie mechanizmów bez blokad, w rzeczywistości mogą używać wewnętrznych blokad implementacji biblioteki standardowej języka C++ (więcej informacji na ten temat można znaleźć w rozdziale 5.). W przypadku tych platform wybór prostej struktury danych na bazie blokad może być lepszą decyzją, która jednak wymaga bliższej analizy — przed wybraniem odpowiedniej implementacji należy zidentyfikować wymagania i zbadać dostępne rozwiązania pod kątem zgodności z tymi wymaganiami. Zacznijmy od omówienia najprostszej struktury danych: stosu. 7.2.1.

Implementacja stosu gwarantującego bezpieczeństwo przetwarzania wielowątkowego bez blokad

Koncepcja stosu jest stosunkowo prosta: węzły są odczytywane w odwrotnej kolejności, niż zostały dodane do tej struktury — stos jest więc strukturą LIFO (od ang. last in, first out). W tej sytuacji ważnym aspektem stosu jest zagwarantowanie możliwości bezpiecznego pobrania przez wątek wartości właśnie dodanej przez inny wątek. Równie ważne jest zagwarantowanie, że tę samą wartość pobierze (zdejmie ze stosu) tylko jeden wątek. W najprostszej postaci stos jest reprezentowany przez listę jednokierunkową, gdzie wskaźnik head identyfikuje pierwszy węzeł (ten, który zostanie zwrócony w najbliższej operacji odczytu) i gdzie każdy węzeł zawiera wskaźnik do kolejnego węzła. W tym schemacie procedura dodania nowego węzła jest stosunkowo prosta:

1. utworzenie nowego węzła; 2. ustawienie wskaźnika next, tak aby wskazywał dotychczasowy węzeł head; 3. ustawienie wskaźnika head, tak aby wskazywał nowy węzeł. Opisane rozwiązanie sprawdza się w przypadku aplikacji jednowątkowych, ale nie wystarczy do obsługi wielu wątków modyfikujących stos. Jeśli na przykład dwa wątki jednocześnie dodają węzły, mamy do czynienia z sytuacją wyścigu pomiędzy krokami 2. i 3. — drugi wątek może zmodyfikować wartość węzła head po jej odczytaniu przez pierwszy wątek (w kroku 2.), ale przed jego aktualizacją (w kroku 3.). Opisana sytuacja wyścigu może spowodować, że zmiany wprowadzone przez drugi wątek zostaną utracone. Zanim podejmiemy próbę wyeliminowania tego problemu, powinniśmy zwrócić uwagę na jeszcze jeden problem — zaraz po zaktualizowaniu wskaźnika head, tak aby wskazywał nowy węzeł, inny wątek może odczytać ten węzeł. W tej sytuacji należy

7.2.

Przykłady struktur danych bez blokad

225

zadbać o właściwe przygotowanie węzła head przed ustawieniem odpowiedniego wskaźnika, ponieważ późniejsze modyfikacje tego węzła będą niemożliwe. Co w takim razie możemy zrobić z tą niepożądaną sytuacją wyścigu? Rozwiązaniem jest użycie w kroku 3. atomowej operacji porównania i wymiany, aby wykluczyć możliwość modyfikacji węzła head od momentu jego odczytania w kroku 2. W razie modyfikacji należałoby wrócić na początek pętli i wykonać tę operację ponownie. Na listingu 7.2 pokazano możliwy sposób implementacji funkcji push() gwarantującej bezpieczeństwo przetwarzania wielowątkowego bez blokad. Listing 7.2. Implementacja funkcji push() bez blokad

template class lock_free_stack { private: struct node { T data; node* next; node(T const& data_): data(data_) {} }; std::atomic head; public: void push(T const& data) { node* const new_node=new node(data); new_node->next=head.load(); while(!head.compare_exchange_weak(new_node->next,new_node)); } };

Kod z listingu 7.2 jest w pełni zgodny z opisanym wcześniej trzypunktowym planem — tworzy nowy węzeł , ustawia w tym węźle wskaźnik next, tak aby wskazywał dotychczasowy węzeł head , i ustawia wskaźnik head, tak aby wskazywał nowy węzeł . Wypełnienie danych struktury node na poziomie konstruktora instancji tej struktury gwarantuje nam, że nowy węzeł może być pobrany (zdjęty ze szczytu stosu) zaraz po skonstruowaniu. Problem natychmiastowego odczytania nowego węzła udało się więc dość łatwo rozwiązać. Używamy następnie funkcji compare_exchange_weak() do sprawdzenia, czy wskaźnik head wciąż zawiera tę samą wartość, którą zapisaliśmy w polu new_node->next ; jeśli tak, ustawiamy w tym polu wartość new_node. W opisanym fragmencie kodu wykorzystano pewną ciekawą cechę operacji porównywania i wymiany — jeśli ta operacja zwraca wartość false, która oznacza, ze porównanie zakończyło się niepowodzeniem (na przykład z powodu zmodyfikowania węzła head przez inny wątek), wartość przekazana za pośrednictwem pierwszego parametru (w tym przypadku new_ ´node->next) jest aktualizowana, tak aby zawierała bieżącą wartość wskaźnika head. Oznacza to, że nie musimy każdorazowo ponownie ładować wartości head w pętli, ponieważ niezbędne działania podejmie sam kompilator. Co więcej, ponieważ pętla jest

226

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

wykonywana tak długo, jak operacja porównania i wymiany zwraca wartość false, możemy użyć funkcji compare_exchange_weak, która w niektórych architekturach pozwala wygenerować lepiej zoptymalizowany kod niż funkcja compare_exchange_strong (patrz rozdział 5.). Mimo że wciąż nie dysponujemy operacją pop(), możemy sprawdzić, czy implementacja funkcji push() jest zgodna ze sformułowanymi wcześniej wskazówkami. Jedynym miejscem, w którym ten kod może zgłosić wyjątek, jest procedura konstruowania nowej instancji struktury node , jednak na tym etapie lista nie jest modyfikowana, zatem ta część kodu jest w pełni bezpieczna. Ponieważ dane mają być przechowywane w ramach struktury node i ponieważ do aktualizacji wskaźnika head możemy użyć funkcji compare_ ´exchange_weak(), kod w tej formie nie powoduje ryzyka sytuacji wyścigu. Po pomyślnym wykonaniu operacji porównania i wymiany nowy węzeł znajduje się na liście i jako taki jest gotowy do pobrania. Ponieważ w opisanej implementacji nie zastosowano żadnych blokad, nie ma ryzyka zakleszczeń, a funkcja push() realizuje swoje zadania bez najmniejszych problemów. Skoro dysponujemy już operacją umieszczania danych na stosie, potrzebujemy jeszcze mechanizmu ich zdejmowania ze stosu. Kroki składające się na tę operację są dość proste:

1. odczytanie bieżącej wartości węzła head, 2. odczytanie wartości pola head->next, 3. przypisanie wartości head polu head->next, 4. zwrócenie danych zawartych w odczytanym węźle, 5. usunięcie odczytanego węzła. Okazuje się jednak, że w środowisku obejmującym wiele wątków sytuacja nie jest taka prosta. Jeśli dwa wątki próbują usuwać elementy ze stosu, oba mogą odczytać tę samą wartość węzła head w kroku 1. Jeśli jeden z tych wątków wykona wszystkie czynności aż do kroku 5., zanim drugi wątek osiągnie krok 2., ten drugi wątek będzie operował na tzw. dyndającym wskaźniku (ang. dangling pointer). Opisany problem należy do największych utrudnień związanych z pisaniem kodu bez blokad, zatem zapomnijmy na chwilę o kroku 5. i kwestii wycieku węzłów. Okazuje się jednak, że na tym problemy się nie kończą. Warto zwrócić uwagę na jeszcze jeden problem: jeśli dwa wątki odczytują tę samą wartość elementu head, oba te wątki zwracają ten sam węzeł. Takie działanie narusza jedną z podstawowych reguł zachowania stosu i jako takie musi zostać wyeliminowane. Problem można rozwiązać w ten sam sposób, w jaki wyeliminowaliśmy sytuację wyścigu z funkcji push() — należy aktualizować wartość head za pomocą operacji porównania i wymiany. Jeśli próba wykonania tej operacji zakończy się niepowodzeniem, możemy przyjąć, że albo umieszczono na stosie nowy węzeł, albo inny wątek właśnie zdjął ze stosu węzeł, który próbowaliśmy pobrać w bieżącym wątku. W obu przypadkach należy wrócić do kroku 1. (mimo że samo wywołanie operacji porównania i wymiany ponownie odczytuje element head). Po pomyślnym wykonaniu operacji porównania i wymiany możemy być pewni, że bieżący wątek jest jedynym, który zdjął dany węzeł ze szczytu stosu, zatem możemy bezpiecznie przejść do kroku 4. Pierwszą wersję funkcji pop() pokazano poniżej:

7.2.

Przykłady struktur danych bez blokad

227

template class lock_free_stack { public: void pop(T& result) { node* old_head=head.load(); while(!head.compare_exchange_weak(old_head,old_head->next)); result=old_head->data; } };

Kod w tej formie jest co prawda czytelny i dość zwięzły, jednak wciąż rodzi szereg problemów (oprócz wspomnianego wcześniej wycieku węzłów). Po pierwsze, funkcja nie obsługuje prawidłowo listy pustej — jeśli head ma postać wskaźnika pustego, wywołanie tej funkcji doprowadzi do niezdefiniowanego zachowania (w związku z próbą odczytania wskaźnika next). Ten problem można łatwo rozwiązać, sprawdzając w pętli while występowanie wskaźnika pustego i — zależnie od wyniku tego testu — zgłaszając wyjątek pustego stosu lub zwracając wartość typu bool reprezentującą wynik operacji. Drugi problem dotyczy bezpieczeństwa w razie wystąpienia wyjątków. Przy okazji wprowadzania stosu gwarantującego bezpieczeństwo przetwarzania wielowątkowego w rozdziale 3. wykazałem, że zwykłe zwracanie obiektu przez wartość może zagrozić bezpieczeństwu obsługi wyjątków (jeśli ten wyjątek jest zgłaszany w czasie kopiowania zwracanej wartości, ta wartość jest tracona). W takim przypadku przekazywanie referencji do wyniku jest wystarczającym rozwiązaniem, ponieważ pozwala zagwarantować, że w razie zgłoszenia wyjątku stos pozostanie niezmieniony. W tym przypadku nie możemy sobie jednak pozwolić na taki luksus; bezpieczne kopiowanie danych jest możliwe dopiero po sprawdzeniu, czy bieżący wątek jest jedynym wątkiem zwracającym dany węzeł (w przeciwnym razie ten węzeł byłby już usunięty ze struktury danych). Oznacza to, że przekazywanie zmiennej docelowej dla wartości zwracanej przez referencję w tym przypadku nie rozwiązuje problemu: równie dobrze można zwracać dane przez wartość. Warunkiem bezpiecznego zwracania tej wartości jest zastosowanie innego rozwiązania z rozdziału 3. — zwrócenie wskaźnika (być może wskaźnika inteligentnego) do wartości danych. W przypadku zwracania wskaźnika inteligentnego można zwrócić po prostu wartość nullptr określającą, że nie istnieje żadna wartość do zwrócenia. Takie rozwiązanie wymaga jednak alokowania danych na stercie. Jeśli funkcja pop() alokuje miejsce na stercie, proponowana zmiana nie rozwiązuje problemu, ponieważ sama operacja alokowania może zgłosić wyjątek. Zamiast tego możemy alokować pamięć w momencie umieszczania danych na stosie przez funkcję push() — alokacja pamięci dla nowego węzła jest przecież nieunikniona. Zwracanie wartości typu std::shared_ptr nie spowoduje zgłoszenia wyjątku, zatem tak zmieniona funkcja pop() będzie bezpieczna. Wszystkie opisane zmiany pokazano na listingu 7.3. Listing 7.3. Stos bez blokad (narażony na problem wycieku węzłów)

template class lock_free_stack { private:

228

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad struct node { std::shared_ptr data; node* next;

Dane są teraz wskazywane przez wskaźnik

node(T const& data_): data(std::make_shared(data_)) {}

Tworzy obiekt typu std::shared_ptr dla zaalokowanej instancji typu T

}; std::atomic head; public: void push(T const& data) { node* const new_node=new node(data); new_node->next=head.load(); while(!head.compare_exchange_weak(new_node->next,new_node)); } std::shared_ptr pop() { node* old_head=head.load(); Przed użyciem wskaźnika old_head sprawdza, czy ten wskaźnik nie jest pusty while(old_head && !head.compare_exchange_weak(old_head,old_head->next)); return old_head ? old_head->data : std::shared_ptr(); } };

Dane są teraz reprezentowane przez wskaźnik , zatem musimy zaalokować te dane na stercie w konstruktorze struktury node . W pętli wywołującej funkcję compare_ ´exchange_weak() musimy dodatkowo sprawdzić, czy wskaźnik old_head nie jest pusty . I wreszcie nasz kod musi albo zwrócić wartość związaną z danym węzłem (jeśli taka wartość istnieje), albo wskaźnik pusty . Łatwo zauważyć, że chociaż kod w tej formie jest przykładem rozwiązania bez blokad, nie jest to algorytm bez oczekiwania, ponieważ pętle while w obu funkcjach (push() i pop()) teoretycznie mogą być nieskończone, jeśli funkcja compare_exchange_weak() stale będzie wykazywała błąd. W środowisku z mechanizmem odzyskiwania pamięci (czyli na przykład w takich językach zarządzanych jak C# czy Java) rozwiązanie w tej formie byłoby kompletne — stary węzeł byłby usunięty z pamięci niedługo po zniszczeniu odwołań we wszystkich wątkach. Okazuje się, że wiele kompilatorów języka C++ nie oferuje takich mechanizmów odzyskiwania pamięci, zatem za realizację tych zadań odpowiada sam programista aplikacji. 7.2.2.

Eliminowanie niebezpiecznych wycieków — zarządzanie pamięcią w strukturach danych bez blokad

Przy okazji analizy pierwszej wersji funkcji pop() zdecydowaliśmy się dopuścić do wycieku węzłów, aby uniknąć sytuacji wyścigu. Problem dotyczy przypadku, w którym jeden wątek usuwa węzeł w czasie, gdy inny wątek dysponuje wskaźnikiem do tego węzła (i planuje użyć tego wskaźnika). Wycieków pamięci nie można jednak akceptować w żadnym programie języka C++, zatem musimy podjąć jakieś działania naprawcze. Czas przyjrzeć się temu problemowi nieco bliżej i podjąć próbę znalezienia rozwiązania.

7.2.

Przykłady struktur danych bez blokad

229

Problem polega na tym, że chcemy zwolnić węzeł, ale nie możemy tego zrobić, jeśli nie mamy pewności, czy żaden inny wątek nie dysponuje wciąż wskaźnikiem do tego węzła. Jeśli tylko jeden wątek wywołuje funkcję pop() dla konkretnej instancji stosu, problem w ogóle nie występuje. Skoro po umieszczeniu węzła na stosie funkcja push() nie podejmuje żadnych działań dotyczących tego węzła, wątek, który wywołał funkcję pop(), z natury rzeczy jest jedynym wątkiem odwołującym się do tego węzła — oznacza to, że usunięcie węzła ze stosu jest w pełni bezpieczne. Jeśli jednak musimy obsługiwać wiele wątków, które mogą jednocześnie wywoływać funkcję pop() dla tej samej instancji stosu, powinniśmy znaleźć sposób określania, kiedy można bezpiecznie usuwać węzły. Oznacza to, że musimy zaimplementować mechanizm odzyskiwania pamięci specjalnie dla węzłów. Na pierwszy rzut oka zadanie wydaje się dość trudne, jednak okazuje się, że jego realizacja nie stanowi większego problemu: wystarczy sprawdzać węzły, do których uzyskuje dostęp funkcja pop(). W tym przypadku nie interesują nas węzły umieszczane na stosie przez funkcję push(), ponieważ węzeł, który jeszcze nie znajduje się na stosie, z natury rzeczy nie jest dostępny dla pozostałych wątków (problem współbieżnego usuwania dotyczy więc tylko dostępu do tego samego węzła za pośrednictwem funkcji pop()). Jeśli żaden inny wątek nie wywołuje funkcji pop(), usunięcie wszystkich węzłów oczekujących na tę operację jest w pełni poprawne. Jeśli węzły są dodawane do listy „do usunięcia” przy okazji pobierania danych ze stosu, wszystkie te węzły można usunąć w momencie, w którym żaden wątek nie będzie wywoływał funkcji pop(). Jak możemy sprawdzić, czy nie istnieją żadne wątki wywołujące funkcję pop()? To proste — wystarczy policzyć te wątki. Jeśli licznik będzie zwiększany na początku funkcji i zmniejszany na jej końcu, będzie można bezpiecznie usuwać węzły z listy „do usunięcia” w czasie, gdy wartość tego licznika będzie równa zero. Musimy oczywiście użyć licznika atomowego, tak aby do jego wartości mogło bezpiecznie uzyskiwać dostęp wiele wątków. Na listingu 7.4 pokazano poprawioną wersję funkcji pop(); na listingu 7.5 w dalszej części tego punktu pokazano funkcje pomocnicze stworzone z myślą o tej implementacji. Listing 7.4. Zwalnianie węzłów w przypadku braku wątków wykonujących funkcję pop()

template class lock_free_stack { private: Zmienna atomowa std::atomic threads_in_pop; void try_reclaim(node* old_head); public: std::shared_ptr pop() { Przed wszystkimi innymi operacjami zwiększa licznik ++threads_in_pop; node* old_head=head.load(); while(old_head && !head.compare_exchange_weak(old_head,old_head->next)); std::shared_ptr res; if(old_head) { Odzyskuje usunięte węzły res.swap(old_head->data); } (jeśli to możliwe)

230

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad try_reclaim(old_head); return res;

Wyodrębnia dane z węzła, zamiast kopiować wskaźnik

} };

Zmienna atomowa threads_in_pop jest używana w roli licznika wątków, które aktualnie próbują zdjąć element ze stosu. Licznik jest zwiększany na początku funkcji pop() i zmniejszany w kodzie funkcji try_reclaim(), która jest wywoływana zaraz po usunięciu danego węzła . Ponieważ właściwa operacja usuwania węzła najprawdopodobniej będzie nieco opóźniona, możemy użyć funkcji swap() do usunięcia danych z tego węzła zamiast kopiować odpowiedni wskaźnik — dzięki temu dane będą usuwane automatycznie w momencie, w którym nie będą potrzebne (nie będą utrzymywane tylko z powodu referencji w jakimś nieusuniętym węźle). Kod funkcji try_reclaim() pokazano na listingu 7.5. Listing 7.5. Mechanizm odzyskiwania pamięci na podstawie licznika referencji

template class lock_free_stack { private: std::atomic to_be_deleted; static void delete_nodes(node* nodes) { while(nodes) { node* next=nodes->next; delete nodes; nodes=next; } } void try_reclaim(node* old_head) { if(threads_in_pop==1) Odzyskuje węzły z listy elementów { do usunięcia node* nodes_to_delete=to_be_deleted.exchange(nullptr); Sprawdza, czy jest jedynym wątkiem if(!--threads_in_pop) { wykonującym funkcję pop() delete_nodes(nodes_to_delete); } else if(nodes_to_delete) { chain_pending_nodes(nodes_to_delete); } delete old_head; } else { chain_pending_node(old_head); --threads_in_pop; } } void chain_pending_nodes(node* nodes)

7.2.

Przykłady struktur danych bez blokad

231

{ node* last=nodes; while(node* const next=last->next) { last=next; } chain_pending_nodes(nodes,last); } void chain_pending_nodes(node* first,node* last) { last->next=to_be_deleted; while(!to_be_deleted.compare_exchange_weak( last->next,first)); } void chain_pending_node(node* n) { chain_pending_nodes(n,n); } };

Jeśli podczas próby odzyskania węzła liczba reprezentowana przez zmienną threads_ ´in_pop wynosi 1, bieżący wątek jest jedynym wątkiem aktualnie wykonującym funkcję pop(), co oznacza, że można bezpiecznie usunąć węzeł właśnie zdjęty ze stosu (i wszystkie ewentualne węzły oczekujące na usunięcie). Jeśli jednak wartość licznika jest różna od 1, usuwanie jakichkolwiek węzłów byłoby niebezpieczne, zatem należy dodać dany węzeł do listy węzłów oczekujących na usunięcie . Przyjmijmy na chwilę, że zmienna threads_in_pop zawiera wartość 1. Musimy teraz podjąć próbę usunięcia węzłów oczekujących; gdybyśmy tego nie zrobili, węzły pozostaną w pamięci do czasu zniszczenia samego stosu. W tym celu musimy najpierw uzyskać listę oczekujących węzłów za pomocą operacji atomowej exchange , po czym zmniejszyć licznik reprezentowany przez zmienną threads_in_pop . Jeśli zmniejszony licznik ma wartość zero, możemy być pewni, że żaden inny wątek nie uzyskuje dostępu do listy węzłów oczekujących na usunięcie. Lista może co prawda zawierać nowe węzły, ale na tym etapie nie ma potrzeby ich usuwania — najważniejszym celem operacji jest bezpieczne odzyskanie przestrzeni zajmowanej przez węzły z pierwotnie odczytanej listy. Wystarczy teraz wywołać funkcję delete_nodes, aby iteracyjnie przetworzyć listę i usunąć poszczególne węzły . Jeśli licznik po zmniejszeniu ma wartość inną niż zero, odzyskanie węzłów nie jest bezpieczne. Jeśli lista tych węzłów nie jest pusta , należy ponownie umieścić te węzły na liście do usunięcia . Taka sytuacja może mieć miejsce, jeśli wiele wątków jednocześnie uzyskuje dostęp do tej struktury danych. Pozostałe wątki mogły wywołać funkcję pop() w czasie pomiędzy pierwszym testem zmiennej threads_in_pop a odczytaniem listy , zatem mogły dodać do listy nowe węzły, które wciąż mogą być przedmiotem dostępu tych wątków. Na rysunku 7.1 pokazano sytuację, w której wątek C dodaje węzeł Y do listy to_be_deleted, mimo że wątek B wciąż odwołuje się do tego węzła za pośrednictwem elementu old_head i tym samym podejmie próbę odczytania wskaźnika next w ramach tego elementu. W tej sytuacji próba usunięcia węzłów z listy przez wątek A doprowadziłaby do niezdefiniowanego zachowania wątku B.

232

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Rysunek 7.1. Trzy wątki jednocześnie wywołują funkcję pop() — pokazany scenariusz dobrze ilustruje, dlaczego należy sprawdzać wartość licznika threads_in_pop po odzyskaniu węzłów do usunięcia za pomocą funkcji try_reclaim()

7.2.

Przykłady struktur danych bez blokad

233

Aby dodać węzły oczekujące na usunięcie do odpowiedniej listy, należy ponownie użyć wskaźnika next w ramach tych węzłów do powiązania elementów nowej listy. W przypadku ponownego wiązania istniejącego łańcucha węzłów z tą listą należy przeszukać ten łańcuch, aby wskazać jego koniec , zastąpić wskaźnik next w ostatnim węźle dotychczasowym wskaźnikiem to_be_deleted i zapisać pierwszy węzeł w łańcuchu jako nowy wskaźnik to_be_deleted . W pętli należy użyć funkcji compare_ ´exchange_weak, aby wykluczyć możliwość wycieku węzłów, które zostały dodane przez inny wątek. Zaletą tego rozwiązania jest aktualizacja wskaźnika next z końca łańcucha w przypadku wprowadzenia zmian. Dodanie do tej listy pojedynczego węzła jest szczególnym przypadkiem, w którym pierwszy węzeł łańcucha jest jednocześnie jego ostatnim elementem . Opisane rozwiązanie sprawdza się w przypadku mało obciążonych systemów, gdzie dość często zdarzają się momenty, w których żaden wątek nie wykonuje funkcji pop(). Niewielkie obciążenie systemu zwykle jest jednak sytuacją przejściową — stąd konieczność sprawdzania, czy wartość licznika threads_in_pop spada do zera , przed odzyskaniem węzłów i decyzja o wykonywaniu tego testu przed usunięciem węzła właśnie zdjętego ze stosu . Usuwanie węzła może być czasochłonną operacją, a naszym celem jest maksymalne skrócenie przedziału czasowego, w którym pozostałe wątki będą mogły zmodyfikować tę listę. Im więcej czasu minie pomiędzy wykryciem przez wątek po raz pierwszy wartości 1 w zmiennej threads_in_pop a próbą usunięcia węzłów, tym mniejsze będą szanse wywołania przez inny wątek funkcji pop(), zmiany wartości licznika thre ´ads_in_pop (tak aby nie była już równa 1) i tym samym samego usunięcia węzłów. W mocno obciążonych systemach stan względnego spokoju może nigdy nie występować, ponieważ nowe wątki mogą rozpoczynać wykonywanie funkcji pop(), zanim dotychczasowe wątki zakończą wykonywanie tej funkcji. W takim przypadku lista to_be_deleted rośnie w nieskończoność, co prędzej czy później prowadzi do wycieku pamięci. Jeśli w systemie nie zdarzają się okresy mniejszego obciążenia, musimy znaleźć alternatywny mechanizm odzyskiwania węzłów. Kluczem do rozwiązania tego problemu jest identyfikacja konkretnych węzłów, do których nie uzyskują dostępu żadne inne wątki i które tym samym mogą być bezpiecznie odzyskane. Najprostszym sposobem realizacji tego zadania jest zastosowanie tzw. wskaźników ryzyka (ang. hazard pointers). 7.2.3.

Wykrywanie węzłów, których nie można odzyskać, za pomocą wskaźników ryzyka

Pojęcie wskaźników ryzyka odnosi się do techniki opracowanej przez Mageda Michaela1. Nazwa tych wskaźników wynika z tego, że usunięcie węzła, który wciąż może być przedmiotem referencji w pozostałych węzłach, jest ryzykowne. Jeśli pozostałe wątki rzeczywiście dysponują referencjami do danego węzła i planują dostęp do tego węzła za pośrednictwem tych referencji, zachowanie programu będzie niezdefiniowane. Jeśli jakiś wątek planuje dostęp do obiektu, który może być usuwany przez inny wątek, pierwszy wątek powinien tak ustawić wskaźnik ryzyka, aby odwoływał się do tego obiektu i tym 1

Maged M. Michael, „Safe Memory Reclamation for Dynamic Lock-Free Objects Using Atomic Reads and Writes”, PODC ’02: Proceedings of the Twenty-first Annual Symposium on Principles of Distributed Computing (2002), ISBN 1-58113-485-1.

234

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

samym sygnalizował drugiemu wątkowi, że usunięcie tego obiektu byłoby ryzykowne. Po przetworzeniu obiektu, kiedy nie jest on już potrzebny, pierwszy wątek może usunąć wskaźnik ryzyka. Każdy, kto kiedykolwiek obserwował wyścig łodzi uniwersytetów Oxford i Cambridge, miał okazję zapoznać się z działaniem analogicznego mechanizmu na starcie — sternik każdej z załóg może podnieść rękę, aby zasygnalizować, że jego łódź nie jest gotowa do startu. Jeśli choć jeden sternik podniesie rękę, sędzia nie może rozpocząć wyścigu. Jeśli obaj sternicy opuszczą ręce, wyścig może się rozpocząć; obaj sternicy mogą jednak ponownie podnieść rękę przed rozpoczęciem wyścigu, jeśli uznają, że sytuacja uległa zmianie i wyścig nie powinien się rozpocząć. Przed usunięciem obiektu wątek musi sprawdzić wskaźniki ryzyka należące do pozostałych wątków w systemie. Jeśli żaden z tych wskaźników nie odwołuje się do tego obiektu, można bezpiecznie wykonać operację jego usuwania. W przeciwnym razie wątek musi wstrzymać tę operację. Lista obiektów, których usunięcie zostało odłożone na później, jest okresowo weryfikowana pod kątem zawierania obiektów, które można już usunąć. Na tak wysokim poziomie działanie tego mechanizmu wydaje się stosunkowo proste, jednak warto się zastanowić, jak zaimplementować to rozwiązanie w języku C++? W pierwszym kroku należy wybrać miejsce przechowywania wskaźnika do obiektu, do którego uzyskujemy dostęp, czyli samego wskaźnika ryzyka. Wspomniany wskaźnik musi być widoczny dla wszystkich wątków. Co więcej, należy utworzyć taki wskaźnik dla każdego wątku, który może uzyskiwać dostęp do tej struktury danych. Prawidłowe i efektywne alokowanie tych wskaźników może być dość trudne, zatem odłóżmy to zadanie na później i przyjmijmy, że dysponujemy już gotową funkcją get_hazard_pointer_ ´for_current_thread(), która zwraca referencję do wskaźnika ryzyka. Musimy teraz ustawić ten wskaźnik przy okazji odczytywania wskaźnika do przetworzenia — w tym przypadku będzie to wartość head odczytana ze struktury listy: std::shared_ptr pop() { std::atomic& hp=get_hazard_pointer_for_current_thread(); node* old_head=head.load(); node* temp; do { temp=old_head; hp.store(old_head); old_head=head.load(); } while(old_head!=temp); // ... }

Opisane działania należy wykonywać w pętli while, aby wykluczyć możliwość usunięcia a ustawieniem wskaźnika węzła pomiędzy odczytaniem starego wskaźnika head ryzyka . W czasie pomiędzy tymi operacjami żaden inny wątek nie wie, że uzyskujemy dostęp do tego węzła. Gdyby jednak stary węzeł head miał zostać usunięty, musiałaby ulec zmianie wartość samego wskaźnika head, co można łatwo sprawdzić. W razie wykrycia tej zmiany należałoby kontynuować działanie pętli do momentu wykrycia we wskaźniku head tej samej wartości, którą przypisaliśmy wskaźnikowi ryzyka . Stosowanie wskaźników ryzyka jest możliwe, ponieważ operowanie na wartości wskaźnika po

7.2.

Przykłady struktur danych bez blokad

235

usunięciu wskazywanego obiektu jest dopuszczalne. W przypadku domyślnej implementacji operatorów new i delete opisane działanie prowadzi do niezdefiniowanych zachowań, zatem należy albo zadbać o akceptację tego rozwiązania przez stosowaną implementację, albo użyć niestandardowego mechanizmu alokacji, który dopuszcza takie działanie. Skoro ustawiliśmy już wskaźnik ryzyka, możemy przystąpić do wykonywania dalszej części pliku pop(), ponieważ wiemy, że żaden inny wątek nie usunie interesujących nas węzłów. Okazuje się, że przy okazji niemal każdego ładowania wartości old_head należy zaktualizować ten wskaźnik ryzyka przed przetworzeniem właśnie odczytanej wartości wskaźnika. Po pobraniu węzła z listy możemy usunąć wskaźnik ryzyka. Jeśli do tego samego węzła nie odwołują się żadne inne wskaźniki ryzyka, możemy ten węzeł bezpiecznie usunąć; w przeciwnym razie należy ten węzeł dodać do listy węzłów oczekujących na usunięcie. Kompletną implementację funkcji pop() według opisanego schematu pokazano na listingu 7.6. Listing 7.6. Implementacja funkcji pop() z wykorzystaniem wskaźników ryzyka

std::shared_ptr pop() { std::atomic& hp=get_hazard_pointer_for_current_thread(); node* old_head=head.load(); do { Pętla powtarzana do momentu takiego ustawienia wskaźnika node* temp; ryzyka, aby wskazywał węzeł head do { temp=old_head; hp.store(old_head); old_head=head.load(); } while(old_head!=temp); } while(old_head && !head.compare_exchange_strong(old_head,old_head->next)); Po zakończeniu zeruje wskaźnik ryzyka hp.store(nullptr); std::shared_ptr res; if(old_head) Przed usunięciem węzła sprawdza ewentualne { wskaźniki ryzyka wskazujące res.swap(old_head->data); ten węzeł if(outstanding_hazard_pointers_for(old_head)) { reclaim_later(old_head); } else { delete old_head; } delete_nodes_with_no_hazards(); } return res; }

Pętla ustawiająca wskaźnik ryzyka została przeniesiona do pętli zewnętrznej, która ponownie ładuje wartość old_head w razie niepowodzenia operacji porównania i wymiany .

236

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

W tej implementacji zastosowano funkcję compare_exchange_strong(), ponieważ realizujemy zadania w ciele pętli while — pozorne niepowodzenie sygnalizowane przez funkcję compare_exchange_weak() niepotrzebnie powodowałoby ponowne ustawianie wskaźnika ryzyka. W ten sposób możemy zagwarantować, że wskaźnik ryzyka zostanie prawidłowo ustawiony przed przetworzeniem wskaźnika old_head. Po oznaczeniu węzła jako naszego (przetwarzanego przez nasz wątek) możemy wyzerować wskaźnik ryzyka . Gdybyśmy uzyskali węzeł, musielibyśmy sprawdzić wskaźniki ryzyka należące do pozostałych wątków, aby upewnić się, czy nie odwołują się do tego węzła . Jeśli takie odwołania istnieją, nie możemy jeszcze usunąć tego węzła — powinniśmy raczej umieścić go na liście węzłów oczekujących na usunięcie . Jeśli odpowiednie wskaźniki ryzyka nie istnieją, możemy od razu usunąć ten wątek . Na koniec stosujemy wywołanie sprawdzające wszystkie węzły, dla których wcześniej wywołano funkcję reclaim_ ´later(). Jeśli te węzły nie są już wskazywane przez żadne wskaźniki ryzyka, możemy je bezpiecznie usunąć . Każdy węzeł, dla którego nadal istnieją wskaźniki ryzyka, pozostaje na tej liście przynajmniej do czasu najbliższego wywołania funkcji pop() przez jakiś wątek. Nowe funkcje — get_hazard_pointer_for_current_thread(), reclaim_later(), out ´standing_hazard_pointers_for() i delete_nodes_with_no_hazards() — oczywiście kryją w sobie mnóstwo szczegółowych rozwiązań, zatem warto zajrzeć za kurtynę i przyjrzeć się ich działaniu. Szczegółowy schemat alokowania instancji wskaźników ryzyka na potrzeby poszczególnych wątków przez funkcję get_hazard_pointer_for_current_thread() nie ma większego znaczenia dla logiki programu (choć oczywiście może mieć wpływ na jego efektywność, o czym za chwilę). Do tej pory posługiwaliśmy się dość prostą strukturą: tablicą stałej wielkości zawierającą pary identyfikator wątku – wskaźnik. Funkcja get_hazard_ ´pointer_for_current_thread() przeszukuje tę tablicę pod kątem pierwszego wolnego elementu i ustawia w odpowiednim polu identyfikator bieżącego wątku. W momencie zakończenia działania tego wątku odpowiedni element jest zwalniany poprzez umieszczenie w polu identyfikatora domyślnego wyniku wywołania std::thread::id(). Implementację tego rozwiązania pokazano na listingu 7.7. Listing 7.7. Prosta implementacja funkcji get_hazard_pointer_for_current_thread()

unsigned const max_hazard_pointers=100; struct hazard_pointer { std::atomic id; std::atomic pointer; }; hazard_pointer hazard_pointers[max_hazard_pointers]; class hp_owner { hazard_pointer* hp; public: hp_owner(hp_owner const&)=delete; hp_owner operator=(hp_owner const&)=delete;

7.2.

Przykłady struktur danych bez blokad

237

hp_owner(): hp(nullptr) { for(unsigned i=0;ipointer; } ~hp_owner() { hp->pointer.store(nullptr); hp->id.store(std::thread::id()); } }; std::atomic& get_hazard_pointer_for_current_thread() { Każdy wątek dysponuje thread_local static hp_owner hazard; return hazard.get_pointer(); własnym wskaźnikiem ryzyka }

Właściwa implementacja funkcji get_hazard_pointer_for_current_thread() tylko pozornie jest dość prosta ; funkcja operuje na zmiennej thread_local typu hp_owner , która zawiera wskaźnik ryzyka dla bieżącego wątku. Funkcja zwraca następnie wskaźnik zawarty w tym obiekcie . Opisany mechanizm działa w następujący sposób. Pierwsze wywołanie tej funkcji przez każdy wątek powoduje utworzenie nowej instancji typu hp_owner. Konstruktor tej nowej instancji przeszukuje następnie tablicę par właściciel – wskaźnik pod kątem ewentualnego wpisu bez właściciela. Do identyfikacji i pobrania wpisu bez właściciela w jednym kroku używana jest funkcja compare_exchange_ ´strong() . Jeśli wywołanie funkcji compare_exchange_strong() zakończy się niepowodzeniem, należy przyjąć, że dany element należy do innego wątku, i przejść do następnego elementu. Jeśli operacja wymiany zostanie wykonana pomyślnie, odpowiedni element został prawidłowo uzyskany przez bieżący wątek, zatem można ten element zapisać i przerwać wyszukiwanie . W razie osiągnięcia końca listy i nieznalezienia wolnego elementu należy zgłosić wyjątek sygnalizujący zbyt dużą liczbę wątków dysponujących wskaźnikami ryzyka.

238

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Po utworzeniu instancji typu hp_owner dla danego wątku dalsze próby dostępu przebiegają nieporównanie szybciej, ponieważ odpowiedni wskaźnik jest zapisywany w pamięci podręcznej, a tablica par właściciel – wskaźnik nie musi być ponownie przeszukiwana. Jeśli dla wątku utworzona została instancja typu hp_owner, w momencie kończenia działania tego wątku wspomniana instancja jest niszczona. Przed zapisaniem w polu identyfikatora wartości zwróconej przez wywołanie std::thread::id() destruktor przypisuje wskaźnikowi wartość nullptr, aby umożliwić innemu wątkowi ponowne użycie tego elementu . W przypadku opisanej implementacji funkcji get_hazard_pointer_for_current_ ´thread() implementacja funkcji outstanding_hazard_pointers_for() jest naprawdę prosta — jej działanie sprowadza się do przeszukania tablicy wskaźników ryzyka: bool outstanding_hazard_pointers_for(void* p) { for(unsigned i=0;inext=nodes_to_reclaim.load(); while(!nodes_to_reclaim.compare_exchange_weak(node->next,node)); } template void reclaim_later(T* data) { add_to_reclaim_list(new data_to_reclaim(data)); } void delete_nodes_with_no_hazards() { data_to_reclaim* current=nodes_to_reclaim.exchange(nullptr); while(current) { data_to_reclaim* const next=current->next; if(!outstanding_hazard_pointers_for(current->data)) { delete current; } else { add_to_reclaim_list(current); } current=next; } }

Jak łatwo zauważyć, reclaim_later() jest szablonem funkcji, nie zwykłą funkcją . Takie rozwiązanie jest uzasadnione, ponieważ wskaźniki ryzyka są uniwersalnym narzędziem, zatem nie ma sensu ograniczać tego rozwiązania tylko do obsługi węzłów stosu. Wcześniej do reprezentowania wskaźników używałem typu std::atomic. W tym przypadku musimy obsługiwać wszystkie typy wskaźników, ale nie możemy użyć typu void*, ponieważ chcemy usuwać elementy danych, a operacja usuwania wymaga wskaźnika do rzeczywistego typu. Okazuje się, że konstruktor struktury data_to_reclaim doskonale radzi sobie z tym zadaniem: funkcja reclaim_later() tworzy tylko nową instancję struktury data_to_reclaim na potrzeby danego wskaźnika i dodaje tę instancję do listy węzłów oczekujących na usunięcie . Działanie samej funkcji add_to_reclaim_list() ogranicza się do wywoływania w pętli funkcji compare_exchange_weak() dla pierwszego elementu listy (zgodnie ze schematem stosowanym już we wcześniejszych rozwiązaniach).

240

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Wróćmy na chwilę do konstruktora struktury data_to_reclaim : jak widać, także ten konstruktor ma postać szablonu. Konstruktor zapisuje dane do usunięcia jako wskaźnik typu void* w składowej data, po czym zapisuje wskaźnik do odpowiedniej instancji funkcji do_delete(), która rzutuje przekazany wskaźnik typu void* na wybrany typ wskaźnika, po czym usuwa wskazywany obiekt. Szablon klasy std::function jest bezpiecznym opakowaniem wskaźnika do funkcji, zatem destruktor struktury data_to_ ´reclaim może usunąć dane, stosując proste wywołanie zapisanej funkcji . Destruktor struktury data_to_reclaim nie jest wywoływany podczas dodawania węzłów do listy — jest wywoływany w momencie, w którym nie istnieją żadne wskaźniki ryzyka odwołujące się do tego węzła. Za wywoływanie tego destruktora odpowiada funkcja delete_nodes_with_no_hazards(). Funkcja delete_nodes_with_no_hazards() uzyskuje najpierw całą listę węzłów przeznaczonych do usunięcia za pomocą prostego wywołania funkcji exchange() . Ten prosty, ale bardzo ważny krok daje nam pewność, że bieżący wątek jest jedynym, który próbuje odzyskać ten konkretny zbiór węzłów. Pozostałe wątki mogą teraz swobodnie dodawać do tej listy nowe węzły lub nawet podejmować próby ich odzyskiwania bez najmniejszego wpływu na działanie bieżącego wątku. Dopóki ta lista zawiera jakieś węzły, kod sprawdza kolejne węzły pod kątem istnienia ewentualnych wskaźników ryzyka odwołujących się do tych węzłów . Jeśli takie wskaźniki nie istnieją, możemy bezpiecznie usunąć dany element listy i tym samym zniszczyć przechowywane dane . W przeciwnym razie należy ponownie dodać ten element do listy węzłów oczekujących na usunięcie w przyszłości . Mimo że ta prosta implementacja rzeczywiście realizuje zadanie bezpiecznego odzyskiwania usuniętych węzłów, zastosowane rozwiązania obciążają ten proces dodatkowymi kosztami. Przeszukiwanie tablicy wskaźników ryzyka wymaga sprawdzenia wartości zmiennej atomowej max_hazard_pointers. Operacja sprawdzania tej wartości jest wykonywana po każdym wywołaniu funkcji pop(). Operacje atomowe z natury rzeczy są dość wolne (często sto razy wolniejsze niż ich nieatomowe odpowiedniki wykonywane na tym samym procesorze komputera biurkowego), zatem funkcja pop() staje się dość czasochłonną operacją. Oprócz przeszukiwania listy wskaźników ryzyka pod kątem odwołań do węzła, który chcemy usunąć, musimy jeszcze wyszukać na tej liście wszystkie węzły oczekujące. Opisane rozwiązanie jest dalece nieefektywne. Lista może zawierać max_ha ´zard_pointers węzłów, z których każdy należy sprawdzić pod kątem wskazywania przez max_hazard_pointers wskaźników ryzyka. Niedobrze! Musi istnieć lepsze rozwiązanie. LEPSZE STRATEGIE ODZYSKIWANIA WĘZŁÓW PRZY UŻYCIU WSKAŹNIKÓW RYZYKA

Lepsze rozwiązanie oczywiście istnieje. Rozwiązanie opisane powyżej jest przykładem prostej, dość naiwnej implementacji wskaźników ryzyka, która ma na celu przede wszystkim prezentację tej techniki. Pierwszą zmianą, którą warto rozważyć, jest poświęcenie pewnej ilości pamięci na rzecz wyższej wydajności. Zamiast przy okazji każdego wywołania funkcji pop() sprawdzać wszystkie węzły na liście elementów przeznaczonych do odzyskania możemy w ogóle zrezygnować z odzyskiwania węzłów do momentu, w którym lista będzie zawierała więcej elementów, niż wynosi wartość max_hazard_pointers. Takie rozwiązanie gwarantuje, że zawsze będziemy mogli odzyskać przynajmniej jeden węzeł. Jeśli jednak będziemy czekali, aż lista będzie zawierała max_hazard_pointers+1 węzłów, proponowane rozwiązanie nie będzie dużo lepsze od oryginalnego modelu.

7.2.

Przykłady struktur danych bez blokad

241

Po przekroczeniu progu max_hazard_pointers program znowu będzie próbował odzyskiwać węzły przy okazji większości wywołań funkcji pop(), zatem wzrost wydajności będzie minimalny. Jeśli jednak wstrzymamy odzyskiwanie do momentu, w którym lista będzie zawierała 2*max_hazard_pointers węzłów, będziemy mieli pewność, że za każdym razem zostanie odzyskanych przynajmniej max_hazard_pointers węzłów, zatem przed każdą próbą odzyskania węzłów funkcja pop() zostanie wywołana przynajmniej max_hazard_pointers razy. To rozwiązanie jest już dużo lepsze. Zamiast sprawdzać wartość max_hazard_pointers węzłów dla każdego wywołania funkcji push() (co nie zawsze musi prowadzić do odzyskania choć jednego węzła), sprawdzamy teraz 2*max_hazard_pointers węzłów co max_hazard_pointers wywołań funkcji pop() i zawsze odzyskujemy przynajmniej max_hazard_pointers węzłów. Oznacza to, że dla każdego wywołania funkcji pop() statystycznie zostaną sprawdzone dwa węzły, z których jeden zostanie odzyskany. Nawet proponowane rozwiązanie ma pewną wadę (oprócz większego wykorzystania pamięci): skoro musimy teraz zliczać węzły na liście do odzyskania, powinniśmy zastosować licznik atomowy. Co więcej, dostęp do samej listy węzłów do odzyskania wciąż jest przedmiotem współzawodnictwa wielu wątków. Jeśli dysponujemy wolną pamięcią, możemy poświęcić dodatkowe zasoby na rzecz jeszcze wyższej wydajności schematu odzyskiwania węzłów — każdy wątek może dysponować własną listą węzłów do odzyskania w formie zmiennej lokalnej. W ten sposób można wyeliminować zmienne atomowe używane w roli licznika lub na potrzeby dostępu do listy. Zamiast tych zmiennych atomowych należałoby zaalokować max_hazard_pointers*max_hazard_pointers węzłów. Jeśli jakiś wątek zakończy działanie przed odzyskaniem wszystkich węzłów, można te węzły umieścić (tak jak wcześniej) na globalnej liście i dołączyć do lokalnej listy wątku, który jako następny podejmie próbę odzyskania węzłów. Inną poważną wadą wskaźników ryzyka jest ochrona prawna patentu zarejestrowanego przez firmę IBM2. Programista tworzący oprogramowanie, które będzie rozpowszechniane i stosowane w kraju obowiązywania odpowiednich przepisów patentowych, musi zadbać o wymagane umowy licencyjne. Problem dotyczy wielu technik odzyskiwania pamięci w oprogramowaniu bez blokad — ponieważ ten obszar jest przedmiotem wyjątkowo intensywnych badań, wielkie korporacje próbują opatentować jak najwięcej projektowanych rozwiązań. Część czytelników zapewne zastanawia się teraz, dlaczego poświęciłem tyle miejsca technice, która nie może być stosowana przez wielu programistów — dobre pytanie. Po pierwsze, w pewnych przypadkach można stosować tę technikę bez konieczności wykupywania licencji. Jeśli na przykład pracujesz nad darmowym oprogramowaniem udostępnianym na zasadach licencji GPL3, może się okazać, że tworzony program mieści się w zdefiniowanym przez firmę IBM zbiorze produktów, które nie wymagają zakupu licencji4. Po drugie (ten argument jest ważniejszy), powyższe wyjaśnienie tych technik dobrze ilustruje aspekty, na które należy zwracać uwagę podczas pisania kodu bez blokad (na przykład koszty operacji atomowych). 2

Maged M. Michael, U.S. Patent and Trademark Office application number 20040107227, „Method for efficient implementation of dynamic lock-free data structures with safe memory reclamation”.

3

Tekst licencji GNU General Public License można znaleźć na stronie http://www.gnu.org/licenses/gpl.html.

4

Odpowiedni dokument, zatytułowany IBM Statement of Non-Assertion of Named Patents Against OSS, jest dostępny na stronie http://www.ibm.com/ibm/licensing/patents/pledgedpatents.pdf.

242

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Czy wobec tego istnieją jakieś nieopatentowane techniki odzyskiwania pamięci, które można stosować w kodzie bez blokad? Na szczęście istnieją. Jednym z takich mechanizmów jest technika zliczania referencji. 7.2.4.

Wykrywanie używanych węzłów metodą zliczania referencji

W punkcie 7.2.2 wykazałem, że największym problemem związanym z usuwaniem węzłów jest identyfikacja danych, do których wciąż odwołują się węzły odczytujące. Gdybyśmy mogli bezpiecznie określać, które węzły są wskazywane przez referencje i kiedy żaden wątek nie uzyskuje dostępu do tych węzłów, moglibyśmy je usuwać w pełni bezpiecznie. Wskaźniki ryzyka rozwiązują ten problem poprzez przechowywanie listy aktualnie używanych węzłów. Zliczanie referencji rozwiązuje ten problem poprzez przechowywanie liczby wątków uzyskujących dostęp do poszczególnych węzłów. Na pierwszy rzut oka to rozwiązanie wydaje się dość proste, jednak w praktyce okazuje się kłopotliwe w implementacji i stosowaniu. Większość programistów początkowo zakłada, że można zrealizować to zadanie za pomocą typu std::shared_ptr, czyli rozwiązania zaprojektowanego właśnie z myślą o wskaźnikach do zliczanych referencji. Okazuje się jednak, że chociaż część operacji na typie std::shared_ptr ma postać operacji atomowych, nie mamy gwarancji, że odpowiednie działania będą wykonywane bez stosowania blokad. Mimo że wspomniany problem dotyczy w równym stopniu wszystkich innych operacji na typach atomowych, należy pamiętać, że typ std::sha ´red_ptr zaprojektowano z myślą o wielu różnych kontekstach i scenariuszach, zatem stosowanie operacji atomowych bez blokad powodowałoby niepotrzebne opóźnienia w pozostałych zastosowaniach tej klasy. Jeśli dana platforma obsługuje implementację, dla której wywołanie std::atomic_is_lock_free(&some_ shared_ptr) zwraca wartość true, problem odzyskiwania pamięci można rozwiązać w dziecinnie prosty sposób. Wystarczy użyć typu std::shared_ptr dla listy węzłów (patrz listing 7.9). Listing 7.9. Stos bez blokad zaimplementowany przy użyciu implementacji typu std::shared_ptr bez blokad

template class lock_free_stack { private: struct node { std::shared_ptr data; std::shared_ptr next; node(T const& data_): data(std::make_shared(data_)) {} }; std::shared_ptr head; public: void push(T const& data) { std::shared_ptr const new_node=std::make_shared(data); new_node->next=head.load();

7.2.

Przykłady struktur danych bez blokad

243

while(!std::atomic_compare_exchange_weak(&head, &new_node->next,new_node)); } std::shared_ptr pop() { std::shared_ptr old_head=std::atomic_load(&head); while(old_head && !std::atomic_compare_exchange_weak(&head, &old_head,old_head->next)); return old_head ? old_head->data : std::shared_ptr(); } };

Jeśli jednak stosowana implementacja typu std::shared_ptr używa blokad (co jest bardzo prawdopodobne), należy ręcznie zarządzać mechanizmem zliczania referencji. Jedna z możliwych technik polega na zastosowaniu nie jednego, a dwóch liczników referencji dla każdego węzła — licznika wewnętrznego i licznika zewnętrznego. Suma obu tych wartości reprezentuje łączną liczbę referencji do danego węzła. Licznik zewnętrzny jest utrzymywany obok wskaźnika do danego węzła i jest zwiększany przy okazji każdego odczytu tego wskaźnika. Licznik wewnętrzny jest zmniejszany w momencie zakończenia operacji na węźle przez wątek odczytujący. Oznacza to, że prosta operacja odczytu wskaźnika powoduje zwiększenie o jeden licznika zewnętrznego i zmniejszenie o jeden licznika wewnętrznego. W momencie, w którym para licznik zewnętrzny – wskaźnik nie jest już potrzebna (tj. w chwili, w której dany węzeł nie jest już dostępny dla wielu wątków), licznik wewnętrzny jest zwiększany o wartość licznika zewnętrznego pomniejszoną o jeden, a licznik zewnętrzny jest usuwany. W chwili, w której licznik wewnętrzny osiągnie wartość zero, można przyjąć, że nie istnieją żadne referencje do danego węzła i że można ten węzeł bezpiecznie usunąć. W opisanym modelu nadal bardzo ważne jest stosowanie operacji atomowych dla aktualizacji danych współdzielonych. Przeanalizujmy teraz implementację stosu bez blokad, w której użyto tej techniki do zagwarantowania, że węzły będą odzyskiwane tylko wtedy, gdy będzie to naprawdę bezpieczne. Na listingu 7.10 pokazano wewnętrzną strukturę danych i stosunkowo prostą implementację funkcji push(). Listing 7.10. Umieszczanie węzła na stosie bez blokad przy użyciu dwóch odrębnych liczników referencji

template class lock_free_stack { private: struct node; struct counted_node_ptr { int external_count; node* ptr; }; struct node { std::shared_ptr data;

244

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad std::atomic internal_count; counted_node_ptr next; node(T const& data_): data(std::make_shared(data_)), internal_count(0) {} }; std::atomic head; public: ~lock_free_stack() { while(pop()); } void push(T const& data) { counted_node_ptr new_node; new_node.ptr=new node(data); new_node.external_count=1; new_node.ptr->next=head.load(); while(!head.compare_exchange_weak(new_node.ptr->next,new_node)); } };

Łatwo zauważyć, że licznik zewnętrzny został opakowany wraz ze wskaźnikiem do węzła w ramach struktury counted_node_ptr . Oprócz licznika wewnętrznego struktura counted_node_ptr zawiera wskaźnik next .Ponieważ counted_node_ptr jest zwykłą strukturą, możemy jej używać w szablonie klas std::atomic w roli typu węzła head listy . Na platformach, które obsługują operację porównywania i wymiany podwójnego słowa, opisana struktura będzie na tyle mała, że klasa std::atomic będzie typem bez blokad. Jeśli jednak nasza platforma nie obsługuje tej operacji, być może lepszym rozwiązaniem będzie użycie wersji typu std::shared_ptr z listingu 7.9, ponieważ w przypadku zbyt dużego typu dla rozkazów atomowych szablon std::atomic będzie musiał używać muteksu do zagwarantowania atomowości (w takim przypadku nasz algorytm „bez blokad” ostatecznie musiałby zawierać blokady). Alternatywnym rozwiązaniem — jeśli możemy ograniczyć rozmiar licznika i jeśli wiemy, że na naszej platformie wskaźnik zawiera wolne bity (na przykład dlatego, że składa się z 64 bitów, podczas gdy przestrzeń adresowa jest tylko 48-bitowa) — jest zapisywanie licznika w nieużywanych bitach wskaźnika, tak aby obie wartości mieściły się w jednym słowie maszynowym. Takie zabiegi wymagają pewnej wiedzy na temat platformy, zatem ich prezentacja wykraczałaby poza zakres tematyczny tej książki. Funkcja push() jest stosunkowo prosta . W ciele tej funkcji konstruujemy nową instancję typu counted_node_ptr (która odwołuje się do zaalokowanego wcześniej węzła wraz z powiązanymi danymi) oraz przypisujemy polu next tego węzła dotychczasową wartość elementu head. Możemy teraz użyć funkcji compare_exchange_weak() do ustawienia nowej wartości elementu head (tak jak w kodzie z wcześniejszych listingów). Na tym etapie liczniki są ustawiane w taki sposób, aby zmienna internal_count zawierała

7.2.

Przykłady struktur danych bez blokad

245

wartość zero, a zmienna external_count zawierała wartość jeden. Ponieważ mamy do czynienia z zupełnie nowym węzłem, istnieje tylko jedna referencja zewnętrzna wskazująca ten węzeł (sam wskaźnik head). Jak zwykle problemy wychodzą na jaw dopiero podczas implementowania funkcji pop(), której kod pokazano na listingu 7.11. Listing 7.11. Zdejmowanie węzła ze stosu bez blokad przy użyciu dwóch liczników referencji

template class lock_free_stack { private: void increase_head_count(counted_node_ptr& old_counter) { counted_node_ptr new_counter; do { new_counter=old_counter; ++new_counter.external_count; } while(!head.compare_exchange_strong(old_counter,new_counter)); old_counter.external_count=new_counter.external_count; } public: std::shared_ptr pop()# { counted_node_ptr old_head=head.load(); for(;;) { increase_head_count(old_head); node* const ptr=old_head.ptr; if(!ptr) { return std::shared_ptr(); } if(head.compare_exchange_strong(old_head,ptr->next)) { std::shared_ptr res; res.swap(ptr->data); int const count_increase=old_head.external_count-2; if(ptr->internal_count.fetch_add(count_increase)== -count_increase) { delete ptr; } return res; } else if(ptr->internal_count.fetch_sub(1)==1) {

246

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad delete ptr; } } } };

Tym razem po załadowaniu wartości elementu head należy najpierw zwiększyć licznik zewnętrznych referencji do węzła head, aby zasygnalizować odwołanie bieżącego wątku do tego węzła i jednocześnie zagwarantować, że operowanie na tym wskaźniku będzie bezpieczne. W razie użycia wskaźnika przed zwiększeniem licznika referencji inny wątek mógłby zwolnić odpowiedni węzeł przed uzyskaniem dostępu do jego zawartości, co doprowadziłoby do powstania tzw. dyndającego wskaźnika. Właśnie dlatego stosujemy dwa odrębne liczniki referencji — zwiększenie zewnętrznego licznika referencji gwarantuje nam, że wskaźnik zachowuje prawidłowość przez cały czas operowania na danym węźle. Licznik jest zwiększany w pętli wywołującej funkcję compare_exchange_ ´strong() , która porównuje i ustawia całą strukturę; zwiększenie licznika wyklucza możliwość jednoczesnej zmiany wskaźnika przez inny wątek. Po zwiększeniu licznika możemy bezpiecznie użyć pola ptr należącego do załadowanego elementu head, aby uzyskać dostęp do wskazywanego węzła . Jeśli ten wskaźnik jest pusty, mamy do czynienia z końcem listy, zatem nie ma więcej elementów do przetworzenia. Jeśli ten wskaźnik nie jest pusty, możemy podjąć próbę usunięcia danego węzła za pomocą funkcji compare_exchange_strong() wywołanej dla elementu head . Jeśli operacja compare_exchange_strong() zakończy się pomyślnie, bieżący wątek przejął własność danego węzła i może wymienić dane przed jego zwróceniem . W ten sposób możemy zagwarantować, że dane nie będą utrzymywane przy życiu tylko dlatego, że pozostałe wątki uzyskujące dostęp do stosu wciąż dysponują wskaźnikami do tego węzła. Możemy teraz dodać licznik zewnętrzny do licznika wewnętrznego dla danego węzła za pomocą operacji atomowej fetch_add . Jeśli łączny licznik referencji ma wartość zero, tj. jeśli poprzednia wartość licznika wewnętrznego (zwrócona przez funkcję fetch_add) była liczbą przeciwną do wartości licznika zewnętrznego, możemy bezpiecznie usunąć dany węzeł. Warto pamiętać, że dodawana wartość w rzeczywistości jest mniejsza o dwa od licznika zewnętrznego ; skoro usunęliśmy węzeł z listy, wartość jednego z liczników została zmniejszona, a ponieważ bieżący wątek nie uzyskuje już dostępu do tego węzła, wartość licznika należy dodatkowo zmniejszyć. Niezależnie od tego, czy węzeł został usunięty, na tym należy zakończyć działanie funkcji pop(), która może teraz zwrócić dane . zakończy się niepowodzeniem, będzie to Jeśli operacja porównania i wymiany oznaczało, że inny wątek albo usunął dany węzeł przed nami, albo umieścił nowy węzeł na stosie. W obu przypadkach należy rozpocząć operację od początku z odświeżoną wartością elementu head, która została zwrócona przez wywołanie operacji porównania i wymiany. Najpierw jednak należy zmniejszyć licznik referencji dla węzła, który próbowaliśmy usunąć. Bieżący wątek już nigdy nie będzie próbował uzyskiwać dostępu do tego węzła. Jeśli bieżący wątek jest ostatnim, który dysponuje taką referencją (ponieważ inny wątek usunął dany węzeł ze stosu), wewnętrzny licznik referencji będzie wynosił 1, zatem zmniejszenie tej wartości doprowadzi do wyzerowania licznika. W takim przypadku możemy usunąć dany węzeł jeszcze przed opuszczeniem pętli .

7.2.

Przykłady struktur danych bez blokad

247

Do tej pory stosowaliśmy dla wszystkich operacji atomowych domyślny tryb porządkowania pamięci std::memory_order_seq_cst. W większości systemów operacje atomowe wykonywane w tym trybie są bardziej kosztowne (zajmują więcej czasu i powodują dodatkowe obciążenie w związku z koniecznością synchronizacji) niż alternatywne modele; w niektórych przypadkach różnica w wydajności jest dość duża. Skoro dysponujemy już gotową logiką operacji na strukturze danych, możemy rozważyć użycie mniej restrykcyjnego modelu porządkowania pamięci. Nie chcemy, aby korzystanie ze struktury stosu wiązało się z niepotrzebnymi kosztami i opóźnieniami. Zanim zostawimy temat stosu na rzecz projektowania struktury kolejki bez stosu, warto jeszcze raz przeanalizować poszczególne operacje i sprawdzić, czy przynajmniej dla niektórych z nich nie można zastosować łagodniejszych trybów porządkowania pamięci i jednocześnie zachować obecne bezpieczeństwo? 7.2.5.

Zmiana modelu pamięci używanego przez operacje na stosie bez blokad

Zanim przystąpimy do zmiany trybu porządkowania pamięci, musimy dokładnie przeanalizować poszczególne operacje i zidentyfikować niezbędne relacje pomiędzy nimi. Możemy następnie wrócić do projektu tej struktury i zastosować możliwie najmniej restrykcyjne tryby porządkowania pamięci, które wystarczą do wymuszania niezbędnych relacji. W tym celu musimy przeanalizować wiele różnych scenariuszy działania programu z perspektywy samych wątków. Najprostszy możliwy scenariusz ma miejsce wtedy, gdy jeden wątek umieszcza dane na stosie po to, aby jakiś czas później inny wątek zdjął te dane ze stosu. W tym prostym przypadku musimy mieć na uwadze trzy ważne elementy danych. Pierwszym z nich jest struktura counted_node_ptr używana do przekazywania danych, czyli head. Drugim elementem jest struktura node, do której odwołuje się wskaźnik head; trzecim elementem są właściwe dane wskazywane przez ten węzeł. Wątek wykonujący funkcję push() konstruuje najpierw element danych i instancję typu node, po czym ustawia wartość head. Wątek wykonujący funkcję pop() najpierw ładuje wartość elementu head, rozpoczyna pętlę z operacją porównania i wymiany na tym elemencie head (aby zwiększyć licznik referencji), a następnie odczytuje strukturę node w celu uzyskania wartości next. Łatwo w tym scenariuszu wskazać niezbędną relację — wartość next ma postać zwykłego obiektu nieatomowego, zatem warunkiem jej bezpiecznego odczytania jest istnienie relacji poprzedzania pomiędzy operacjami zapisu (wątkiem umieszczającym element na stosie) i odczytu (wątkiem zdejmującym element ze stosu). Ponieważ jedyną operacją atomową w ramach funkcji push() jest wywołanie funkcji compare_exchange_weak() i ponieważ operacja zwalniania musi być powiązana z wątkami relacją poprzedzania, funkcja compare_exchange_weak() musi stosować tryb nie mniej restrykcyjny niż std::memory_order_release. W razie niepowodzenia wywołania compare_exchange_weak() dane nie ulegają zmianie, a wątek może dalej działać w pętli, zatem w tym przypadku wystarczy model porządkowania danych std::memory_order_relaxed: void push(T const& data) { counted_node_ptr new_node; new_node.ptr=new node(data);

248

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad new_node.external_count=1; new_node.ptr->next=head.load(std::memory_order_relaxed) while(!head.compare_exchange_weak(new_node.ptr->next,new_node, std::memory_order_release,std::memory_order_relaxed)); }

Co należy zrobić z kodem funkcji pop()? Aby wymusić stosowanie niezbędnej relacji poprzedzania, przed dostępem do wskaźnika next należy wykonać operację w trybie co najmniej tak restrykcyjnym jak w przypadku opcji std::memory_order_acquire. Wskaźnik, którego używamy podczas uzyskiwania dostępu do pola next, zawiera starą wartość odczytaną przez funkcję compare_exchange_strong() w ciele funkcji increase_ ´head_count(), zatem w razie pomyślnego zakończenia tej operacji musimy zastosować odpowiedni tryb porządkowania pamięci. Tak jak w przypadku wywołania w ramach funkcji push(), jeśli wymiana zakończy się niepowodzeniem, pętla jest wykonywana ponownie, zatem w razie niepowodzenia można zastosować złagodzony tryb porządkowania: void increase_head_count(counted_node_ptr& old_counter) { counted_node_ptr new_counter; do { new_counter=old_counter; ++new_counter.external_count; } while(!head.compare_exchange_strong(old_counter,new_counter, std::memory_order_acquire,std::memory_order_relaxed)); old_counter.external_count=new_counter.external_count; }

Jeśli wywołanie funkcji compare_exchange_strong() zakończy się pomyślnie, możemy przyjąć, że odczytana wartość zawierała w polu ptr wartość, która teraz znajduje się w zmiennej old_counter. Ponieważ zapis w ciele funkcji push() był operacją zwalniania i ponieważ wywołanie funkcji compare_exchange_strong() jest operacją uzyskania, operacja zapisu jest synchronizowana z operacją ładowania, zatem oba działania są związane relacją poprzedzania. Oznacza to, że operacja zapisu w polu ptr w ciele funkcji push() poprzedza dostęp do wskaźnika ptr->next w ciele funkcji pop(), zatem opisane rozwiązanie jest bezpieczne. Łatwo zauważyć, że tryb porządkowania pamięci stosowany dla początkowego wywołania funkcji head.load() w ogóle nie został uwzględniony w tej analizie, zatem dla tej operacji możemy bezpiecznie stosować opcję std::memory_order_relaxed. Przejdźmy teraz do funkcji compare_exchange_strong() i operacji przypisywania wartości head elementowi old_head.ptr->next. Czy zagwarantowanie integralności danych w wątku wykonującym tę operację wymaga zastosowania dodatkowych zabezpieczeń? Jeśli operacja wymiany zostanie wykonana pomyślnie, wątek uzyska dostęp do pola ptr->data, zatem musimy zadbać o to, aby operacja zapisu danych w polu ptr->data (w wątku wykonującym funkcję push()) poprzedzała operację ładowania. Okazuje się jednak, że dysponujemy już odpowiednimi gwarancjami — operacja uzyskiwania w ramach funkcji increase_head_count() gwarantuje nam, że pomiędzy operacją zapisu w wątku wykonującym funkcję push() a operacją porównania i wymiany występuje relacja

7.2.

Przykłady struktur danych bez blokad

249

synchronizacji. Ponieważ operacja zapisu danych w wątku wykonującym funkcję push() poprzedza w sekwencji operację zapisu elementu head, a wywołanie funkcji increase_ ´head_count() poprzedza w sekwencji operację ładowania pola ptr->data, mamy do czynienia z relacją poprzedzania, zatem opisane rozwiązanie będzie działało prawidłowo nawet w razie zastosowania trybu std::memory_order_relaxed dla operacji porównania i wymiany w funkcji pop(). Jedynym innym miejscem, w którym pole ptr->data jest zmieniane, jest właśnie analizowane wywołanie funkcji swap() — żaden inny wątek nie może operować na tym samym węźle (dzięki zastosowaniu operacji porównania i wymiany). Jeśli wywołanie funkcji compare_exchange_strong() zakończy się niepowodzeniem, nowa wartość zmiennej old_head nie zostanie zmieniona do następnej iteracji pętli, a ponieważ już wcześniej uznaliśmy, że tryb std::memory_order_acquire stosowany dla operacji increase_head_count() jest wystarczająco restrykcyjny, także tryb std::memory_ ´order_relaxed powinien wystarczyć. Co z pozostałymi wątkami? Czy zapewnienie bezpieczeństwa pozostałych wątków wymaga zastosowania bardziej restrykcyjnych mechanizmów? Okazuje się, że nie, ponieważ element head jest modyfikowany wyłącznie przez operację porównania i wymiany. Operacja porównania i wymiany jest w istocie operacją odczytu-modyfikacji-zapisu i jako taka występuje w roli operacji zwalniania w sekwencji działań wykonywanych w ciele funkcji push(). Oznacza to, że wywołanie funkcji compare_exchange_weak() w kodzie funkcji push() jest synchronizowane z wywołaniem funkcji compare_exchan ´ge_strong() w ciele funkcji increase_head_count(), które odczytuje wartość zapisaną wcześniej w tej sekwencji, nawet jeśli pozostałe wątki w międzyczasie zmodyfikowały element head. Nasza analiza powoli dobiega końca — pozostały nam już tylko operacje fetch_add() i kwestia modyfikowania licznika referencji. Wątek, który uzyskał dane zawarte w żądanym węźle, może bezpiecznie kontynuować działanie, ponieważ żaden inny wątek nie mógł zmodyfikować zawartości tego węzła. Okazuje się jednak, że każdy wątek, któremu nie udało się uzyskać danych węzła, „wie” o modyfikacji tych danych wprowadzonych przez inny wątek (do uzyskania wskazywanego elementu danych służy funkcja swap()). Oznacza to, że musimy zagwarantować poprzedzanie operacji usuwania przez wywołanie funkcji swap(), aby uniknąć niebezpiecznego wyścigu danych. Bodaj najprostszym sposobem realizacji tego zadania jest wywoływanie funkcji fetch_add(), albo w trybie std::memory_order_release (w przypadku pomyślnego wykonania operacji), albo w trybie std::memory_order_acquire (w razie konieczności ponownego wykonania pętli). Okazuje się, że nawet takie zabezpieczenia są niepotrzebne — tylko jeden wątek wykonuje operację usuwania (wątek, który zeruje licznik), zatem tylko ten wątek musi wykonać operację uzyskiwania. Funkcja fetch_add() realizuje operację odczytu-modyfikacji-zapisu, zatem jest częścią sekwencji zwalniania. Oznacza to, że wystarczy użyć dodatkowego wywołania funkcji load(). Jeśli w odgałęzieniu dla ponownego wykonywania pętli licznik referencji zostanie zmniejszony do zera, licznik można ponownie załadować w trybie std::memory_order_acquire (aby wymusić stosowanie relacji synchronizacji), natomiast sama funkcja fetch_add() może być wykonywana w trybie std::memory_ ´order_relaxed. Ostateczną wersję implementacji stosu z nową funkcją pop() pokazano na listingu 7.12.

250

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad Listing 7.12. Stos bez blokad zaimplementowany przy użyciu mechanizmu zliczania referencji i złagodzonych operacji atomowych

template class lock_free_stack { private: struct node; struct counted_node_ptr { int external_count; node* ptr; }; struct node { std::shared_ptr data; std::atomic internal_count; counted_node_ptr next; node(T const& data_): data(std::make_shared(data_)), internal_count(0) {} }; std::atomic head; void increase_head_count(counted_node_ptr& old_counter) { counted_node_ptr new_counter; do { new_counter=old_counter; ++new_counter.external_count; } while(!head.compare_exchange_strong(old_counter,new_counter, std::memory_order_acquire, std::memory_order_relaxed)); old_counter.external_count=new_counter.external_count; } public: ~lock_free_stack() { while(pop()); } void push(T const& data) { counted_node_ptr new_node; new_node.ptr=new node(data); new_node.external_count=1; new_node.ptr->next=head.load(std::memory_order_relaxed)

7.2.

Przykłady struktur danych bez blokad

251

while(!head.compare_exchange_weak(new_node.ptr->next,new_node, std::memory_order_release, std::memory_order_relaxed));

} std::shared_ptr pop() { counted_node_ptr old_head= head.load(std::memory_order_relaxed); for(;;) { increase_head_count(old_head); node* const ptr=old_head.ptr; if(!ptr) { return std::shared_ptr(); } if(head.compare_exchange_strong(old_head,ptr->next, std::memory_order_relaxed)) { std::shared_ptr res; res.swap(ptr->data); int const count_increase=old_head.external_count-2; if(ptr->internal_count.fetch_add(count_increase, std::memory_order_release)==-count_increase) { delete ptr; } return res; } else if(ptr->internal_count.fetch_add(-1, std::memory_order_relaxed)==1) { ptr->internal_count.load(std::memory_order_acquire); delete ptr; } } } };

Zadanie nie było łatwe, ale ostatecznie dopięliśmy swego i uzyskaliśmy lepszą, bardziej efektywną strukturę stosu. Stosowanie przemyślanego, złagodzonego modelu porządkowania pamięci pozwala podnieść wydajność bez negatywnego wpływu na poprawność działania systemu. Jak widać, implementacja funkcji pop() obejmuje teraz 37 wierszy zamiast 8 wierszy równoważnej implementacji tej funkcji w stosie na bazie blokad (patrz listing 6.1) i 7 wierszy kodu podstawowej implementacji bez zarządzania pamięcią (patrz listing 7.2). Podobny wzorzec zastosujemy podczas implementacji struktury kolejki bez blokad — także w tym przypadku zarządzanie pamięcią będzie się wiązało ze znacznym wzrostem złożoności kodu.

252 7.2.6.

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Implementacja kolejki gwarantującej bezpieczeństwo przetwarzania wielowątkowego bez blokad

Struktura kolejki stawia przed nami nieco inne wyzwania niż stos, ponieważ operacje push() i pop() uzyskują dostęp do różnych elementów tej struktury (analogiczne operacje na stosie uzyskują dostęp do tego samego węzła, czyli głowy stosu). W tej sytuacji należy stosować odmienne mechanizmy synchronizacji. Musimy zagwarantować, że zmiany wprowadzane na jednym końcu tej struktury będą widoczne na drugim końcu. Okazuje się jednak, że struktura funkcji try_pop() dla kolejki z listingu 6.6 nie różni się zbytnio od funkcji pop() dla prostego stosu bez blokad z listingu 7.2, zatem możemy przyjąć, że także kod kolejki bez blokad będzie dość podobny. Sprawdźmy, jak to zrobić. Jeśli w roli punktu wyjścia użyjemy kodu z listingu 6.6, będziemy potrzebowali dwóch wskaźników do węzłów: jednego dla elementu head i jednego dla elementu tail. Dostęp do tych wskaźników będzie uzyskiwało wiele wątków, zatem te wskaźniki powinny być atomowe, tak aby można było wyeliminować konieczność stosowania muteksów. Zacznijmy od wprowadzenia drobnej zmiany i sprawdźmy, jaki będzie efekt tych modyfikacji. Wynik tych zmian pokazano na listingu 7.13. Listing 7.13. Kolejka bez blokady z pojedynczym producentem i pojedynczym konsumentem

template class lock_free_queue { private: struct node { std::shared_ptr data; node* next; node(): next(nullptr) {} }; std::atomic head; std::atomic tail; node* pop_head() { node* const old_head=head.load(); if(old_head==tail.load()) { return nullptr; } head.store(old_head->next); return old_head; } public: lock_free_queue(): head(new node),tail(head.load()) {}

7.2.

Przykłady struktur danych bez blokad

253

lock_free_queue(const lock_free_queue& other)=delete; lock_free_queue& operator=(const lock_free_queue& other)=delete; ~lock_free_queue() { while(node* const old_head=head.load()) { head.store(old_head->next); delete old_head; } } std::shared_ptr pop() { node* old_head=pop_head(); if(!old_head) { return std::shared_ptr(); } std::shared_ptr const res(old_head->data); delete old_head; return res; }

};

void push(T new_value) { std::shared_ptr new_data(std::make_shared(new_value)); node* p=new node; node* const old_tail=tail.load(); old_tail->data.swap(new_data); old_tail->next=p; tail.store(p); }

Na pierwszy rzut oka proponowane rozwiązanie wydaje się przemyślane — jeśli tylko jeden wątek jednocześnie wywołuje funkcję push() i jeśli tylko jeden wątek jednocześnie wywołuje funkcję pop(), program działa w pełni poprawnie. W tym przypadku bardzo ważna jest relacja poprzedzania łącząca funkcje push() i pop(), która pozwala zagwarantować bezpieczeństwo operacji pobierania danych. Operacja zapisu elementu tail jest synchronizowana z operacją ładowania elementu tail , operacja zapisu wskaźnika data w poprzednim węźle występuje w sekwencji przed operacją zapisu elementu tail, a operacja ładowania elementu tail występuje w sekwencji przed operacją ładowania wskaźnika data , zatem operacja zapisu wskaźnika data poprzedza operację ładowania — oznacza to, że działanie tego kodu jest w pełni prawidłowe. Mamy więc do czynienia z prawidłową kolejką z jednym producentem i jednym konsumentem (ang. single-producer, single-consumer — SPSC). Problemy pojawiają się dopiero w momencie, w którym wiele wątków jednocześnie wywołuje funkcję push() lub wiele wątków współbieżnie wywołuje funkcję pop(). Przeanalizujmy najpierw kwestię współbieżnych wywołań funkcji push(). Jeśli dwa wątki jednocześnie wywołują funkcję push(), oba alokują nowe węzły jako sztuczny węzeł ,

254

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

oba odczytują tę samą wartość elementu tail i tym samym oba aktualizują składowe . Mamy więc do czytego samego węzła podczas ustawiania wskaźników data i next nienia z wyścigiem danych! Podobne problemy dotyczą funkcji pop_head(). Jeśli dwa wątki współbieżnie wywołają tę funkcję, oba odczytają tę samą wartość elementu head i oba nadpiszą starą wartość tym samym wskaźnikiem next. Każdy z tych wątków będzie następnie zakładał, że jako jedyny pobrał z kolejki dany węzeł — trudno sobie wyobrazić prostszy przepis na katastrofę. Musimy nie tylko zagwarantować, że tylko jeden wątek wykonujący funkcję pop() uzyska określony element kolejki, ale też zapewnić, że pozostałe wątki będą mogły bezpiecznie uzyskiwać dostęp do składowej next węzła odczytywanego z elementu head. Mamy więc do czynienia z tym samym problemem, na który zwróciłem uwagę przy okazji omawiania funkcji pop() stosu bez blokad — oznacza to, że możemy zastosować jedno z zaproponowanych wówczas rozwiązań. Skoro kwestię funkcji pop() można uznać za rozwiązaną, co powinniśmy zrobić z funkcją push()? Okazuje się, że aby związać wywołania funkcji push() i pop() niezbędną relacją poprzedzania, musimy ustawić elementy danych w sztucznym węźle przed zaktualizowaniem elementu tail. Oznacza to, że współbieżne wywołania funkcji push() konkurują o dostęp do tych samych elementów danych, ponieważ odczytały ten sam wskaźnik tail. OBSŁUGA WIELU WĄTKÓW W FUNKCJI PUSH()

Jednym z możliwych rozwiązań jest dodanie sztucznego węzła pomiędzy właściwymi węzłami. W takim przypadku jedyną częścią bieżącego węzła tail, która wymaga aktualizacji, jest wskaźnik next, który można dość łatwo przekształcić w zmienną atomową. Jeśli jakiemuś wątkowi uda się zmienić wskaźnik next tak, aby wskazywał nowy węzeł (zamiast zawierać wartość nullptr), przyjmujemy, że ten wątek pomyślnie dodał wskaźnik; w przeciwnym razie wątek musi ponownie rozpocząć całą operację i ponownie odczytać wskaźnik tail. Implementacja tego rozwiązania wymaga wprowadzenia drobnej zmiany w kodzie funkcji pop(), aby węzły z pustym wskaźnikiem do danych były odrzucane i powodowały ponowne wykonywanie pętli. Do najważniejszych wad tego rozwiązania należy konieczność usuwania dwóch węzłów w niemal każdym wywołaniu funkcji pop() i dwukrotnie większa liczba operacji alokowania pamięci. Drugim możliwym rozwiązaniem jest zastosowanie atomowego wskaźnika data i ustawianie jego wartości za pomocą wywołania operacji porównania i wymiany. Jeśli to wywołanie zakończy się pomyślnie, nowy węzeł będzie pełnił funkcję ogona, a bieżący wątek będzie mógł bezpiecznie ustawić wskaźnik next, aby wskazywał nowy węzeł, i zaktualizować element tail. Jeśli operacja porównania i wymiany zakończy się niepowodzeniem z powodu operacji zapisu wykonanej przez inny wątek, należy ponownie wykonać pętlę, odczytać element tail i rozpocząć tę operację od początku. Gdybyśmy jednak zastosowali operacje atomowe bez blokad na typie std::shared_ptr, moglibyśmy zapomnieć o opisanym problemie. Jeśli użycie tego modelu jest niemożliwe, musimy znaleźć inne rozwiązanie. Jedną z możliwości jest zwracanie wartości typu std::unique_ptr przez funkcję pop() (taka wartość jest przecież jedyną referencją do danego obiektu) i przechowywanie danych w kolejce w formie zwykłych wskaźników. Dzięki temu będziemy mogli zapisywać dane w formie instancji typu std::atomic, zatem będzie możliwa obsługa niezbędnego wywołania funkcji compare_exchange_strong().

7.2.

Przykłady struktur danych bez blokad

255

W przypadku użycia schematu zliczania referencji z listingu 7.11 do obsługi wielu wątków w funkcji pop(), implementacja push() może mieć postać podobną do tej z listingu 7.14. Listing 7.14. Pierwsza (nieudana) próba poprawienia funkcji push()

void push(T new_value) { std::unique_ptr new_data(new T(new_value)); counted_node_ptr new_next; new_next.ptr=new node; new_next.external_count=1; for(;;) { node* const old_tail=tail.load(); T* old_data=nullptr; if(old_tail->data.compare_exchange_strong( old_data,new_data.get())) { old_tail->next=new_next; tail.store(new_next.ptr); new_data.release(); break; } } }

Użycie schematu zliczania referencji co prawda eliminuje wspomniany wyścig danych, jednak nie jest to jedyny wyścig występujący w funkcji push(). Wystarczy bliżej przeanalizować zmienioną wersję funkcji push() z listingu 7.14, aby odkryć ten sam wzorzec, z którym mieliśmy do czynienia w przypadku stosu — ładowanie wskaźnika atomowego i późniejsze używanie tego samego wskaźnika . W międzyczasie inny wątek może zaktualizować ten wskaźnik , co ostatecznie może doprowadzić do usunięcia danego węzła z pamięci (w ciele funkcji pop()). Jeśli węzeł zostanie usunięty z pamięci przed użyciem tego wskaźnika, program będzie narażony na niezdefiniowane zachowanie. Niedobrze! Wielu programistów ulega pokusie dodania licznika zewnętrznego do węzła tail (tak jak w przypadku węzła head), jednak każdy węzeł kolejki zawiera już licznik zewnętrzny w ramach wskaźnika next należącego do poprzedniego węzła. Utrzymywanie dwóch liczników zewnętrznych dla jednego węzła wymagałoby zmodyfikowania całego schematu zliczania referencji, aby uniknąć przedwczesnego usuwania węzła. Problem można równie dobrze rozwiązać, zliczając liczniki zewnętrzne w ramach struktury węzła i zmniejszając ich liczbę w momencie niszczenia każdego licznika zewnętrznego (i dodając wartość odpowiedniego licznika zewnętrznego do licznika wewnętrznego). Jeśli licznik wewnętrzny ma wartość zero i jeśli nie istnieją żadne liczniki zewnętrzne, możemy bezpiecznie usunąć dany węzeł. Z opisaną techniką po raz pierwszy zetknąłem się w projekcie Atomic Ptr Plus Project prowadzonym przez Joego Seigha5. Kod funkcji push() po wprowadzeniu tego schematu pokazano na listingu 7.15. 5

Atomic Ptr Plus Project, http://atomic-ptr-plus.sourceforge.net/.

256

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad Listing 7.15. Implementacja funkcji push() kolejki bez blokad z wykorzystaniem mechanizmu zliczania referencji

template class lock_free_queue { private: struct node; struct counted_node_ptr { int external_count; node* ptr; }; std::atomic head; std::atomic tail; struct node_counter { unsigned internal_count:30; unsigned external_counters:2; }; struct node { std::atomic data; std::atomic count; counted_node_ptr next; node() { node_counter new_count; new_count.internal_count=0; new_count.external_counters=2; count.store(new_count); next.ptr=nullptr; next.external_count=0; } }; public: void push(T new_value) { std::unique_ptr new_data(new T(new_value)); counted_node_ptr new_next; new_next.ptr=new node; new_next.external_count=1; counted_node_ptr old_tail=tail.load(); for(;;) { increase_external_count(tail,old_tail); T* old_data=nullptr; if(old_tail.ptr->data.compare_exchange_strong(

7.2.

Przykłady struktur danych bez blokad

257

old_data,new_data.get())) { old_tail.ptr->next=new_next; old_tail=tail.exchange(new_next); free_external_counter(old_tail); new_data.release(); break; } old_tail.ptr->release_ref(); } } };

Na listingu 7.15 element tail ma postać instancji typu atomic (tak samo jak element head) , a struktura node obejmuje składową count zamiast stosowanej wcześniej składowej internal_count . Sama składowa count jest strukturą zawierającą składowe internal_count i external_counters . Łatwo zauważyć, że składowa external_counters wymaga tylko dwóch bitów, ponieważ mogą istnieć tylko dwa liczniki zewnętrzne. Zastosowanie dodatkowego pola bitów dla obu składowych (składową internal_count zadeklarowano jako wartość 30-bitową) pozwala ograniczyć łączny rozmiar licznika do 32 bitów. W ten sposób stwarzamy mnóstwo przestrzeni dla wartości licznika wewnętrznego i jednocześnie ograniczamy rozmiar całej struktury, tak aby mieściła się w jednym słowie maszynowym na komputerach 32- i 64-bitowych. Aby uniknąć sytuacji wyścigu, oba liczniki należy aktualizować w jednej niepodzielnej operacji (więcej informacji na ten temat można znaleźć w dalszej części tego punktu). Użycie struktury, która mieści się w jednym słowie maszynowym, zwiększa na wielu platformach szanse wykonywania operacji atomowych bez stosowania blokad. Element node jest inicjalizowany z wartością zero w polu internal_count i z wartością 2 w polu external_counters , ponieważ każdy nowy węzeł (po pomyślnym dodaniu do kolejki) jest wskazywany przez dwie referencje (w węźle tail i we wskaźniku next poprzedniego węzła). Sama funkcja push() przypomina rozwiązanie z listingu 7.14, z tą różnicą, że przed użyciem wartości załadowanej z węzła tail do wywołania funkcji compare_exchange_strong() dla składowej data danego węzła wywołujemy kolejno nową funkcję increase_external_count(), aby zwiększyć licznik , i funkcję free_ex ´ternal_counter() dla starej wartości elementu tail . Skoro dysponujemy już gotową funkcją push(), przejdźmy do omówienia funkcji pop(). Na listingu 7.16 pokazano kod funkcji pop(), w którym połączono logikę zliczania referencji z listingu 7.11 z logiką pobierania elementów z kolejki z listingu 7.13. Listing 7.16. Zdejmowanie węzła z kolejki bez blokad przy użyciu licznika referencji w węźle tail

template class lock_free_queue { private: struct node { void release_ref(); }; public:

258

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad std::unique_ptr pop() { counted_node_ptr old_head=head.load(std::memory_order_relaxed); for(;;) { increase_external_count(head,old_head); node* const ptr=old_head.ptr; if(ptr==tail.load().ptr) { ptr->release_ref(); return std::unique_ptr(); } if(head.compare_exchange_strong(old_head,ptr->next)) { T* const res=ptr->data.exchange(nullptr); free_external_counter(old_head); return std::unique_ptr(res); } ptr->release_ref(); } } };

Udało nam się osiągnąć cel, ładując wartość old_head przed rozpoczęciem wykonywania pętli i przed zwiększeniem wartości licznika zewnętrznego dla załadowanej wartości . Jeśli węzeł head jest taki sam jak węzeł tail, możemy zwolnić daną referencję i zwrócić wskaźnik pusty, ponieważ kolejka nie zawiera żadnych danych. Jeśli kolejka zawiera jakieś dane, należy podjąć próbę uzyskania tych danych — w tym celu wywołujemy funkcję compare_exchange_strong() . Tak jak w przypadku stosu z listingu 7.11, wspomniana operacja w jednym kroku porównuje licznik zewnętrzny i wskaźnik; jeśli któraś z tych wartości uległa zmianie, należy zwolnić referencję i ponownie wykonać pętlę. Jeśli próba wymiany zakończy się pomyślnie, możemy przyjąć, że bieżący wątek uzyskał dane zawarte w węźle, i zwrócić te dane do kodu wywołującego (po zwolnieniu licznika zewnętrznego dla węzła pobranego z kolejki ). Po zwolnieniu obu liczników zewnętrznych i spadku wartości licznika wewnętrznego do zera można bezpiecznie usunąć sam węzeł. Kod funkcji zliczania referencji, które odpowiadają za realizację tych zadań, pokazano na listingach 7.17, 7.18 i 7.19. Listing 7.17. Zwalnianie referencji do węzła w ramach kolejki bez blokad

template class lock_free_queue { private: struct node { void release_ref() { node_counter old_counter= count.load(std::memory_order_relaxed); node_counter new_counter; do {

7.2.

Przykłady struktur danych bez blokad

259

new_counter=old_counter; --new_counter.internal_count; } while(!count.compare_exchange_strong( old_counter,new_counter, std::memory_order_acquire,std::memory_order_relaxed)); if(!new_counter.internal_count && !new_counter.external_counters) { delete this; } } }; };

Implementacja funkcji node::release_ref() została tylko nieznacznie zmieniona w porównaniu z analogiczną implementacją funkcji lock_free_stack::pop() z listingu 7.11. O ile kod z listingu 7.11 musiał obsługiwać tylko jeden licznik zewnętrzny i jako taki mógł używać prostej funkcji fetch_sub, o tyle tym razem cała struktura licznika musi być aktualizowana przy użyciu operacji atomowej, nawet jeśli przedmiotem modyfikacji jest tylko pole internal_count . Realizacja tego celu wymaga zastosowania pętli z operacją porównania i wymiany . Jeśli po zmniejszeniu licznika internal_count oba liczniki mają wartość zero, mamy do czynienia z ostatnią referencją, zatem możemy usunąć dany węzeł . Listing 7.18. Uzyskiwanie nowej referencji do węzła w ramach kolejki bez blokad

template class lock_free_queue { private: static void increase_external_count( std::atomic& counter, counted_node_ptr& old_counter) { counted_node_ptr new_counter; do { new_counter=old_counter; ++new_counter.external_count; } while(!counter.compare_exchange_strong( old_counter,new_counter, std::memory_order_acquire,std::memory_order_relaxed)); old_counter.external_count=new_counter.external_count; } };

Kod z listingu 7.18 implementuje alternatywną ścieżkę. Tym razem, zamiast zwalniać referencję, uzyskujemy nową referencję i zwiększamy licznik zewnętrzny. Funkcja incre ´ase_external_count() pod wieloma względami przypomina funkcję increase_head_

260

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

´count() z listingu 7.12, z tą różnicą, że ma postać statycznej funkcji składowej, która zamiast operować na stałym liczniku, otrzymuje licznik zewnętrzny do zaktualizowania na wejściu (za pośrednictwem pierwszego parametru). Listing 7.19. Zwalnianie licznika zewnętrznego węzła kolejki bez blokad

template class lock_free_queue { private: static void free_external_counter(counted_node_ptr &old_node_ptr) { node* const ptr=old_node_ptr.ptr; int const count_increase=old_node_ptr.external_count-2; node_counter old_counter= ptr->count.load(std::memory_order_relaxed); node_counter new_counter; do { new_counter=old_counter; --new_counter.external_counters; new_counter.internal_count+=count_increase; } while(!ptr->count.compare_exchange_strong( old_counter,new_counter, std::memory_order_acquire,std::memory_order_relaxed)); if(!new_counter.internal_count && !new_counter.external_counters) { delete ptr; } } };

Funkcja free_external_counter() jest odpowiednikiem funkcji increase_external_ ´count(). Nowa funkcja przypomina odpowiedni kod funkcji lock_free_stack::pop() z listingu 7.11, jednak została zmodyfikowana z myślą o obsłudze licznika external_ ´counters. Funkcja aktualizuje dwa liczniki za pomocą pojedynczego wywołania funkcji compare_exchange_strong() dla całej struktury count (podobnie jak w przypadku zmniejszania licznika internal_count w funkcji release_ref()). Wartość internal_count jest aktualizowana w taki sam sposób jak na listingu 7.11 , natomiast wartość licznika external_counters jest zmniejszana o jeden . Jeśli obie wartości są równe zero, dany węzeł nie jest już wskazywany przez żadne referencje i jako taki może być bezpiecznie usunięty .Opisane operacje należy wykonać w jednym kroku (stąd konieczność zastosowania pętli z operacją porównania i wymiany), aby uniknąć sytuacji wyścigu. Gdyby liczniki były aktualizowane osobno, dwa wątki mogłyby jednocześnie zakładać, że każdy z nich dysponuje ostatnią referencją, a próba dwukrotnego usunięcia tego samego węzła doprowadziłaby do niezdefiniowanego zachowania.

7.2.

Przykłady struktur danych bez blokad

261

Opisane rozwiązanie co prawda działa prawidłowo i nie jest narażone na sytuację wyścigu, jednak wciąż nie rozwiązaliśmy problemu niedostatecznej wydajności. Po rozpoczęciu wykonywania funkcji push() przez jeden wątek (w wyniku pomyślnego wykonania operacji compare_exchange_strong() na składowej old_tail.ptr->data — patrz wiersz na listingu 7.15) żaden inny wątek nie może rozpocząć wykonywania funkcji push(). Każdy wątek, który spróbuje wywołać tę funkcję, otrzyma nową wartość (nie nullptr), co spowoduje, że wywołanie funkcji compare_exchange_strong() zakończy się niepowodzeniem i doprowadzi do ponownego wykonania pętli. Ten mechanizm aktywnego oczekiwania (ang. busy wait) bezproduktywnie zajmuje cykle procesora. Oznacza to, że w rzeczywistości mamy do czynienia z blokadą. Pierwsze wywołanie funkcji push() blokuje pozostałe wątki do momentu zwrócenia sterowania, zatem kod w tej formie nie jest już algorytmem bez blokad. Co więcej, nawet jeśli system operacyjny dysponuje mechanizmem nadawania wyższego priorytetu wątkowi dysponującemu blokadą muteksu (jeśli istnieją jakieś zablokowane wątki), w tym przypadku odpowiedni mechanizm nie będzie działał, zatem zablokowane wątki będą zajmowały cykle procesora do zakończenia pierwszego wątku. Warto w tej sytuacji zastosować następny ciekawy zabieg ze zbioru technik implementacji algorytmów bez blokad — okazuje się, że wątek oczekujący może wesprzeć wątek wykonujący funkcję push(). IMPLEMENTACJA KOLEJKI BEZ BLOKAD POPRZEZ WZAJEMNE WSPIERANIE WĄTKÓW

Aby przywrócić pierwotny charakter tego kodu (jako algorytmu bez blokad), należy znaleźć sposób realizacji zadań przez wątek oczekujący, nawet jeśli wykonywanie funkcji push() w innym wątku zajmuje sporo czasu. Jednym ze sposobów osiągnięcia tego celu jest wsparcie wątku wykonującego tę funkcję. W tym przypadku doskonale wiemy, co trzeba zrobić — należy wskaźnikowi next w węźle tail przekazać nowy sztuczny węzeł, po czym zaktualizować sam wskaźnik tail. Wszystkie sztuczne węzły są traktowane dokładnie tak samo, zatem nie ma znaczenia, czy posługujemy się węzłem utworzonym przez wątek, któremu udało się umieścić dane w kolejce, czy węzłem utworzonym przez wątek, który wciąż czeka na wykonanie tej operacji. Jeśli przekształcimy wskaźnik next w ramach węzła w zmienną atomową, do ustawienia tego wskaźnika będziemy mogli użyć funkcji compare_exchange_strong(). Po ustawieniu wskaźnika next możemy użyć pętli wykonującej funkcję compare_exchan ´ge_weak() do ustawiania elementu tail i jednocześnie zagwarantować, że ten wskaźnik wciąż odwołuje się do tego samego, oryginalnego węzła. Jeśli wskaźnik tail nie odwołuje się do oryginalnego węzła (jeśli został zmieniony przez inny wątek), można wstrzymać dalsze próby i ponownie wykonać pętlę. Opisane rozwiązanie wymaga wprowadzenia drobnych zmian w funkcji pop() w celu ładowania wskaźnika next (patrz listing 7.20). Listing 7.20. Funkcja pop() zmodyfikowana pod kątem wspomagania wątku wykonującego funkcję push()

template class lock_free_queue { private: struct node { std::atomic data;

262

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad std::atomic count; std::atomic next; }; public: std::unique_ptr pop() { counted_node_ptr old_head=head.load(std::memory_order_relaxed); for(;;) { increase_external_count(head,old_head); node* const ptr=old_head.ptr; if(ptr==tail.load().ptr) { return std::unique_ptr(); } counted_node_ptr next=ptr->next.load(); if(head.compare_exchange_strong(old_head,next)) { T* const res=ptr->data.exchange(nullptr); free_external_counter(old_head); return std::unique_ptr(res); } ptr->release_ref(); } } };

Jak już wspomniałem, niezbędne zmiany są dość proste — wskaźnik next jest teraz zmienną atomową , zatem także operacja load jest atomowa . W tym przykładzie zastosowano domyślny tryb porządkowania pamięci memory_order_seq_cst, zatem równie dobrze można by pominąć jawne wywołanie funkcji load() i skorzystać z automatycznej konwersji na typ counted_node_ptr. Użycie jawnego wywołania jest jednak o tyle cenne, że wskazuje miejsce, w którym w przyszłości można wskazać alternatywny model porządkowania pamięci. Kod funkcji push() wymaga nieco głębszych zmian (patrz listing 7.21). Listing 7.21. Prosta implementacja funkcji push() z kodem wspierającym inny wątek (w celu wyeliminowania blokad)

template class lock_free_queue { private: void set_new_tail(counted_node_ptr &old_tail, counted_node_ptr const &new_tail) { node* const current_tail_ptr=old_tail.ptr; while(!tail.compare_exchange_weak(old_tail,new_tail) && old_tail.ptr==current_tail_ptr); if(old_tail.ptr==current_tail_ptr) free_external_counter(old_tail); else current_tail_ptr->release_ref(); } public:

7.2.

Przykłady struktur danych bez blokad

263

void push(T new_value) { std::unique_ptr new_data(new T(new_value)); counted_node_ptr new_next; new_next.ptr=new node; new_next.external_count=1; counted_node_ptr old_tail=tail.load(); for(;;) { increase_external_count(tail,old_tail); T* old_data=nullptr; if(old_tail.ptr->data.compare_exchange_strong( old_data,new_data.get())) { counted_node_ptr old_next={0}; if(!old_tail.ptr->next.compare_exchange_strong( old_next,new_next)) { delete new_next.ptr; new_next=old_next; } set_new_tail(old_tail, new_next); new_data.release(); break; } else { counted_node_ptr old_next={0}; if(old_tail.ptr->next.compare_exchange_strong( old_next,new_next)) { old_next=new_next; new_next.ptr=new node; } set_new_tail(old_tail, old_next); } } } };

Ostatnia wersja kodu pod wieloma względami przypomina funkcję push() z listingu 7.15, jednak istnieje kilka zasadniczych różnic. Jeśli już ustawimy wskaźnik data , będziemy musieli obsłużyć przypadek, w którym inny wątek pomógł nam w realizacji tego zadania — implementacja tego rozwiązania wymaga użycia dodatkowej klauzuli else . Po ustawieniu wskaźnika data w ramach węzła nowa wersja funkcji push() aktualizuje wskaźnik next za pomocą funkcji compare_exchange_strong() . Aby uniknąć wykonywania kodu w pętli, zastosowaliśmy funkcję compare_exchange_ strong(). Jeśli próba wymiany zakończy się niepowodzeniem, możemy być pewni, że inny wątek ustawił już wskaźnik next, nowy węzeł zaalokowany na początku tej operacji nie jest już potrzebny i może zostać usunięty . Wartość next ustawioną przez inny wątek wykorzystamy do aktualizacji elementu tail .

264

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

Właściwą aktualizację wskaźnika tail przeniesiono teraz do funkcji set_new_tail() . Funkcja aktualizuje element tail za pomocą pętli wywołującej funkcję compare_exchan ´ge_weak() , ponieważ w razie próby dodania nowego węzła przez inny wątek (za pomocą funkcji push()) składowa external_count może być zmieniona i ponieważ nie chcemy tracić zaktualizowanej wartości. Okazuje się jednak, że musimy jeszcze wyeliminować ryzyko nadpisania tej wartości w przypadku udanej próby zmiany podjętej przez inny wątek; w przeciwnym razie kolejka mogłaby zawierać pętle, co oczywiście byłoby niepożądane. Musimy zagwarantować, że składowa ptr załadowanej wartości będzie taka sama w razie niepowodzenia operacji porównania i wymiany. Jeśli w momencie opuszczania pętli wartość ptr jest taka sama , możemy przyjąć, że operacja ustawiania elementu tail zakończyła się pomyślnie, zatem możemy zwolnić stary licznik zewnętrzny . Jeśli wartość ptr jest inna, możemy być pewni, że jakiś inny wątek zwolni ten licznik, zatem w bieżącym wątku należy zwolnić tylko pojedynczą referencję utrzymywaną w tym wątku . Wątek wywołujący funkcję push(), któremu nie udało się ustawić wskaźnika data w tej iteracji pętli, może wspomóc wątek, który pomyślnie wprowadził aktualizację. Po pierwsze, należy podjąć próbę zaktualizowania wskaźnika next, tak aby wskazywał nowy węzeł zaalokowany w tym wątku . Jeśli ta operacja zostanie wykonana pomyślnie, należy użyć zaalokowanego węzła w roli nowego węzła tail i zaalokować inny nowy węzeł w związku z przewidywaną operacją dodania nowego elementu do kolejki . Możemy następnie podjąć próbę ustawienia węzła tail poprzez wywołanie funkcji set_new_tail przed ponownym wykonaniem pętli . Część czytelników zapewne zauważyła, że jak na tak niewielki fragment kodu liczba wywołań operacji new i delete jest zadziwiająco duża, ponieważ nowe węzły są alokowane w funkcji push() i niszczone w funkcji pop(). Oznacza to, że efektywność mechanizmu alokowania pamięci ma zasadniczy wpływ na wydajność tego kodu; nieefektywny mechanizm może całkowicie uniemożliwić skalowanie kontenera bez blokad. Kwestie wyboru i implementacji mechanizmów alokowania pamięci wykraczają poza zakres tematyczny tej książki, jednak warto pamiętać, że jedynym sposobem sprawdzenia, który z tych mechanizmów jest lepszy, jest przeprowadzenie odpowiednich testów i sprawdzenie, który z tych mechanizmów działa szybciej. Do najbardziej popularnych technik optymalizacji alokowania pamięci należy stosowanie odrębnych mechanizmów alokacji w poszczególnych wątkach i listy wolnych węzłów do odzyskania (zamiast każdorazowego alokowania pamięci). Przeanalizowaliśmy już dość przykładów — warto teraz podjąć próbę sformułowania na ich podstawie kilku ogólnych wskazówek dotyczących pisania struktur danych bez blokad.

7.3.

Wskazówki dotyczące pisania struktur danych bez blokad Po zapoznaniu się ze wszystkimi przykładami opisanymi w tym rozdziale nie ma wątpliwości, że właściwe implementowanie algorytmów bez blokad jest wyjątkowo trudnym zadaniem. Warto więc sformułować wskazówki, które będą pewnym ułatwieniem podczas projektowania struktur danych bez blokad. Wszystkie sformułowane na początku rozdziału 6. ogólne zalecenia dotyczące współbieżnych struktur danych wciąż pozostają

7.3.

Wskazówki dotyczące pisania struktur danych bez blokad

265

aktualne, jednak w przypadku struktur bez blokad potrzebujemy czegoś więcej. Na podstawie tych przykładów zdefiniowałem kilka przydatnych wskazówek, które warto uwzględniać podczas projektowania własnych struktur danych bez blokad. 7.3.1.

Wskazówka: na etapie tworzenia prototypu należy stosować tryb std::memory_order_seq_cst

Tryb std::memory_order_seq_cst jest dużo prostszy w interpretacji niż pozostałe modele porządkowania pamięci, ponieważ wszystkie operacje są wykonywane według jednego globalnego porządku. We wszystkich przykładach prezentowanych w tym rozdziale stosowałem początkowo właśnie tryb std::memory_order_seq_cst; mniej restrykcyjne tryby porządkowania pamięci stosowałem dopiero po sprawdzeniu, czy podstawowe operacje działają prawidłowo. Ten sposób wprowadzania alternatywnych trybów porządkowania pamięci jest formą optymalizacji, zatem należy to robić w przemyślany sposób. Ogólnie rzecz biorąc, identyfikacja operacji, dla których można stosować złagodzone tryby porządkowania pamięci, jest możliwa dopiero po opracowaniu kompletnego kodu operującego na projektowanej strukturze danych. Każdy inny sposób pracy nad kodem może prowadzić do komplikacji. Zmiana trybu porządkowania pamięci jest o tyle ryzykowna, że kod może działać prawidłowo w trakcie testów, ale nie musi gwarantować poprawnego wykonywania we wszystkich scenariuszach. Jeśli nie dysponujemy automatycznymi testami algorytmu, które sprawdzają wszystkie możliwe kombinacje widoczności wątków pod kątem spójności z wybranym trybem porządkowania, samo wykonanie tego kodu na platformie testowej z pewnością nie wystarczy. 7.3.2.

Wskazówka: należy używać schematu odzyskiwania pamięci bez blokad

Jednym z największych utrudnień związanych z wykonywaniem kodu bez blokad jest zarządzanie pamięcią. Należy unikać usuwania obiektów w sytuacji, gdy pozostałe wątki mogą nadal dysponować referencjami do tych obiektów, i jednocześnie należy dbać o to, aby obiekty były usuwane możliwie wcześnie, aby wyeliminować problem nadmiernego wykorzystania pamięci. W tym rozdziale omówiłem trzy techniki zapewniania bezpieczeństwa odzyskiwania pamięci: Q

Q

Q

oczekiwanie do momentu, w którym żaden wątek nie będzie uzyskiwał dostępu do struktury danych, i usuwanie wszystkich obiektów oczekujących na odzyskanie; używanie wskaźników ryzyka do identyfikacji wątków uzyskujących dostęp do poszczególnych obiektów; zliczanie referencji w celu uniknięcia problemu usuwania obiektów wskazywanych przez referencje.

We wszystkich przypadkach najważniejszym celem jest znalezienie sposobu śledzenia liczby wątków uzyskujących dostęp do poszczególnych obiektów i usuwanie tylko tych obiektów, które nie są już wskazywane przez żadne referencje. Istnieje wiele innych sposobów odzyskiwania pamięci zajmowanej przez elementy struktur danych bez blokad. Opisany scenariusz wprost idealnie nadaje się do zastosowania mechanizmu

266

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

odzyskiwania pamięci (ang. garbage collector). Pisanie algorytmów jest dużo prostsze, jeśli mamy pewność, że mechanizm odzyskiwania pamięci będzie zwalniał nieużywane węzły (ale nie będzie tego robił za wcześnie). Alternatywnym rozwiązaniem jest wielokrotne wykorzystywanie tych samych węzłów i usuwanie poszczególnych elementów dopiero w momencie niszczenia całej struktury danych. Ponieważ w tym modelu wątki są wykorzystywane wielokrotnie, pamięć nigdy nie znajduje się w nieprawidłowym stanie, co rozwiązuje przynajmniej część problemów związanych z unikaniem niezdefiniowanego zachowania. Wadą tego rozwiązania jest wzrost znaczenia innego problemu. Chodzi o tzw. problem ABA. 7.3.3

Wskazówka: należy unikać problemu ABA

Problem ABA dotyczy wszystkich algorytmów na bazie operacji porównania i wymiany. Przebieg tego zjawiska jest następujący:

1. Wątek nr 1 odczytuje zmienną atomową x i odkrywa, że ta zmienna zawiera wartość A. 2. Wątek nr 1 wykonuje jakąś operację na podstawie tej wartości, na przykład odwołuje się do wskazywanego obiektu (jeśli ta wartość jest wskaźnikiem) lub przeszukuje strukturę danych. 3. Wykonywanie wątku nr 1 zostaje wstrzymane przez system operacyjny. 4. Inny wątek wykonuje na zmiennej x jakąś operację, która zmienia jej wartość na B. 5. Jakiś wątek zmienia następnie dane powiązane z wartością A w taki sposób, że wartość utrzymywana przez wątek nr 1 nie jest już prawidłowa. Zmiana może być dość zasadnicza — może polegać na przykład na zwolnieniu wskazywanego obszaru pamięci lub na modyfikacji powiązanej wartości. 6. Na podstawie nowych danych wątek zmienia wartość zmiennej x na A. Jeśli ta zmienna reprezentuje wskaźnik, wspomniana wartość może wskazywać nowy obiekt, który przypadkiem znalazł się pod tym samym adresem co poprzedni obiekt. 7. Wątek nr 1 wznawia działanie i wykonuje operację porównania i wymiany na zmiennej x, porównując jej wartość do wartości A. Operacja porównania i wymiany kończy się pomyślnie (ponieważ zmienna rzeczywiście zawiera wartość A), jednak nie jest to już ta sama wartość A. Dane odczytane w kroku 2. nie są już prawidłowe, a mimo to wątek nr 1 nie może odkryć wprowadzonej zmiany, co ostatecznie prowadzi do uszkodzenia struktury danych. Opisany problem nie występował co prawda w żadnym z zaprezentowanych tutaj algorytmów, ale tworzenie algorytmów bez blokad narażonych na to zjawisko zdarza się dość często. Najbardziej popularnym sposobem unikania tego problemu jest uzupełnienie zmiennej x o specjalny licznik ABA. W takim przypadku operacja porównywania i wymiany będzie wykonywana na połączonej strukturze obejmującej właściwą zmienną x i dodatkowy licznik. Za każdym razem, gdy wartość tej zmiennej jest modyfikowana,

7.4.

Podsumowanie

267

licznik jest zwiększany, zatem nawet jeśli zmienna x w dwóch kolejnych odczytach będzie miała tę samą wartość, operacja porównania i wymiany zakończy się niepowodzeniem, jeśli tylko inny wątek w międzyczasie zmodyfikuje tę zmienną. Problem ABA występuje szczególnie często w algorytmach używających list wolnych elementów lub innych mechanizmów wielokrotnego stosowania węzłów (zamiast każdorazowego zwalniania i alokowania pamięci). 7.3.4.

Wskazówka: należy identyfikować pętle aktywnego oczekiwania i wykorzystywać czas bezczynności na wspieranie innego wątku

W ostatnim przykładzie struktury kolejki mieliśmy do czynienia z wątkiem oczekującym na możliwość wykonania swojej operacji (dodania nowego elementu) w związku z wykonywaniem tej samej operacji przez inny wątek. W oryginalnej wersji wątek oczekujący wykonywał pętlę aktywnego oczekiwania, bezproduktywnie zajmując czas procesora. Skoro wątek oczekujący wykonuje pętlę aktywnego oczekiwania, program stosuje w rzeczywistości operację blokującą, zatem równie dobrze można by zastosować zwykłe muteksy i blokady. Zmodyfikowanie algorytmu tak, aby wątek oczekujący wykonywał pewne kroki przed zakończeniem operacji przez oryginalny wątek, pozwala wyeliminować zjawisko aktywnego oczekiwania i zmienić charakter operacji, która w oryginalnej wersji blokowała pozostałe wątki. W przykładowej implementacji kolejki opisane rozwiązanie wymagało przekształcenia składowej danych w zmienną atomową oraz użycia operacji porównania i wymiany do ustawienia tej zmiennej, jednak w przypadku bardziej skomplikowanych struktur danych implementacja tego modelu mogłaby wymagać głębszych modyfikacji.

7.4.

Podsumowanie Tak jak w rozdziale 6. omówiono struktury danych implementowane przy użyciu blokad, tak tutaj opisałem proste implementacje struktur danych bez blokad (ponownie posłużyłem się przykładami stosu i kolejki). Pokazałem, jak zadbać o właściwe porządkowanie pamięci podczas wykonywania operacji atomowych, aby wykluczyć ryzyko wyścigu danych i zagwarantować, żeby każdy wątek dysponował spójną reprezentacją struktury danych. Wykazałem też, że w przypadku struktur danych bez blokad zarządzanie pamięcią jest nieporównanie trudniejsze niż w implementacjach struktur na bazie blokad. Omówiłem kilka rozwiązań umożliwiających obsługę zarządzania pamięcią. Pokazałem też, jak unikać tworzenia pętli aktywnego oczekiwania poprzez wspomaganie wątku, na który czeka bieżący wątek. Projektowanie struktur danych bez blokad jest skomplikowane i w trakcie tego procesu nietrudno o błąd, jednak takie struktury danych cechują się wysoką skalowalnością, co w pewnych sytuacjach jest bardzo ważne. Mam jednak nadzieję, że po analizie przykładów pokazanych w tym rozdziale i po lekturze proponowanych wskazówek projektowanie własnych struktur danych bez blokad, implementowanie tych struktur na podstawie ogólnych projektów oraz znajdowanie błędów w istniejących implementacjach będzie dużo łatwiejsze. Wszędzie tam, gdzie dane są współdzielone przez wiele wątków, musimy dobrze przemyśleć stosowane struktury i sposoby synchronizacji danych przetwarzanych przez wiele wątków. Projektując struktury danych z myślą o współbieżności, możemy umieścić

268

ROZDZIAŁ 7. Projektowanie współbieżnych struktur danych bez blokad

niezbędne mechanizmy w samej strukturze danych, tak aby pozostały kod mógł się koncentrować na właściwych zadaniach, nie na synchronizacji danych. Odpowiednie rozwiązania zostaną omówione w rozdziale 8., gdzie przejdziemy od omawiania współbieżnych struktur danych do kwestii związanych z szeroko rozumianym współbieżnym kodem. Algorytmy równoległe używają wielu wątków do poprawy wydajności, a wybór właściwej współbieżnej struktury danych ma zasadniczy wpływ na efektywność programów, których wątki operują na współdzielonych danych.

Projektowanie współbieżnego kodu

W tym rozdziale zostaną omówione następujące zagadnienia: Q Q

Q

Q

Q Q

techniki dzielenia danych pomiędzy wątki; czynniki wpływające na wydajność współbieżnego kodu; techniki projektowania struktur danych pod kątem czynników wpływających na wydajność; bezpieczeństwo obsługi wątków w kodzie wielowątkowym; skalowalność; przykładowe implementacje wielu równoległych algorytmów.

W większości dotychczasowych rozdziałów koncentrowałem się na narzędziach ułatwiających pisanie współbieżnego kodu, które wprowadzono w ostatniej wersji C++11. W rozdziałach 6. i 7. przeanalizowaliśmy techniki używania tych narzędzi do projektowania podstawowych struktur danych, które gwarantują bezpieczeństwo współbieżnego dostępu wielu wątków. Tak jak wiedza stolarza nie może się ograniczać do umiejętności zbijania prostych szafek czy stołów, tak w naszej pracy nie wystarczy jednak umiejętność projektowania i stosowania najprostszych struktur danych — projektowanie współbieżnego kodu obejmuje wiele innych zadań. Musimy teraz spojrzeć nieco szerzej na tę kwestię, aby budować większe struktury danych potrzebne do osiągania trudniejszych celów. W roli przykładów użyję wielowątkowych implementacji

270

ROZDZIAŁ 8. Projektowanie współbieżnego kodu

kilku algorytmów dostępnych w bibliotece standardowej języka C++, jednak te same reguły dotyczą w równym stopniu innych aplikacji, niezależnie od skali. Tak jak w przypadku każdego projektu programistycznego, projektowanie współbieżnego kodu musi być przemyślane. Okazuje się jednak, że w przypadku wielowątkowego kodu należy uwzględnić jeszcze więcej czynników niż podczas projektowania sekwencyjnego kodu. Musimy nie tylko mieć na uwadze typowe czynniki, jak hermetyzacja, relacje łączące poszczególne elementy czy spójność (które szczegółowo opisano w wielu książkach poświęconych projektowaniu oprogramowania), ale też wybrać dane, które będą współdzielone przez wątki, zdecydować, jak będzie synchronizowany dostęp do tych danych, wskazać wątki, które będą musiały czekać na zakończenie pewnych operacji, itd. W tym rozdziale skoncentrujemy się właśnie na tych kwestiach — od problemów wysokiego poziomu (ale bardzo ważnych), w tym wyboru liczby wątków, wyboru kodu wykonywanego przez poszczególne wątki i oceny wpływu tych decyzji na czytelność kodu, po niskopoziomowe, szczegółowe decyzje dotyczące struktury współdzielonych danych zapewniającej optymalną wydajność. Zacznijmy od omówienia technik dzielenia pracy pomiędzy wątki.

8.1.

Techniki dzielenia pracy pomiędzy wątki Wyobraźmy sobie na moment, że zlecono nam budowę domu. Aby wykonać to zadanie, musimy wykopać fundamenty, postawić mury, przygotować instalację wodno-kanalizacyjną, okablować budynek itp. Teoretycznie można wszystkie te zadania wykonać samodzielnie (po odbyciu odpowiednich szkoleń), jednak praca w pojedynkę prawdopodobnie zajęłaby mnóstwo czasu i wymagałaby nieustannego przełączania zadań. Alternatywnym rozwiązaniem jest zatrudnienie kilku dodatkowych pracowników do pomocy. W takim przypadku należy zdecydować, ile osób zatrudnić i jakimi umiejętnościami muszą dysponować te osoby. Moglibyśmy na przykład zatrudnić kilka osób dysponujących ogólnymi umiejętnościami, tak aby każdy mógł pomagać przy wszystkich pracach. Budowa domu wciąż byłaby narażona na przełączanie kontekstu, ale i tak przebiegałaby sprawniej dzięki zaangażowaniu większej liczby osób. Jeszcze innym rozwiązaniem jest zatrudnienie zespołu specjalistów: na przykład murarza, cieśli, elektryka i hydraulika. Każdy specjalista realizowałby tylko zadania, na których zna się najlepiej, zatem w czasie, w którym nie byłoby żadnych zadań związanych z instalacją wodno-kanalizacyjną, hydraulik mógłby usiąść i napić się herbaty lub kawy. Także w tym przypadku budowa będzie przebiegała szybciej niż w oryginalnym modelu, ponieważ prace będą wykonywane przez większą grupę — hydraulik może przecież instalować muszlę klozetową w czasie, gdy elektryk układa przewody w kuchni, jednak pracownicy częściej pozostają bezczynni, jeśli nie ma żadnych zadań dla poszczególnych specjalistów. Mimo dłuższego czasu bezczynności może się okazać, że dom zostanie wybudowany szybciej dzięki zaangażowaniu specjalistów zamiast niewykwalifikowanych pracowników. Specjaliści nie tylko nie muszą stale zmieniać narzędzi, ale też potrafią realizować swoje zadania dużo szybciej niż pracownicy bez doświadczenia. Przewaga jednego z tych rozwiązań zależy od konkretnych okoliczności — warto więc sprawdzić oba modele i przekonać się, które sprawdzi się najlepiej.

8.1.

Techniki dzielenia pracy pomiędzy wątki

271

Nawet w razie decyzji o zatrudnieniu fachowców należy jeszcze określić, ilu pracowników poszczególnych specjalności należy zaangażować. Może się okazać, że dom powstanie szybciej, jeśli liczba murarzy będzie większa niż liczba elektryków. Kształt grupy i ogólna efektywność zespołu mogą się zmieniać także w zależności od liczby budowanych domów. Nawet jeśli hydraulik nie ma zbyt wiele pracy w jednym domu, być może w przypadku jednoczesnej budowy większej liczby domów możliwe będzie pełne wykorzystanie jego czasu. Co więcej, jeśli nie chcemy ponosić kosztów zatrudnienia specjalistów, dla których nie mamy wystarczająco dużo zadań, powinniśmy rozważyć zatrudnienie większej liczby niewykwalifikowanych pracowników — takie rozwiązanie może być korzystne, nawet jeśli liczba osób pracujących w jednym domu jest ograniczona. Na tym możemy chyba zakończyć omawianie tematów budowlanych i przejść do analogii w świecie wątków. Okazuje się, że wątki podlegają dokładnie tym samym zasadom. Musimy zdecydować, ilu wątków użyjemy i jaki będzie podział zadań pomiędzy nimi. Musimy zdecydować, czy chcemy stosować uniwersalne wątki, które będą przystosowane do realizacji dowolnych zadań w dowolnym czasie, czy raczej wyspecjalizowane wątki, które będą efektywnie realizować tylko jedno zadanie. Możemy też zastosować jakąś kombinację obu tych modeli. Wspomniane decyzje musimy podjąć niezależnie od tego, dlaczego uznaliśmy, że warto zastosować techniki przetwarzania współbieżnego. Co więcej, decyzje podjęte na tym etapie będą miały zasadniczy wpływ na wydajność i czytelność kodu. W tej sytuacji bardzo ważne jest dobre rozumienie dostępnych operacji, tak aby można było świadomie podejmować decyzje podczas projektowania struktury aplikacji. W tym podrozdziale przeanalizujemy kilka technik dzielenia zadań. Zaczniemy od omówienia metod dzielenia danych pomiędzy wątki. 8.1.1.

Dzielenie danych pomiędzy wątki przed rozpoczęciem przetwarzania

Do algorytmów, których dostosowywanie do wymogów przetwarzania współbieżnego jest najprostsze, należą algorytmy wykonujące tę samą operację na każdym elemencie zbioru danych (na przykład algorytmy na bazie funkcji std::for_each). Algorytmy tego typu można zrównoleglać, przypisując po jednym wątku każdemu elementowi do przetworzenia. Sposób podziału elementów pod kątem optymalnej wydajności zależy w dużym stopniu od szczegółowych cech przetwarzanej struktury danych (więcej informacji na ten temat można znaleźć w dalszej części tego rozdziału przy okazji omawiania kwestii wydajności). Najprostszym sposobem podziału danych jest przydział pierwszych N elementów do jednego wątku, kolejnych N do innego wątku itd. (patrz rysunek 8.1), jednak istnieją też inne sposoby dzielenia i przydziału danych. Niezależnie od sposobu podziału danych każdy wątek przetwarza tylko te elementy, które zostały mu przydzielone, i nie komunikuje się z pozostałymi wątkami do momentu zakończenia przetwarzania. Opisywana struktura zapewne wyda się znajoma programistom, którzy mieli okazję korzystać z frameworku Message Passing Interface (MPI)1 lub OpenMP2: zadanie jest dzielone na zbiór równoległych podzadań, wątki robocze wykonują te zadania nie 1

http://www.mpi-forum.org/

2

http://www.openmp.org/

272

ROZDZIAŁ 8. Projektowanie współbieżnego kodu

Rysunek 8.1. Podział sąsiadujących fragmentów danych pomiędzy wątki

zależnie od siebie, a wyniki są łączone w ostatnim kroku, tzw. redukcji. Właśnie ten model zastosowano w przykładzie kumulowania wyników z podrozdziału 2.4 — oba równoległe zadania i końcowy krok redukcji polegały na kumulacji danych. W przypadku prostego algorytmu na bazie funkcji for_each w ostatnim kroku nie ma potrzeby wykonywania jakichkolwiek operacji, ponieważ żadne wyniki nie wymagają redukcji. Identyfikacja ostatniego kroku jako redukcji jest bardzo ważna; co ciekawe, w najprostszych, naiwnych implementacjach (podobnych do tej z listingu 2.8) redukcja jest wykonywana w roli ostatniego kroku przetwarzania sekwencyjnego. Okazuje się jednak, że także ten krok często można zrównoleglić — skoro operacja kumulowania danych jest używana w roli redukcji, kod z listingu 2.8 można by tak zmodyfikować, aby na przykład zawierał wywołania rekurencyjne, gdyby liczba wątków przekraczała liczbę elementów do przetworzenia przez jeden wątek. Alternatywnym rozwiązaniem byłoby wykonywanie wybranych kroków operacji redukcji przez wątki robocze po zakończeniu właściwych zadań. W takim przypadku można by uniknąć konieczności każdorazowego uruchamiania nowych wątków. Opisana technika co prawda stwarza ogromne możliwości, jednak nie może być stosowana we wszystkich przypadkach. Czasem danych nie można dzielić z wyprzedzeniem, ponieważ właściwy punkt podziału można wskazać dopiero po ich przetworzeniu. Problem jest szczególnie widoczny w przypadku algorytmów rekurencyjnych, na przykład algorytmu sortowania szybkiego, gdzie należy stosować zupełnie inne rozwiązanie. 8.1.2.

Rekurencyjne dzielenie danych

Algorytm sortowania szybkiego (ang. quicksort) składa się z dwóch podstawowych kroków: podziału danych na elementy, które w ostatecznym porządku powinny się znaleźć przed i za wybranym elementem dzielącym, oraz rekurencyjnego sortowania obu części struktury. Tak działającego algorytmu nie można zrównoleglić, dzieląc dane z wyprzedzeniem, ponieważ dopiero w wyniku przetwarzania elementów możemy określić, do której „połowy” powinny trafić poszczególne elementy. Warunkiem zrównoleglenia takiego algorytmu jest więc wykorzystanie jego rekurencyjnego charakteru. Na każdym poziomie rekurencji wzrasta liczba wywołań funkcji quick_sort, ponieważ musimy sortować zarówno elementy należące do podzbioru sprzed elementu podziału, jak i elementy z podzbioru następującego po tym elemencie. Te wywołania rekurencyjne

8.1.

273

Techniki dzielenia pracy pomiędzy wątki

są całkowicie niezależne od siebie, ponieważ operują na odrębnych zbiorach elementów, zatem wprost idealnie nadają się do przetwarzania współbieżnego. Schemat tego rekurencyjnego podziału zbiorów pokazano na rysunku 8.2.

Rysunek 8.2. Rekurencyjne dzielenie danych

Przykład takiej implementacji analizowaliśmy w rozdziale 4. Zamiast stosować dwa wywołania rekurencyjne dla obu podzbiorów, używaliśmy (na każdym etapie) funkcji std::async() do uruchamiania dwóch asynchronicznych zadań dla dolnego podzbioru. Wywołanie funkcji std::async() powoduje, że to biblioteka wątków języka C++ musi zdecydować, czy zadanie ma być wykonywane w nowym wątku i czy powinno być realizowane w sposób synchroniczny. Ten aspekt jest szczególnie ważny, jeśli sortowanie dotyczy wielkiego zbioru danych, ponieważ uruchamianie nowego wątku dla każdego wywołania rekurencyjnego szybko doprowadziłoby do powstania ogromnej liczby wątków. Przy okazji omawiania zagadnień związanych z wydajnością wykażę, że zbyt duża liczba wątków paradoksalnie może spowolnić wykonywanie aplikacji. Jeśli zbiór danych jest bardzo duży, nie można wykluczyć wyczerpania puli wątków. Idea rekurencyjnego dzielenia zbiorczego zadania w opisany sposób jest korzystna — wystarczy, że uda nam się zachować kontrolę nad liczbą wątków. W prostych przypadkach można użyć do tego celu funkcji std::async(), jednak istnieją też inne rozwiązania. Jednym z alternatywnych rozwiązań jest użycie funkcji std::thread::hardware_con ´currency() do wyboru liczby wątków (tak jak w równoległej wersji funkcji accumu ´late() z listingu 2.8). Zamiast uruchamiać nowy wątek dla rekurencyjnych wywołań możemy po prostu umieścić podzbiór do posortowania na stosie gwarantującym bezpieczeństwo przetwarzania wielowątkowego (na przykład na jednym ze stosów opisanych w rozdziałach 6. i 7.). Wątek, który w tym czasie nie realizuje żadnych zadań (na przykład dlatego, że zakończył przetwarzanie wszystkich swoich danych, lub dlatego, że czeka na posortowanie jakiegoś podzbioru), może pobrać fragment danych ze stosu i rozpocząć jego sortowanie. Przykładową implementację z wykorzystaniem tej techniki pokazano na listingu 8.1. Listing 8.1. Równoległy algorytm sortowania szybkiego przy użyciu stosu podzbiorów oczekujących na posortowanie

template struct sorter { struct chunk_to_sort

274

ROZDZIAŁ 8. Projektowanie współbieżnego kodu { };

std::list data; std::promise promise;

thread_safe_stack chunks; std::vector threads; unsigned const max_thread_count; std::atomic end_of_data; sorter(): max_thread_count(std::thread::hardware_concurrency()-1), end_of_data(false) {} ~sorter() { end_of_data=true; for(unsigned i=0;iset_clear_mutex.unlock(); } void lock() { std::lock(self->set_clear_mutex,lk); } ~custom_lock() { self->thread_cond_any=0; self->set_clear_mutex.unlock(); } }; custom_lock cl(this,cv,lk); interruption_point(); cv.wait(cl); interruption_point(); } // dalsza część taka jak wcześniej }; template void interruptible_wait(std::condition_variable_any& cv,

347

348

ROZDZIAŁ 9. Zaawansowane zarządzanie wątkami Lockable& lk) { }

this_thread_interrupt_flag.wait(cv,lk);

Niestandardowy typ blokady uzyskuje blokadę wewnętrznego muteksu set_clear_mutex już podczas konstruowania obiektu , po czym tak ustawia wskaźnik thread_cond_any, aby odwoływał się do obiektu klasy std::condition_variable_any przekazanego na wejściu konstruktora . Referencja do zmiennej typu Lockable jest zapisywana z myślą o przyszłym użyciu (na tym etapie ta instancja musi być już zablokowana). Możemy teraz sprawdzać ewentualne sygnały o przerwaniu działania bez ryzyka występowania sytuacji wyścigu. Jeśli na tym etapie flaga przerwania jest ustawiona, możemy być pewni, że została ustawiona przed uzyskaniem blokady muteksu set_clear_mutex. W momencie wywołania funkcji unlock() przez zmienną warunkową w ramach funkcji wait() odblokowujemy obiekt typu Lockable i wewnętrzny muteks set_clear_mutex . Takie rozwiązanie umożliwia wątkom, które próbują przerwać bieżący wątek, uzyskanie blokady muteksu set_clear_mutex i sprawdzenie wskaźnika thread_cond_any w trakcie wykonywania wywołania funkcji wait(), ale nie wcześniej. Właśnie o to chodziło nam w przypadku klasy std::condition_variable, jednak wówczas realizacja tego celu okazała się niemożliwa. Po zakończeniu oczekiwania przez funkcję wait() (w wyniku otrzymania powiadomienia lub na skutek fałszywego budzenia) możemy wywołać funkcję lock(), która ponownie uzyskuje blokadę wewnętrznego muteksu set_clear_mutex i obiektu Lockable . Przed wyzerowaniem wskaźnika thread_cond_any w destruktorze klasy custom_lock , w którym dodatkowo odblokowujemy muteks clear_mutex, możemy ponownie sprawdzić ewentualne przerwania, które mogły mieć miejsce w trakcie wykonywania funkcji wait(). 9.2.5.

Przerywanie pozostałych wywołań blokujących

Na tym możemy zakończyć analizę metod przerywania oczekiwania na zmienne warunkowe, jednak wciąż nie poruszyliśmy kwestii pozostałych wywołań blokujących, w tym blokad muteksów, oczekiwania na obiekty przyszłości itp. Ogólnie rzecz biorąc, w wymienionych przypadkach należy stosować technikę na bazie limitów czasowych (tak jak dla zmiennych warunkowych typu std::condition_variable), ponieważ nie jest możliwe przerwanie oczekiwania bez spełnienia warunku, na który czeka dany wątek, ani bez dostępu do wewnętrznych mechanizmów muteksu czy przyszłości. Ponieważ jednak w każdym z tych przypadków cel oczekiwania jest znany z góry, możemy wykonywać odpowiednią pętlę w ciele funkcji interruptible_wait(). Poniżej pokazano przykład przeciążonej wersji funkcji interruptible_wait() stworzonej z myślą o klasie std::future: template void interruptible_wait(std::future& uf) { while(!this_thread_interrupt_flag.is_set()) { if(uf.wait_for(lk,std::chrono::milliseconds(1)== std::future_status::ready) break;

9.2.

Przerywanie wykonywania wątków

349

} interruption_point(); }

Funkcja interruptible_wait() czeka albo na ustawienie flagi przerwania, albo na osiągnięcie gotowości przez obiekt przyszłości, ale też wstrzymuje działanie w oczekiwaniu na ten obiekt na 1 milisekundę. Oznacza to, że w przypadku zegara o wysokiej rozdzielczości średni czas uwzględnienia żądania przerwania wyniesie około 0,5 ms. Funkcja wait_for czeka zwykle co najmniej przez czas trwania taktu zegara, zatem jeśli ten takt zajmuje 15 ms, musimy się liczyć z koniecznością oczekiwania przez blisko 15 ms, nie 1 ms. Takie działanie może, ale nie musi być zgodne z naszymi oczekiwaniami — wszystko zależy od rodzaju aplikacji. W razie konieczności zawsze można podjąć próbę skrócenia limitu czasowego. Wadą takiego kroku jest częstsze budzenie wątku w celu sprawdzenia flagi przerwania i tym samym wyższe koszty przełączania zadań. Wiemy już, jak można wykrywać przerwania za pomocą funkcji interruption_point() i interruptible_wait(), jednak wciąż nie wyjaśniłem, jak te przerwania należy obsługiwać. 9.2.6.

Obsługa przerwań

Z perspektywy przerywanego wątku przerwanie jest w istocie wyjątkiem thread_in ´terrupted, który można obsłużyć tak jak każdy inny wyjątek. W szczególności możemy przechwycić ten wyjątek w standardowym bloku catch: try { do_something(); } catch(thread_interrupted&) { handle_interruption(); }

Oznacza to, że możemy przechwycić przerwanie, obsłużyć je w wybrany sposób, po czym kontynuować normalne działanie. Jeśli zdecydujemy się na takie rozwiązanie i jeśli inny wątek ponownie wywoła funkcję interrupt(), bieżący wątek zostanie ponownie przerwany przy okazji najbliższego osiągnięcia punktu przerwania. Takie działanie jest naturalne, jeśli dany wątek wykonuje sekwencję niezależnych zadań; w takim przypadku przerwanie jednego zadania spowoduje jego porzucenie i przejście do wykonywania następnego zadania z listy. Ponieważ zmienna thread_interrupted reprezentuje wyjątek, podczas wywoływania kodu, który może być przerwany, musimy mieć na uwadze wszystkie aspekty bezpieczeństwa wyjątków — musimy zagwarantować, że nie dojdzie do wycieku zasobów i że nasze struktury danych zachowają spójny stan. W wielu przypadkach najlepszym rozwiązaniem jest zakończenie wątku w wyniku przerwania, tak aby odpowiedni wyjątek był propagowany na wyższy poziom. Jeśli jednak dopuścimy do propagowania wyjątków poza funkcję wątku przekazaną na wejściu konstruktora klasy std::thread, wywołana zostanie funkcja std::terminate(), która przerwie wykonywanie całego programu. Aby uniknąć konieczności umieszczania konstrukcji catch (thread_interrupted) w każdej funkcji, która otrzymuje na wejściu obiekt interruptible_thread, możemy

350

ROZDZIAŁ 9. Zaawansowane zarządzanie wątkami

umieścić ten blok catch w kodzie opakowania używanego do inicjalizacji flagi interrupt ´_flag. Dopiero po zastosowaniu tego rozwiązania propagowanie nieobsłużonego wyjątku przerwania będzie bezpieczne, ponieważ będzie powodowało zakończenie wykonywania tylko jednego wątku. Kod inicjalizujący wątek w ramach konstruktora klasy interruptible_thread ma teraz następującą postać: internal_thread=std::thread([f,&p]{ p.set_value(&this_thread_interrupt_flag); try { f(); } catch(thread_interrupted const&) {} });

Przeanalizujmy teraz konkretny przykład użycia mechanizmu przerwań. 9.2.7.

Przerywanie zadań wykonywanych w tle podczas zamykania aplikacji

Przeanalizujmy przykład aplikacji przeszukującej zasoby komputera (lokalnej wyszukiwarki). Oprócz interakcji z użytkownikiem taka aplikacja musi monitorować stan systemu plików i identyfikować wszelkie zmiany, aby na bieżąco aktualizować swój indeks. Za takie przetwarzanie odpowiada zwykle wątek wykonywany w tle, aby można było uniknąć negatywnego wpływu na czas reakcji graficznego interfejsu użytkownika. Wątek wykonywany w tle musi działać przez cały czas życia aplikacji — jest uruchamiany w ramach procedury inicjalizacji tej aplikacji i działa aż do momentu zakończenia pracy programu. Aplikacje tego typu są zwykle zamykane wraz z wyłączaniem komputera, ponieważ warunkiem utrzymania aktualnego indeksu jest stałe działanie tego oprogramowania. W tym przypadku zamknięcie aplikacji wymaga odpowiedniego zakończenia wątków wykonywanych w tle — jednym ze sposobów realizacji tego celu jest przerwanie tych wątków. Przykładową implementację mechanizmu zarządzania wątkami w systemie tego typu pokazano na listingu 9.13. Listing 9.13. Monitorowanie systemu plików w tle

std::mutex config_mutex; std::vector background_threads; void background_thread(int disk_id) { while(true) { interruption_point(); fs_change fsc=get_fs_changes(disk_id); if(fsc.has_changes()) { update_index(fsc); } } }

9.2.

Przerywanie wykonywania wątków

351

void start_background_processing() { background_threads.push_back( interruptible_thread(background_thread,disk_1)); background_threads.push_back( interruptible_thread(background_thread,disk_2)); } int main() { start_background_processing(); process_gui_until_exit(); std::unique_lock lk(config_mutex); for(unsigned i=0;imutex()->unlock(). Zgłaszane wyjątki Brak. FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::SWAP

Wymienia własność unikatowych blokad powiązanych z dwoma obiektami klasy std::unique_lock. Deklaracja void swap(unique_lock& other) noexcept;

Skutki wywołania Jeśli przed wywołaniem tej funkcji obiekt other był właścicielem blokady, blokada ta należy teraz do obiektu *this. Jeśli przed wywołaniem tej funkcji obiekt *this był właścicielem blokady, blokada ta należy teraz do obiektu other.

536

DODATEK D Biblioteka wątków języka C++

Warunki końcowe Funkcja this->mutex() zwraca wartość równą wartości zwracanej przez funkcję other.mutex() przed tym wywołaniem. Funkcja other.mutex() zwraca wartość równą wartości zwracanej przez funkcję this->mutex() przed tym wywołaniem. Funkcja this->owns_lock() zwraca wartość równą wartości zwracanej przez funkcję other.owns_lock() przed tym wywołaniem. Funkcja other.owns_lock() zwraca wartość równą wartości zwracanej przez funkcję this->owns_lock() przed tym wywołaniem. Zgłaszane wyjątki Brak. FUNKCJA NIESKŁADOWA STD::UNIQUE_LOCK::SWAP

Wymienia własność blokad muteksów powiązanych z dwoma obiektami klasy std::unique_lock. Deklaracja void swap(unique_lock& lhs,unique_lock& rhs) noexcept;

Skutki wywołania lhs.swap(rhs)

Zgłaszane wyjątki Brak. FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::LOCK

Uzyskuje blokadę muteksu powiązanego z obiektem *this. Deklaracja void lock();

Warunki wstępne this->mutex()!=NULL, this->owns_lock()==false.

Skutki wywołania Wywołuje funkcję this->mutex()->lock(). Zgłaszane wyjątki Ewentualne wyjątki zgłaszane są przez funkcję this->mutex()->lock(). Wyjątek std::system_error z kodem błędu std::errc::operation_not_permitted, jeśli this->mutex()==NULL. Wyjątek std::system_error z kodem błędu std::errc::resource_deadlock_would_occur, jeśli dla danego wpisu this->owns_lock()==true. Warunki końcowe this->owns_lock()==true.

FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::TRY_LOCK

Próbuje uzyskać blokadę muteksu powiązanego z obiektem *this. Deklaracja bool try_lock();

D.5.

Nagłówek

537

Warunki wstępne Typ Mutex użyty podczas tworzenia konkretnej klasy szablonu std::unique_lock musi spełniać wymagania stawiane typom blokowalnym (Lockable). this->mutex()!=NULL, this->owns_lock()==false. Skutki wywołania Wywołuje funkcję this->mutex()->try_lock(). Zwracana wartość Zwraca wartość true, jeśli wywołanie funkcji this->mutex()->try_lock() zwróciło wartość true; w przeciwnym razie wartość false. Zgłaszane wyjątki Ewentualne wyjątki zgłaszane są przez funkcję this->mutex()->try_lock(). Wyjątek std::system_error z kodem błędu std::errc::operation_not_permitted, jeśli this->mutex()==NULL. Wyjątek std::system_error z kodem błędu std::errc::resource_deadlock_would_occur, jeśli dla danego wpisu this->owns_lock()==true. Warunki końcowe Jeśli funkcja zwraca wartość true, this->owns_lock()==true; w przeciwnym razie this->owns_lock()==false. FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::UNLOCK

Zwalnia blokadę muteksu powiązanego z obiektem *this. Deklaracja void unlock();

Warunki wstępne this->mutex()!=NULL, this->owns_lock()==true.

Skutki wywołania Wywołuje funkcję this->mutex()->unlock(). Zgłaszane wyjątki Ewentualne wyjątki zgłaszane są przez funkcję this->mutex()->unlock(). Wyjątek std::system_error z kodem błędu std::errc::operation_not_permitted, jeśli dla danego wpisu this->owns_lock()== false. Warunki końcowe this->owns_lock()==false.

FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::TRY_LOCK_FOR

Próbuje uzyskać blokadę muteksu powiązanego z obiektem *this w określonym czasie. Deklaracja template bool try_lock_for( std::chrono::duration const& relative_time);

538

DODATEK D Biblioteka wątków języka C++

Warunki wstępne Typ Mutex użyty podczas tworzenia konkretnej klasy szablonu std::unique_lock musi spełniać wymagania stawiane typom czasowo blokowalnym (TimedLockable). this->mutex()!=NULL, this->owns_lock()==false. Skutki wywołania Wywołuje funkcję this->mutex()->try_lock_for(relative_time). Zwracana wartość Zwraca wartość true, jeśli wywołanie funkcji this->mutex()->try_lock_for() zwróciło wartość true; w przeciwnym razie wartość false. Zgłaszane wyjątki Ewentualne wyjątki zgłaszane są przez funkcję this->mutex()->try_lock_for(). Wyjątek std::system_error z kodem błędu std::errc::operation_not_permitted, jeśli this->mutex()==NULL. Wyjątek std::system_error z kodem błędu std::errc::resource_deadlock_would_occur, jeśli dla danego wpisu this->owns_lock()==true. Warunki końcowe Jeśli funkcja zwraca wartość true, this->owns_lock()==true; w przeciwnym razie this->owns_lock()==false. FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::TRY_LOCK_UNTIL Próbuje uzyskać blokadę muteksu powiązanego z obiektem *this w określonym czasie.

Deklaracja template bool try_lock_until( std::chrono::time_point const& absolute_time);

Warunki wstępne Typ Mutex użyty podczas tworzenia konkretnej klasy szablonu std::unique_lock musi spełniać wymagania stawiane typom czasowo blokowalnym (TimedLockable). this->mutex()!=NULL, this->owns_lock()==false. Skutki wywołania Wywołuje funkcję this->mutex()->try_lock_until(absolute_time). Zwracana wartość Zwraca wartość true, jeśli wywołanie funkcji this->mutex()->try_lock_until() zwróciło wartość true; w przeciwnym razie wartość false. Zgłaszane wyjątki Ewentualne wyjątki zgłaszane są przez funkcję this->mutex()->try_lock_until(). Wyjątek std::system_error z kodem błędu std::errc::operation_not_permitted, jeśli this->mutex()==NULL. Wyjątek std::system_error z kodem błędu std::errc::resource_deadlock_would_occur, jeśli dla danego wpisu this->owns_lock()==true.

D.5.

Nagłówek

539

Warunki końcowe Jeśli funkcja zwraca wartość true, this->owns_lock()==true; w przeciwnym razie this->owns_lock()==false. LOGICZNA FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::OPERATOR Sprawdza, czy obiekt *this jest właścicielem blokady muteksu.

Deklaracja explicit operator bool() const noexcept;

Zwracana wartość this->owns_lock().

Zgłaszane wyjątki Brak. UWAGA. Jest to jawny operator konwersji, zatem jego niejawne, automatyczne wywołania mają miejsce tylko w sytuacji, gdy wynik operacji jest używany jako wartość logiczna, ale nie w sytuacji, gdy wynik jest traktowany jako wartość całkowitoliczbowa 0 lub 1. FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::OWNS_LOCK Sprawdza, czy obiekt *this jest właścicielem blokady muteksu.

Deklaracja bool owns_lock() const noexcept;

Zwracana wartość Zwraca wartość true, jeśli obiekt *this jest właścicielem blokady jakiegoś muteksu; w przeciwnym razie wartość false. Zgłaszane wyjątki Brak. FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::MUTEX

Zwraca ewentualny muteks powiązany z obiektem *this. Deklaracja mutex_type* mutex() const noexcept;

Zwracana wartość Zwraca wskaźnik do ewentualnego muteksu powiązanego z obiektem *this; wartość NULL, jeśli taki muteks nie istnieje. Zgłaszane wyjątki Brak. FUNKCJA SKŁADOWA STD::UNIQUE_LOCK::RELEASE

Zwraca ewentualny muteks z obiektem *this oraz zwalnia związek pomiędzy obiektem *this a tym muteksem. Deklaracja mutex_type* release() noexcept;

540

DODATEK D Biblioteka wątków języka C++

Skutki wywołania Przerywa związek muteksu z obiektem *this, ale nie zwalnia ewentualnych blokad utrzymywanych przez ten obiekt. Zwracana wartość Zwraca wskaźnik do ewentualnego muteksu powiązanego z obiektem *this przed tym wywołaniem; wartość NULL, jeśli taki muteks nie istnieje. Warunki końcowe this->mutex()==NULL, this->owns_lock()==false.

Zgłaszane wyjątki Brak. UWAGA. Jeśli przed wywołaniem tej funkcji wywołanie this->owns_lock() zwróciłoby wartość true, za odblokowanie odpowiedniego muteksu odpowiada wątek wywołujący funkcję release.

D.5.7.

Szablon funkcji std::lock

Szablon funkcji std::lock udostępnia mechanizmy niezbędne do jednoczesnego blokowania wielu muteksów bez ryzyka zakleszczeń związanych ze stosowaniem blokad w różnej kolejności. Deklaracja template void lock(LockableType1& m1,LockableType2& m2...);

Warunki wstępne Przekazane typy LockableType1, LockableType2, … muszą spełniać warunki stawiane typom blokowalnym (Lockable). Skutki wywołania Uzyskuje blokadę wszystkich przekazanych obiektów blokowalnych m1, m2 itd., wykonując nieokreśloną sekwencję wywołań funkcji składowych lock(), try_lock() i unlock() tych typów w sposób eliminujący ryzyko zakleszczeń. Warunki końcowe Bieżący wątek jest właścicielem blokady każdego z przekazanych obiektów blokowalnych. Zgłaszane wyjątki Ewentualne wyjątki zgłaszane są przez funkcje lock(), try_lock() i unlock(). UWAGA. Jeśli jakiś wyjątek jest propagowany poza wywołanie funkcji std::lock, funkcję unlock() należy wywołać dla każdego obiektu m1, m2 itd., dla którego uzyskano blokadę za pomocą funkcji lock() lub try_lock().

D.5.

D.5.8.

Nagłówek

541

Szablon funkcji std::try_lock

Szablon funkcji std::try_lock umożliwia podejmowanie prób blokowania zbioru obiektów blokowanych w jednym kroku, tak aby albo wszystkie te obiekty zostały zablokowane, albo nie został zablokowany żaden z nich. Deklaracja template int try_lock(LockableType1& m1,LockableType2& m2...);

Warunki wstępne Przekazane typy LockableType1, LockableType2, … muszą spełniać warunki stawiane typom blokowalnym (Lockable). Skutki wywołania Próbuje uzyskać blokadę wszystkich przekazanych obiektów blokowalnych m1, m2 itd., wywołując dla każdego z tych obiektów funkcję try_lock(). Jeśli wywołanie funkcji try_lock() zwraca wartość false lub zgłasza jakiś wyjątek, blokady już uzyskane są zwalniane za pomocą wywołań funkcji unlock() dla odpowiednich obiektów blokowalnych. Zwracana wartość Zwraca -1, jeśli udało się uzyskać wszystkie blokady (jeśli wszystkie wywołania funkcji try_lock() zwróciły wartość true); w przeciwnym razie zwraca indeks (liczony od zera) obiektu, dla którego wywołanie funkcji try_lock() zwróciło wartość false. Warunki końcowe Jeśli funkcja zwraca wartość -1, bieżący wątek jest właścicielem blokad wszystkich przekazanych obiektów blokowalnych. W przeciwnym razie wszystkie blokady uzyskane przez wywołanie tej funkcji są zwalniane. Zgłaszane wyjątki Ewentualne wyjątki zgłaszane są przez wywołania funkcji try_lock(). UWAGA. Jeśli jakiś wyjątek jest propagowany poza wywołanie funkcji std::try_lock, funkcję unlock() należy wywołać dla każdego obiektu m1, m2 itd., dla którego uzyskano blokadę za pomocą funkcji try_lock().

D.5.9.

Klasa std::once_flag

Instancje klasy std::once_flag są używane łącznie z funkcją std::call_once do zagwarantowania, że określona funkcja jest wywoływana dokładnie raz, nawet jeśli wiele wątków próbuje jednocześnie wywołać tę funkcję. Instancje klasy std::once_flag nie mogą być konstruowane poprzez kopiowanie (CopyConstructible), przypisywane poprzez kopiowanie (CopyAssignable), konstruowane poprzez przenoszenie (MoveConstructible) ani przypisywane poprzez przenoszenie (MoveAssignable). Definicja klasy struct once_flag { constexpr once_flag() noexcept;

542

DODATEK D Biblioteka wątków języka C++ once_flag(once_flag const& ) = delete; once_flag& operator=(once_flag const& ) = delete; };

KONSTRUKTOR DOMYŚLNY KLASY STD::ONCE_FLAG Konstruktor domyślny klasy std::once_flag tworzy nową instancję tego typu w stanie

określającym, że powiązana funkcja nie została wywołana. Deklaracja constexpr once_flag() noexcept;

Skutki wywołania Konstruuje nową instancję klasy std::once_flag w stanie określającym, że powiązana funkcja nie została wywołana. Ponieważ jest to konstruktor constexpr, instancja ze statycznym czasem trwania jest konstruowana na etapie inicjalizacji statycznej — takie rozwiązanie eliminuje ryzyko sytuacji wyścigu i problemów związanych z porządkiem inicjalizacji danych. D.5.10. Szablon funkcji std::call_once

Funkcja std::call_once jest używana łącznie z instancją klasy std::once_flag do zagwarantowania, że określona funkcja jest wywoływana dokładnie raz, nawet jeśli wiele wątków próbuje jednocześnie wywołać tę funkcję. Deklaracja template void call_once(std::once_flag& flag,Callable func,Args args...);

Warunki wstępne Wyrażenie INVOKE(func,args) jest poprawne dla przekazanych wartości parametrów func i args. Typ Callable i wszystkie składowe parametru Args mogą być konstruowane poprzez przenoszenie (MoveConstructible). Skutki wywołania Wywołania funkcji std::call_once dla tego samego obiektu klasy std::once_flag są szeregowane. Jeśli dla tego samego obiektu std::once_flag nie wywołano wcześniej skutecznie funkcji std::call_once, argument func (lub jego kopia) jest wywoływany tak jak w przypadku wyrażenia INVOKE(func,args), natomiast wywołanie funkcji std::call_once jest skuteczne tylko w sytuacji, gdy wywołanie funkcji func zwraca sterowanie bez zgłaszania wyjątków. Ewentualny wyjątek jest propagowany do kodu wywołującego. Jeśli wcześniej miało miejsce skuteczne wywołanie funkcji std::call_once dla tego samego obiektu klasy std::once_flag, wywołanie funkcji std::call_once zwraca sterowanie bez wywoływania funkcji func. Synchronizacja Efektywne wywoływanie funkcji std::call_once dla obiektu klasy std::once_flag musi się zakończyć przed wszystkimi kolejnymi wywołaniami funkcji std::call_once dla tego samego obiektu klasy std::once_flag.

D.6.

Nagłówek

543

Zgłaszane wyjątki Zgłasza wyjątek typu std::system_error, jeśli nie jest możliwe skuteczne wykonanie tej funkcji, lub ewentualny wyjątek propagowany przez wywołanie funkcji func.

D.6.

Nagłówek Nagłówek oferuje obsługę działań na liczbach wymiernych w czasie kompilacji. Zawartość nagłówka namespace std { template class ratio; // działania na liczbach wymiernych template using ratio_add = patrz opis; template using ratio_subtract = patrz opis; template using ratio_multiply = patrz opis; template using ratio_divide = patrz opis; // porównywanie liczb wymiernych template struct ratio_equal; template struct ratio_not_equal; template struct ratio_less; template struct ratio_less_equal; template struct ratio_greater; template struct ratio_greater_equal; typedef typedef typedef typedef typedef typedef typedef typedef typedef typedef typedef

ratio atto; ratio femto; ratio pico; ratio nano; ratio micro; ratio milli; ratio centi; ratio deci; ratio deca; ratio hecto; ratio kilo;

544

DODATEK D Biblioteka wątków języka C++ typedef typedef typedef typedef typedef

ratio mega; ratio giga; ratio tera; ratio peta; ratio exa;

}

D.6.1.

Szablon klasy std::ratio

Szablon klasy std::ratio udostępnia mechanizm obsługujący działania arytmetyczne w czasie kompilacji kodu źródłowego na takich liczbach wymiernych jak pół (std::ratio), dwie trzecie (std::ratio) czy piętnaście czterdziestych trzecich (std::ratio). Szablon jest używany w bibliotece standardowej języka C++ do określania okresów na potrzeby konkretnych typów szablonu klasy std::chrono::duration. Definicja klasy template class ratio { public: typedef ratio type; static constexpr intmax_t num= patrz opis poniżej; static constexpr intmax_t den= patrz opis poniżej; };

Wymagania D musi być różne od zera. Opis Parametry num i den reprezentują licznik i mianownik ułamka N/D sprowadzonego do najprostszej postaci. Parametr den zawsze jest wartością dodatnią. Jeśli N i D mają ten sam znak, parametr num jest liczbą dodatnią; w przeciwnym razie num jest liczbą ujemną. Przykłady ratio::num == 2 ratio::den == 3 ratio::num == -2 ratio::den == 3

D.6.2.

Alias szablonu std::ratio_add

Alias szablonu std::ratio_add udostępnia mechanizm dodawania dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja template using ratio_add = std::ratio;

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio.

D.6.

Nagłówek

545

Skutki wywołania Typ ratio_add zdefiniowano jako alias konkretnej klasy szablonu std::ratio reprezentującej sumę ułamków, które z kolei są reprezentowane przez parametry R1 i R2 (o ile można obliczyć tę sumę bez przepełnienia). Jeśli próba obliczenia wyniku prowadzi do przepełnienia, program nie może być poprawnie skompilowany. W przypadku braku przepełnienia arytmetycznego klasa std::ratio_add powinna reprezentować takie same wartości num i den jak klasa std::ratio. Przykłady std::ratio_add::num == 11 std::ratio_add::den == 15 std::ratio_add::num == 3 std::ratio_add::den == 2

D.6.3.

Alias szablonu std::ratio_subtract

Alias szablonu std::ratio_add udostępnia mechanizm odejmowania dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja template using ratio_subtract = std::ratio;

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. Skutki wywołania Typ ratio_subtract zdefiniowano jako alias konkretnej klasy szablonu std::ratio reprezentującej różnicę ułamków, które z kolei są reprezentowane przez parametry R1 i R2 (o ile można obliczyć tę różnicę bez przepełnienia). Jeśli próba obliczenia wyniku prowadzi do przepełnienia, program nie może być poprawnie skompilowany. W przypadku braku przepełnienia arytmetycznego klasa std::ratio_subtract powinna reprezentować takie same wartości num i den jak klasa std::ratio. Przykłady std::ratio_subtract::num == 2 std::ratio_subtract::den == 15 std::ratio_subtract::num == -5 std::ratio_subtract::den == 6

D.6.4.

Alias szablonu std::ratio_multiply

Alias szablonu std::ratio_multiply udostępnia mechanizm mnożenia dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja template using ratio_multiply = std::ratio;

546

DODATEK D Biblioteka wątków języka C++

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. Skutki wywołania Typ ratio_multiply zdefiniowano jako alias konkretnej klasy szablonu std::ratio reprezentującej iloczyn ułamków, które z kolei są reprezentowane przez parametry R1 i R2 (o ile można obliczyć ten iloczyn bez przepełnienia). Jeśli próba obliczenia wyniku prowadzi do przepełnienia, program nie może być poprawnie skompilowany. W przypadku braku przepełnienia arytmetycznego klasa std::ratio_multiply powinna reprezentować takie same wartości num i den jak klasa std::ratio. Przykłady std::ratio_multiply::num == 2 std::ratio_multiply::den == 15 std::ratio_multiply::num == 5 std::ratio_multiply::den == 7

D.6.5.

Alias szablonu std::ratio_divide

Alias szablonu std::ratio_divide udostępnia mechanizm dzielenia dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja template using ratio_divide = std::ratio;

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. Skutki wywołania Typ ratio_divide zdefiniowano jako alias konkretnej klasy szablonu std::ratio reprezentującej iloraz ułamków, które z kolei są reprezentowane przez parametry R1 i R2 (o ile można obliczyć ten iloraz bez przepełnienia). Jeśli próba obliczenia wyniku prowadzi do przepełnienia, program nie może być poprawnie skompilowany. W przypadku braku przepełnienia arytmetycznego klasa std::ratio_divide powinna reprezentować takie same wartości num i den jak klasa std::ratio. Przykłady std::ratio_divide::num == 5 std::ratio_divide::den == 6 std::ratio_divide::num == 7 std::ratio_divide::den == 45

D.6.6.

Szablon klasy std::ratio_equal

Szablon klasy std::ratio_equal udostępnia mechanizm porównywania dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych.

D.6.

547

Nagłówek

Definicja klasy template class ratio_equal: public std::integral_constant< bool,(R1::num == R2::num) && (R1::den == R2::den)> {};

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. Przykłady std::ratio_equal::value

== == == ==

true false false true

Szablon klasy std::ratio_not_equal

Szablon klasy std::ratio_not_equal udostępnia mechanizm sprawdzania nierówności dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja klasy template class ratio_not_equal: public std::integral_constant {};

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. Przykłady std::ratio_not_equal::value

== == == ==

false true true false

Szablon klasy std::ratio_less

Szablon klasy std::ratio_less udostępnia mechanizm porównywania dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja klasy template class ratio_less: public std::integral_constant {};

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. Skutki wywołania Szablon std::ratio_less dziedziczy po szablonie std::integral_constant, gdzie wartość reprezentuje wyrażenie R1::num*R2::den) < (R2::num*R1::den). Jeśli to możliwe, implementacje tego

548

DODATEK D Biblioteka wątków języka C++

szablonu powinny używać metod obliczania wyniku, które nie będą powodowały przepełnień. W razie wystąpienia przepełnienia program nie może zostać poprawnie skompilowany. Przykłady std::ratio_less::value == false std::ratio_less::value == true std::ratio_less< std::ratio, std::ratio >::value == true std::ratio_less< std::ratio, std::ratio >::value == false

D.6.9.

Szablon klasy std::ratio_greater

Szablon klasy std::ratio_greater udostępnia mechanizm porównywania dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja klasy template class ratio_greater: public std::integral_constant {};

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. D.6.10. Szablon klasy std::ratio_less_equal

Szablon klasy std::ratio_less_equal udostępnia mechanizm porównywania dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja klasy template class ratio_less_equal: public std::integral_constant {};

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio. D.6.11. Szablon klasy std::ratio_greater_equal

Szablon klasy std::ratio_greater_equal udostępnia mechanizm porównywania dwóch wartości typu std::ratio w czasie kompilacji przy użyciu działań na liczbach wymiernych. Definicja klasy template class ratio_greater_equal: public std::integral_constant {};

Warunki wstępne Klasy R1 i R2 muszą być konkretnymi typami szablonu klasy std::ratio.

D.7.

D.7.

Nagłówek

549

Nagłówek Nagłówek udostępnia mechanizmy umożliwiające zarządzanie wątkami i identyfikację tych wątków. Nagłówek oferuje też funkcje potrzebne do usypiania bieżących wątków. Zawartość nagłówka namespace std { class thread; namespace this_thread { thread::id get_id() noexcept; void yield() noexcept; template void sleep_for( std::chrono::duration sleep_duration); template void sleep_until( std::chrono::time_point wake_time); } }

D.7.1.

Klasa std::thread

Klasa std::thread służy do zarządzania pojedynczym wątkiem programu — udostępnia elementy niezbędne do uruchomienia nowego wątku oraz oczekiwania na zakończenie wykonywania wątku. Definiuje też mechanizmy identyfikacji wątków oraz funkcje umożliwiające zarządzanie wątkami programu. Definicja klasy class thread { public: // Typy class id; typedef zdefiniowany-przez-implementację native_handle_type; // opcjonalny // Konstruktor i destruktor thread() noexcept; ~thread(); template explicit thread(Callable&& func,Args&&... args); // Kopiowanie i przenoszenie thread(thread const& other) = delete; thread(thread&& other) noexcept; thread& operator=(thread const& other) = delete; thread& operator=(thread&& other) noexcept;

550

DODATEK D Biblioteka wątków języka C++

void swap(thread& other) noexcept; void join(); void detach(); bool joinable() const noexcept; id get_id() const noexcept; native_handle_type native_handle(); static unsigned hardware_concurrency() noexcept; }; void swap(thread& lhs,thread& rhs);

KLASA STD::THREAD::ID Instancja klasy std::thread::id identyfikuje konkretny wątek programu.

Definicja klasy class thread::id { public: id() noexcept; }; bool bool bool bool bool bool

operator==(thread::id x, thread::id y) noexcept; operator!=(thread::id x, thread::id y) noexcept; operator=(thread::id x, thread::id y) noexcept;

template basic_ostream& operatorjoinable() zwraca wartość true. Skutki wywołania Odłącza wątek programu powiązany z obiektem *this. Warunki końcowe this->get_id()==id(), this->joinable()==false

Wątek programu, który przed wywołaniem tej funkcji był powiązany z obiektem *this, jest teraz odłączony, zatem nie ma żadnego powiązanego obiektu klasy std::thread.

558

DODATEK D Biblioteka wątków języka C++

Zgłaszane wyjątki Zgłasza wyjątek typu std::system_error, jeśli funkcja nie może zrealizować zadania lub jeśli w momencie wywołania funkcja this->joinable() zwraca wartość false. FUNKCJA SKŁADOWA STD::THREAD::GET_ID Zwraca wartość typu std::thread::id, która identyfikuje wątek programu powiązany z obiektem *this.

Deklaracja thread::id get_id() const noexcept;

Zwracana wartość Jeśli obiekt *this jest powiązany z wątkiem programu, funkcja zwraca instancję klasy std::thread::id identyfikującą ten wątek. W przeciwnym razie funkcja zwraca domyślnie skonstruowany obiekt klasy std::thread::id. Zgłaszane wyjątki Brak. STATYCZNA FUNKCJA SKŁADOWA STD::THREAD::HARDWARE_CONCURRENCY

Zwraca wskazówkę dotyczącą liczby wątków, które mogą być współbieżnie wykonywane na danym sprzęcie. Deklaracja unsigned hardware_concurrency() noexcept;

Zwracana wartość Zwraca liczbę wątków, które mogą być współbieżnie wykonywane na danym komputerze. Zwracana wartość może odpowiadać na przykład liczbie procesorów w danym systemie. Jeśli niezbędne informacje są niedostępne lub niezdefiniowane, funkcja zwraca wartość 0. Zgłaszane wyjątki Brak. D.7.2.

Przestrzeń nazw std::this_thread

Funkcje należące do przestrzeni nazw std::this_thread operują na wątku wywołującym. FUNKCJA NIESKŁADOWA STD::THIS_THREAD::GET_ID Zwraca wartość typu std::thread::id identyfikującą bieżący wątek programu.

Deklaracja thread::id get_id() noexcept;

Zwracana wartość Zwraca instancję klasy std::thread::id, która identyfikuje bieżący wątek. Zgłaszane wyjątki Brak.

D.7.

Nagłówek

559

FUNKCJA NIESKŁADOWA STD::THIS_THREAD::YIELD

Funkcja służy do informowania biblioteki o tym, że wątek, który ją wywołał, nie musi działać w momencie tego wywołania. Funkcja jest często używana w pętlach, ponieważ pozwala ograniczyć nadmierne zużycie czasu procesora. Deklaracja void yield() noexcept;

Skutki wywołania Pozwala bibliotece zaplanować inne zadania zamiast tracić czas na wykonywanie bieżącego wątku. Zgłaszane wyjątki Brak. FUNKCJA NIESKŁADOWA STD::THIS_THREAD::SLEEP_FOR

Wstrzymuje wykonywanie bieżącego wątku przez określony czas. Deklaracja template void sleep_for(std::chrono::duration const& relative_time);

Skutki wywołania Blokuje wykonywanie bieżącego wątku do momentu upłynięcia czasu przekazanego za pośrednictwem parametru relative_time. UWAGA. Wątek może być blokowany dłużej niż przez wskazany okres. Jeśli to możliwe, czas, który upłynął od początku blokady, należy mierzyć za pomocą stabilnego zegara.

Zgłaszane wyjątki Brak. FUNKCJA NIESKŁADOWA STD::THIS_THREAD::SLEEP_UNTIL

Wstrzymuje wykonywanie bieżącego wątku do osiągnięcia wskazanego punktu w czasie. Deklaracja template void sleep_until( std::chrono::time_point const& absolute_time);

Skutki wywołania Blokuje wykonywanie bieżącego wątku do osiągnięcia godziny reprezentowanej przez parametr absolute_time (według zegara reprezentowanego przez parametr Clock). UWAGA. Nie mamy żadnych gwarancji co do czasu blokowania wątku wywołującego tę funkcję. Wiemy tylko, że w momencie odblokowania wątku wywołanie Clock::now() zwróci godzinę nie wcześniejszą od wartości absolute_time.

Zgłaszane wyjątki Brak.

560

DODATEK D Biblioteka wątków języka C++

Materiały dodatkowe

Materiały drukowane Tom Cargill, „Exception Handling: A False Sense of Security”, C++ Report 6, nr 9 (listopad – grudzień 1994). Artykuł dostępny także pod adresem http://www.informit.com/content/images/020163371x/ supplements/Exception_Handling_Article.html. C.A.R. Hoare, Communicating Sequential Processes, Prentice Hall International, 1985, ISBN 0131532898. Książka jest dostępna pod adresem http://www.usingcsp.com/cspbook.pdf. Maged M. Michael, „Safe Memory Reclamation for Dynamic Lock-Free Objects Using Atomic Reads and Writes”, PODC ’02: Proceedings of the Twenty-first Annual Symposium on Principles of Distributed Computing (2002), ISBN 1-58113-485-1. Wniosek nr 20040107227 złożony w Biurze Patentów i Znaków Towarowych Stanów Zjednoczonych, „Method for efficient implementation of dynamic lock-free data structures with safe memory reclamation”. Herb Sutter, Wyjątkowy język C++. 47 łamigłówek, zadań programistycznych i rozwiązań (Wydawnictwa Naukowo-Techniczne, 2002), ISBN 83-204-2685-5. „The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software”, Dr. Dobb’s Journal 30, nr 3 (marzec 2005). Artykuł jest dostępny na stronie http://www.gotw.ca/publications/concurrency-ddj.htm.

Materiały dostępne w internecie Strona domowa projektu Atomic Ptr Plus Project, http://atomic-ptr-plus.sourceforge.net/. Kolekcja bibliotek Boost C++, http://www.boost.org. Obsługa standardów C++0x/C++11 w GCC, http://gcc.gnu.org/projects/cxx0x.html. C++11 — zaakceptowany standard ISO dla języka C++, http://www.research.att.com/~bs/C++0xFAQ.html. Język programowania Erlang, http://www.erlang.org/. Licencja GNU General Public License, http://www.gnu.org/licenses/gpl.html. Język programowania Haskell, http://www.haskell.org/. Dokument IBM Statement of Non-Assertion of Named Patents Against OSS, http://www.ibm.com/ibm/licensing/ patents/pledgedpatents.pdf. Biblioteka open source Intel Threading Building Blocks (Intel TBB), http://threadingbuildingblocks.org/.

562

Materiały dodatkowe

Implementacja standardowej biblioteki wątków języka C++ just::thread, http://www.stdthread.co.uk. Forum poświęcone protokołowi Message Passing Interface, http://www.mpi-forum.org/. „Multithreading API for C++0X — A Layered Approach”, C++ Standards Committee Paper N2094, http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2006/n2094.html. Projekt OpenMP, http://www.openmp.org/. Projekt SETI@Home, http://setiathome.ssl.berkeley.edu/.

Skorowidz

A accumulate_block, 293, 294 add_to_list(), 61 add_to_reclaim_list(), 239 aktywne oczekiwanie, 261 algorytm sortowania szybkiego, 330 analiza kodu, 357 próba szczegółowego wyjaśnienia komuś, 358 przeglądanie kodu, 357 pytania dotyczące przeglądanego kodu, 358 asynchroniczne zadanie, 104

B bariera, 316 bariery pamięci, Patrz ogrodzenia biblioteki języka C++, 29 ACE, 29 Boost, 29 Boost Thread Library, 30 C++ Thread Library, 30 efektywność klas, 30 mechanizm przyszłości, 103 okres, 117 przyszłości unikatowe, 103 przyszłości współdzielone, 103 punkt w czasie, 118 RAII, 29 standardowa, 31 typy wywoływalne, 36

wolne funkcje, 150 zegar, 115 blokady wirujące, 221 błądzenie, Patrz uwięzienie Boost Thread Library, 30 buffer, 44

C C++ Thread Library, 30 clear(), 138, 141 compare_exchange_strong(), 140, 144, 236, 248, 263 compare_exchange_weak(), 140, 144, 225, 247

D data.push(), 188 data_cond.notify_one(), 191 definicja klasy stosu, 68 delete_nodes_with_no_hazards(), 238, 240 detach(), 38, 42 dispatch(), 405 do_something(), 63 do_sort(), 275, 333 done(), 110 Double-Checked Locking, 85 drzewo binarne, 209 dyndający wskaźnik, 226

564

Skorowidz

E empty(), 64, 102, 188 exchange(), 140, 143

F fałszywe współdzielenie, 284, 287 fetch_add(), 140, 146, 249 fetch_and(), 147 fetch_or(), 140, 147 fetch_sub(), 146, 176 fetch_xor(), 147 find_entry_for(), 212 find_first_if(), 217 for_each(), 213, 216 frameworki aplikacji, 29 MFC, 29 free_external_counter(), 260 front(), 98 funkcja lambda, 122 funkcja początkowa, 33 funkcje accumulate_block, 293, 294 add_to_list(), 61 add_to_reclaim_list(), 239 clear(), 138, 141 compare_exchange_strong(), 140, 144, 236, 248, 263 compare_exchange_weak(), 140, 144, 225, 247 constexpr, 381 wymagania, 385 data.push(), 188 data_cond.notify_one(), 191 delete_nodes_with_no_hazards(), 238, 240 detach(), 38, 42 dispatch(), 405 do_something(), 63 do_sort(), 275, 333 domyślne, 377 dokumentacja, 378 wymuszanie generowania funkcji, 378 wymuszenie deklarowania konstruktora kopiującego, 378 wymuszenie generowania destruktora wirtualnego, 378 zmiana dostępności funkcji, 378 done(), 110 empty(), 64, 102, 188 exchange(), 140, 143 fetch_add(), 140, 146, 249

fetch_and(), 147 fetch_or(), 140, 147 fetch_sub(), 146, 176 fetch_xor(), 147 find_entry_for(), 212 find_first_if(), 217 foo(), 375 for_each(), 213, 216 free_external_counter(), 260 front(), 98 get(), 125 get_event(), 301 get_future(), 106, 114 get_hazard_pointer_for_current_thread(), 234, 236 get_id(), 52 get_lock(), 80 get_tail(), 200 getting_pin(), 130 handle(), 130, 407 head.get(), 197 hello(), 33 interrupt(), 340, 342, 349 interruptible_wait(), 343, 348 interruption_point(), 341, 344, 351 is_lock_free(), 138 joinable(), 295 lambda, 386 wyrażenie lambda, 386 z konstrukcją rozpoczynającą, 388 list_contains(), 61 load(), 140, 143 lock(), 60, 79, 80, 142 main(), 33, 36 memcmp(), 148 memcpy(), 148 my_thread, 37 my_x.do_lengthy_work(), 45 native_handle(), 32 nieskładowe, 150 notify_one(), 96 now(), 116 open_connection(), 87 parallel_accumulate(), 291, 296, 329 parallel_quick_sort(), 126, 275 początkowa, 33 pop(), 64, 188, 226, 228, 235, 245, 247, 254, 257, 261 pop_head(), 205 pop_task_from_other_thread_queue(), 339 process(), 82, 301

Skorowidz process_connections(), 110 process_data(), 81 push(), 64, 100, 191, 197, 201, 225, 229, 247, 254, 255, 261, 337 push_front(), 216 reclaim_later(), 236, 238, 239 remove_if(), 217 run(), 130 run_pending_task(), 331, 335 send_data(), 87 sequential_quick_sort(), 124 set(), 343 set_condition_variable(), 344 set_exception(), 111 set_new_tail(), 264 share(), 114 size(), 64 sleep_for(), 120 sleep_until(), 120 some_function, 47 spawn_task(), 125 splice(), 124 square_root(), 111 std::accumulate, 49 std::async(), 104, 125, 273, 281, 297 std::atomic_compare_exchange_strong, 469 std::atomic_compare_exchange_strong_explicit, 469 std::atomic_compare_exchange_weak, 470 std::atomic_compare_exchange_weak_explicit, 471 std::atomic_exchange, 467 std::atomic_exchange_explicit, 467 std::atomic_fetch_add, 476, 486 std::atomic_fetch_add_explicit, 476, 486 std::atomic_fetch_and, 478 std::atomic_fetch_and_explicit, 479 std::atomic_fetch_or, 479 std::atomic_fetch_or_explicit, 480 std::atomic_fetch_sub, 477, 487 std::atomic_fetch_sub_explicit, 478, 487 std::atomic_fetch_xor, 480 std::atomic_fetch_xor_explicit, 481 std::atomic_flag_clear, 459 std::atomic_flag_clear_explicit, 460 std::atomic_flag_test_and_set, 459 std::atomic_flag_test_and_set_explicit, 459 std::atomic_flag::clear, 459 std::atomic_flag::test_and_set, 458 std::atomic_init, 463 std::atomic_is_lock_free, 464

565 std::atomic_load, 465 std::atomic_load_explicit, 465 std::atomic_signal_fence(), 457 std::atomic_store, 466 std::atomic_store_explicit, 466 std::atomic_thread_fence(), 456 std::atomic::fetch_add, 486 std::atomic::fetch_sub, 487 std::atomic::fetch_add, 476 std::atomic::fetch_and, 478 std::atomic::fetch_or, 479 std::atomic::fetch_sub, 477 std::atomic::fetch_xor, 480 std::atomic::compare_exchange_strong, 468 std::atomic::compare_exchange_weak, 469 std::atomic::exchange, 467 std::atomic::is_lock_free, 464 std::atomic::load, 464 std::atomic::store, 466 std::bind(), 45, 333 std::call_once, 86 std::chrono::duration_cast, 428 std::chrono::duration::count, 423 std::chrono::duration::max, 426 std::chrono::duration::min, 426 std::chrono::duration::zero, 425 std::chrono::steady_clock::now, 434 std::chrono::system_clock::from_time_t, 433 std::chrono::system_clock::now, 432 std::chrono::system_clock::to_time_t, 433 std::chrono::time_point::max, 431 std::chrono::time_point::min, 431 std::chrono::time_point::time_since_epoch, 430 std::condition_variable_any::notify_all, 446 std::condition_variable_any::notify_one, 446 std::condition_variable_any::wait, 447 std::condition_variable_any::wait_for, 448 std::condition_variable_any::wait_until, 450 std::condition_variable::notify_all, 437 std::condition_variable::notify_one, 437 std::condition_variable::wait, 344, 438 std::condition_variable::wait_for, 439 std::condition_variable::wait_until, 441 std::copy_exception(), 112 std::current_exception(), 112 std::find, 306

566

Skorowidz

funkcje std::for_each, 51, 304 std::future::get, 494 std::future::share, 492 std::future::valid, 492 std::future::wait, 493 std::future::wait_for, 493 std::future::wait_until, 494 std::kill_dependency(), 174 std::lock(), 72 std::move(), 46, 124, 329, 333 std::mutex::lock, 516 std::mutex::try_lock, 516 std::mutex::unlock, 517 std::notify_all_at_thread_exit, 443 std::packaged_task::get_future, 504 std::packaged_task::make_ready_at_thread_exit, 506 std::packaged_task::reset, 505 std::packaged_task::swap, 504 std::packaged_task::valid, 505 std::partial_sum, 312 std::partition(), 124 std::promise::get_future, 510 std::promise::set_exception, 512 std::promise::set_exception_at_thread_exit, 512 std::promise::set_value, 510 std::promise::set_value_at_thread_exit, 511 std::promise::swap, 509 std::recursive_mutex::lock, 519 std::recursive_mutex::try_lock, 519 std::recursive_mutex::unlock, 519 std::recursive_timed_mutex::lock, 526 std::recursive_timed_mutex::try_lock, 526 std::recursive_timed_mutex::try_lock_for, 526 std::recursive_timed_mutex::try_lock_until, 527 std::recursive_timed_mutex::unlock, 528 std::ref, 45 std::shared_future::get, 500 std::shared_future::valid, 498 std::shared_future::wait, 498 std::shared_future::wait_for, 499 std::shared_future::wait_until, 499 std::terminate(), 37, 349 std::this_thread::get_id, 52, 558 std::this_thread::sleep_for, 559 std::this_thread::sleep_until, 559 std::this_thread::yield, 559

std::thread::detach, 557 std::thread::get_id, 558 std::thread::hardware_concurrency, 49, 273, 276, 280, 324, 558 std::thread::join, 557 std::thread::joinable, 556 std::thread::native_handle, 553 std::thread::swap, 556 std::timed_mutex::lock, 521 std::timed_mutex::try_lock, 522 std::timed_mutex::try_lock_for, 522 std::timed_mutex::try_lock_until, 523 std::timed_mutex::unlock, 524 std::unique_lock::lock, 536 std::unique_lock::mutex, 539 std::unique_lock::operator, 539 std::unique_lock::owns_lock, 539 std::unique_lock::release, 539 std::unique_lock::swap, 535, 536 std::unique_lock::try_lock, 536 std::unique_lock::try_lock_for, 537 std::unique_lock::try_lock_until, 538 std::unique_lock::unlock, 537 store(), 140, 143, 177 submit(), 326, 329, 335 swap(), 64 test_and_set(), 138, 141 thread_a(), 76 thread_b(), 76 time_since_epoch(), 118 top(), 64 try_lock(), 76, 80 try_lock_for(), 120 try_lock_until(), 120 try_pop(), 98, 191, 197, 200, 202, 337 try_reclaim(), 230 try_steal(), 337 trywialne, 379 unlock(), 60, 80, 348 update_data_for_widget, 44 wait(), 96, 102, 318, 344, 348, 403 wait_and_dispatch(), 405, 407 wait_and_pop(), 98, 191, 202, 204 wait_for(), 115, 120, 344 wait_for_data(), 205 wait_until(), 115 worker_thread(), 326, 335 zegar::now(), 116

567

Skorowidz

G get(), 125 get_event(), 301 get_future(), 106, 114 get_hazard_pointer_for_current_thread(), 234, 236 get_id(), 52 get_lock(), 80 get_tail(), 200 getting_pin(), 130

H handle(), 130, 407 head.get(), 197 hello(), 33

szablony zmiennoargumentowe, 391 paczka parametrów, 392 rozwinięcie paczki, 392 typy definiowane przez użytkownika, 382 inicjalizacja statyczna, 384 warstwy abstrakcji, 30 wyrażenia stałe, 381 zastosowania, 381 wyrażenie lambda, 37 wyścig danych, 58 zmienne lokalne wątków, 396 join(), 39 joinable(), 295

K I

identyfikowanie wątków, 52 identyfikatory wątków, 52 interrupt(), 340, 342, 349 interruptible_wait(), 343, 348 interruption_point(), 341, 344, 351 is_lock_free(), 138 iteratory jednokrotnego przebiegu, 52 iteratory postępujące, 52

J język C++, 19 automatyczne określanie typu zmiennej, 395 biblioteka operacji atomowych, 31 biblioteka standardowa, 31 biblioteka wątków, 30 biblioteki, 29 efektywność klas, 30 frameworki aplikacji, 29 mechanizm deklarowania funkcji jako usuniętej, 376 mechanizm przyszłości, 103 miejsce w pamięci, 134 model pamięci, 133 muteks, 60 obiekty, 134 obsługa współbieżności, 28, 30 operacje atomowe, 137 porządek modyfikacji, 136 RAII, 29 referencje do r-wartości, 371 semantyka przenoszenia danych, 372

klasy dispatcher, 404 receiver, 403 scoped_thread, 48 sender, 402 std::thread, 36 std::atomic_flag, 457 std::chrono::high_resolution_clock, 116 std::chrono::steady_clock, 116, 433 std::chrono::system_clock, 116, 431 std::condition_variable, 95, 436 std::condition_variable_any, 95, 444 std::future, 103 std::mutex, 60, 515 std::once_flag, 541 std::recursive_mutex, 90, 517 std::recursive_timed_mutex, 524 std::shared_future, 103 std::thread, 549 std::thread::id, 550 std::timed_mutex, 520 thread_guard, 48 thread_pool, 331 komunikacja procesów sekwencyjnych, 126, 127 maszyna stanów, 127, 128 model aktorów, 130 model maszyny stanów, 128 konstruktor domyślny, 52 konstruktor przenoszący, 45 kontenery std::map, 207 std::multimap, 207 std::queue, 97 std::stack, 64, 66 std::unordered_map, 207 std::unordered_multimap, 207

568

Skorowidz

L leniwa inicjalizacja, 85 linie pamięci podręcznej, 284 list_contains(), 61 load(), 140, 143 lock(), 60, 79, 80, 142 l-wartość, 80

M main(), 33, 36 mechanizm przyszłości, 52, 102, 103 asynchroniczne zadanie, 104 obietnice, 109 oczekiwanie na wiele wątków, 112 przyszłości unikatowe, 103 przyszłości współdzielone, 103 wiązanie zadania z przyszłością, 106 wynik obliczeń wykonywanych w tle, 103 wyścig danych, 112 zapisywanie wyjątku, 111 memcmp(), 148 memcpy(), 148 model aktorów, 130 model pamięci języka C++, 133 analiza podstawowych cech strukturalnych, 134 mechanizmy przetwarzania współbieżnego, 134 miejsce w pamięci, 134 modele porządkowania spójnego niesekwencyjnie, 159 niezdefiniowane zachowanie, 136 obiekty, 134 ogrodzenia, 178 operacje atomowe, 136 podział struktury, 135 porządek modyfikacji, 136 porządkowania pamięci, 155 porządkowanie poprzez wzajemne wykluczanie, 155, 166 operacje uzyskiwania, 166 operacje zwalniania, 166 porządkowanie spójne sekwencyjnie, 155, 156 porządkowanie złagodzone, 155, 160 wyjaśnienie porządkowania, 164 relacja poprzedzania, 152, 154 poprzedzanie według zależności, 173 przechodniość, 170 relacja wprowadzania zależności, 173

relacja synchronizacji, 152 sekwencja zwalniania, 175 wyścig danych, 136 muteks, 59, 60, 220 blokowanie, 60 blokowanie rekurencyjne, 90 czytelników-pisarzy, 88 elastyczne blokowanie, 79 hierarchiczny, 77 l-wartość, 80 niezdefiniowane zachowanie, 90 ochrona listy, 61 odblokowywanie, 60 przenoszenie własności, 80 rekurencyjny, 90 r-wartość, 80 stosowanie w języku C++, 60 szczegółowość blokady, 82 wirujący, 142 zakleszczenie, 71 my_thread, 37 my_thread.detach(), 39 my_thread.join(), 39 my_x.do_lengthy_work(), 45

N nadsubskrypcja, 51, 280, 285 native_handle(), 32 niezdefiniowane zachowanie, 58, 86, 90, 136 niezmienniki, 56, 355 niskie współzawodnictwo, 282 notify_one(), 96 now(), 116

O obiekty przyszłości, 122 ochrona współdzielonych danych, 56 blokowanie rekurencyjne, 90 definicja klasy kolejki, 100, 190 definicja klasy stosu, 68, 187 Double-Checked Locking, 85 elastyczne blokowanie muteksu, 79 hierarchia blokad, 75 implementacja listy z obsługą iteracji, 214 implementacja tablicy wyszukiwania, 210 jednowątkowa implementacja kolejki, 195 kolejka z mechanizmami blokowania i oczekiwania, 203 kolejka ze szczegółowymi blokadami, 198

569

Skorowidz kolejka ze sztucznym węzłem, 196 leniwa inicjalizacja, 85 lista jednokierunkowa, 194 metody zapewniania bezpieczeństwa, 185 muteks, 59, 60 muteks czytelników-pisarzy, 88 niezdefiniowane zachowanie, 58 niezmienniki, 56 ochrona listy, 61 pamięć transakcyjna, 59 programowanie bez blokad, 59 przekazywanie referencji, 66 przenoszenie własności muteksu, 80 stosowanie konstruktora, 67 struktury danych bez blokad, 220 sytuacja wyścigu, 58 szczegółowość blokady, 82 szeregowanie, 185 tablica wyszukiwania, 207 unikanie zakleszczeń, 71 wykrywanie sytuacji wyścigu, 63 zakleszczenie, 71 zwracanie wskaźnika, 67 ogrodzenia, 178 open_connection(), 87 operacje atomowe, 136, 137 funkcje nieskładowe, 150 główny szablon, 147 modele porządkowania spójnego niesekwencyjnie, 159 ogrodzenia, 178 operacje dostępne dla typów atomowych, 149 operacje ładowania (odczytu), 140, 143 operacje odczyt-modyfikacja-zapis, 140, 141, 143, operacje porównania-wymiany, 144 operacje wymiany i dodania, 146 operacje zapisu, 140, 141, 143 porównywanie bitowe, 148 porządkowanie pamięci, 155 porządkowanie poprzez wzajemne wykluczanie, 155, 166 operacje uzyskiwania, 166 operacje zwalniania, 166 porządkowanie spójne sekwencyjnie, 155, 156 porządkowanie złagodzone, 155, 160 wyjaśnienie porządkowania, 164 pozorny błąd, 144 relacja poprzedzania, 152, 154 poprzedzanie według zależności, 173 przechodniość, 170 relacja wprowadzania zależności, 173

relacja synchronizacji, 152 sekwencja zwalniania, 175 rozkazy porównywania i wymiany podwójnego słowa, 149 sekwencja zwalniania zasobów, 147 standardowe definicje typów atomowych, 139 standardowe typy atomowe, 138 stos bez blokad, 250 tworzenie typów niestandardowych, 147 wolne funkcje, 150 operator przypisania z przenoszeniem, 45

P pamięć transakcyjna, 59 paradygmat CSP, Patrz komunikacja procesów sekwencyjnych parallel_accumulate(), 291, 296, 329 parallel_quick_sort(), 126, 275 ping-pong buforów, 282 podział zagadnień, 25 pop(), 64, 188, 226, 228, 235, 245, 247, 254, 257, 261 pop_head(), 205 pop_task_from_other_thread_queue(), 339 porównywanie bitowe, 148 potok, 278 pozorne budzenie, 97 prawo Amdahla, 299 problem ABA, 266 process(), 82, 301 process_connections(), 110 process_data(), 81 procesy demonów, 42 programowanie bez blokad, 59 programowanie funkcyjne, 122 algorytm sortowania szybkiego, 122 funkcja lambda, 122 obiekty przyszłości, 122 rekurencyjne sortowanie, 123 równoległe sortowanie szybkie, 125 wnioskowanie typów zmiennych, 122 projektowanie uniwersalnej kolejki, 97 projektowanie współbieżnego kodu, 269 bezpieczeństwo wyjątków, 291, 293, 297 algorytmy równoległe, 291 czynniki wpływające na wydajność kodu, 279 fałszywe współdzielenie, 284 liczba procesorów, 280 nadsubskrypcja, 280, 285 niskie współzawodnictwo, 282

570

Skorowidz

projektowanie współbieżnego kodu ping–pong buforów, 282 przełączanie zadań, 285 współzawodnictwo o dane, 281 wybór najlepszego algorytmu, 281 wysokie współzawodnictwo, 282 zmiana elementu danych, 280 fałszywe współdzielenie, 287 łatwość testowania, 361 eliminacja współbieżności, 362 warunki struktury kodu, 361 mnożenie macierzy, 287 podział elementów tablicy, 287 potok, 278 praktyka, 303 bariera, 316 implementacja równoległego algorytmu wyszukiwania, 307 równoległa implementacja funkcji partial_sum, 319 równoległa wersja funkcji std::for_each, 304 równoległe obliczanie sum częściowych, 313 prawo Amdahla, 299 pula wątków, 276 sąsiedztwo danych, 287 skalowalność, 291, 298 techniki dzielenia pracy pomiędzy wątki, 270 dzielenie przed rozpoczęciem przetwarzania, 271 dzielenie sekwencji zadań, 278 dzielenie według typu zadania, 276 podział sąsiadujących fragmentów danych, 272 rekurencyjne dzielenie danych, 272, 273 równoległy algorytm sortowania szybkiego, 273 sposób izolowania zagadnień, 277 techniki przetwarzania równoległego, 301 ukrywanie opóźnień, 300 współzawodnictwo, 286 wzorce dostępu do danych, 289 przekazywanie argumentów do funkcji wątku, 43 konstruktor przenoszący, 45 kopiowane, 43 operator przypisania z przenoszeniem, 45 przenoszenie, 45 przełączanie kontekstu, 21 przełączanie zadań, 21, 22 przenoszenie własności wątku, 46, 47 przetwarzanie współbieżne, 29 bezpieczeństwo, 184

definicja klasy kolejki, 190 definicja klasy stosu, 68 implementacja listy z obsługą iteracji, 214 implementacja tablicy wyszukiwania, 210 kolejka z mechanizmami blokowania i oczekiwania, 203 kolejka ze szczegółowymi blokadami, 198 mechanizmy przyszłości, 102 międzywątkowa relacja poprzedzania, 155 niechciane blokowanie, 354 oczekiwanie na zewnętrzne dane wejściowe, 355 uwięzienie, 354 zakleszczenie, 354 oczekiwanie na zdarzenie, 94 stos bez blokad, 227, 250 synchronizacja operacji, 93 sytuacje wyścigu, 355 naruszone niezmienniki, 355 problemy związane z czasem życia, 356 wyścig danych, 355 tablica mieszająca, 209 tablica wyszukiwania, 207 techniki lokalizacji błędów, 357 analiza kodu, 357 próba szczegółowego wyjaśnienia komuś, 358 pytania dotyczące przeglądanego kodu, 358 testowanie współbieżnego kodu, 359 współdzielenie danych przez wątki, 55 wywołanie blokujące z limitem czasowym, 115 zarządzanie wątkami, 36 identyfikowanie wątków, 52 mechanizm przyszłości, 52 oczekiwanie na zakończenie wątku, 39 oczekiwanie w razie wystąpienia wyjątku, 39 odłączenie wątku, 41 przekazywanie argumentów do funkcji wątku, 43 przenoszenie własności wątku, 46 uruchamianie wątków w tle, 42 uruchamianie wątku, 36 wątki demonów, 42 wybór liczby wątków, 49 zmienna warunkowa, 95 pula wątków, 106, 276, 324 wiązanie zadania z przyszłością, 106 push(), 64, 100, 191, 197, 201, 225, 229, 247, 254, 255, 261, 337 push_front(), 216

Skorowidz

R RAII, 29 reclaim_later(), 236, 238, 239 referencje do r-wartości, 371 semantyka przenoszenia danych, 372 konstruktor kopiujący, 374 konstruktor przenoszący, 374 relacja poprzedzania, 154 poprzedzanie według zależności, 173 przechodniość, 170 relacja wprowadzania zależności, 173 relacja synchronizacji, 152 sekwencja zwalniania, 175 remove_if(), 217 Resource Acquisition Is Initialization, Patrz RAII RIAA, 40 rozkazy porównywania i wymiany podwójnego słowa, 149 równoległość, Patrz zrównoleglanie zadań równoległość danych, 26 run(), 130 run_pending_task(), 331, 335 r-wartość, 80

S sąsiedztwo danych, 287 scoped_thread, 48 sekwencja zwalniania, 175 send_data(), 87 sequential_quick_sort(), 124 set(), 343 set_condition_variable(), 344 set_exception(), 111 set_new_tail(), 264 share(), 114 size(), 64 skalowalność, 298, 369 sleep_for(), 120 sleep_until(), 120 some_function, 47 spawn_task(), 125 splice(), 124 square_root(), 111 std::accumulate, 49 std::async, 104, 125, 273, 281, 297, 513 std::atomic, 138, 140, 147, 460 std::atomic_compare_exchange_strong, 469 std::atomic_compare_exchange_strong_explicit, 469

571

std::atomic_compare_exchange_weak, 470 std::atomic_compare_exchange_weak_explicit, 471 std::atomic_exchange, 467 std::atomic_exchange_explicit, 467 std::atomic_fetch_add, 476, 486 std::atomic_fetch_add_explicit, 476, 486 std::atomic_fetch_and, 478 std::atomic_fetch_and_explicit, 479 std::atomic_fetch_or, 479 std::atomic_fetch_or_explicit, 480 std::atomic_fetch_sub, 477, 487 std::atomic_fetch_sub_explicit, 478, 487 std::atomic_fetch_xor, 480 std::atomic_fetch_xor_explicit, 481 std::atomic_flag, 138, 141, 457 std::atomic_flag_clear, 459 std::atomic_flag_clear_explicit, 460 std::atomic_flag_test_and_set, 459 std::atomic_flag_test_and_set_explicit, 459 std::atomic_flag::clear, 459 std::atomic_flag::test_and_set, 458 std::atomic_init, 463 std::atomic_is_lock_free, 464 std::atomic_load, 465 std::atomic_load_explicit, 465 std::atomic_signal_fence(), 457 std::atomic_store, 466 std::atomic_store_explicit, 466 std::atomic_thread_fence(), 456 std::atomic, 143 std::atomic, 147 std::atomic, 146 std::atomic::fetch_add, 486 std::atomic::fetch_sub, 487 std::atomic::fetch_add, 476 std::atomic::fetch_and, 478 std::atomic::fetch_or, 479 std::atomic::fetch_sub, 477 std::atomic::fetch_xor, 480 std::atomic, 147 std::atomic::compare_exchange_strong, 468 std::atomic::compare_exchange_weak, 469 std::atomic::exchange, 467 std::atomic::is_lock_free, 464 std::atomic::load, 464 std::atomic::store, 466

572 std::bind, 45, 333 std::call_once, 86, 542 std::chrono::duration, 117, 420 std::chrono::duration_cast, 428 std::chrono::duration::count, 423 std::chrono::duration::max, 426 std::chrono::duration::min, 426 std::chrono::duration::zero, 425 std::chrono::high_resolution_clock, 116 std::chrono::steady_clock, 116, 433 std::chrono::steady_clock::now, 434 std::chrono::system_clock, 116, 431 std::chrono::system_clock::from_time_t, 433 std::chrono::system_clock::now, 432 std::chrono::system_clock::to_time_t, 433 std::chrono::time_point, 118, 429 std::chrono::time_point::max, 431 std::chrono::time_point::min, 431 std::chrono::time_point::time_since_epoch, 430 std::condition_variable, 95, 436 std::condition_variable_any, 95, 444 std::condition_variable_any::notify_all, 446 std::condition_variable_any::notify_one, 446 std::condition_variable_any::wait, 447 std::condition_variable_any::wait_for, 448 std::condition_variable_any::wait_until, 450 std::condition_variable::notify_all, 437 std::condition_variable::notify_one, 437 std::condition_variable::wait, 344, 438 std::condition_variable::wait_for, 439 std::condition_variable::wait_until, 441 std::copy_exception(), 112 std::current_exception(), 112 std::find, 306 std::for_each, 51, 304 std::future, 103, 106, 109, 112, 490 std::future::get, 494 std::future::share, 492 std::future::valid, 492 std::future::wait, 493 std::future::wait_for, 493 std::future::wait_until, 494 std::kill_dependency(), 174 std::lock, 72, 540 std::lock_guard, 60, 528 std::map, 207 std::move(), 46, 124, 329, 333 std::multimap, 207 std::mutex, 60, 515 std::mutex::lock, 516 std::mutex::try_lock, 516

Skorowidz std::mutex::unlock, 517 std::notify_all_at_thread_exit, 443 std::once_flag, 86, 541 std::packaged_task, 106, 391, 501 std::packaged_task::get_future, 504 std::packaged_task::make_ready_at_thread_exit, 506 std::packaged_task::reset, 505 std::packaged_task::swap, 504 std::packaged_task::valid, 505 std::partial_sum, 312 std::partition(), 124 std::promise, 109, 507 std::promise::get_future, 510 std::promise::set_exception, 512 std::promise::set_exception_at_thread_exit, 512 std::promise::set_value, 510 std::promise::set_value_at_thread_exit, 511 std::promise::swap, 509 std::queue, 97 std::ratio, 421, 544 std::ratio_equal, 546 std::ratio_greater, 548 std::ratio_greater_equal, 548 std::ratio_less, 547 std::ratio_less_equal, 548 std::ratio_not_equal, 547 std::recursive_mutex, 90, 517 std::recursive_mutex::lock, 519 std::recursive_mutex::try_lock, 519 std::recursive_mutex::unlock, 519 std::recursive_timed_mutex, 524 std::recursive_timed_mutex::lock, 526 std::recursive_timed_mutex::try_lock, 526 std::recursive_timed_mutex::try_lock_for, 526 std::recursive_timed_mutex::try_lock_until, 527 std::recursive_timed_mutex::unlock, 528 std::ref, 45 std::shared_future, 103, 113, 495 std::shared_future::get, 500 std::shared_future::valid, 498 std::shared_future::wait, 498 std::shared_future::wait_for, 499 std::shared_future::wait_until, 499 std::stack, 64, 66 std::string, 44 std::terminate(), 37, 349 std::this_thread::get_id, 52, 558 std::this_thread::sleep_for, 120, 559 std::this_thread::sleep_until, 120, 559 std::this_thread::yield, 559

573

Skorowidz std::thread, 43, 549 std::thread::detach, 557 std::thread::get_id, 558 std::thread::hardware_concurrency, 49, 273, 276, 280, 324, 558 std::thread::id, 550 std::thread::join, 557 std::thread::joinable, 556 std::thread::native_handle, 553 std::thread::swap, 556 std::timed_mutex, 520 std::timed_mutex::lock, 521 std::timed_mutex::try_lock, 522 std::timed_mutex::try_lock_for, 522 std::timed_mutex::try_lock_until, 523 std::timed_mutex::unlock, 524 std::try_lock, 541 std::unique_lock, 79, 80, 530 std::unique_lock::lock, 536 std::unique_lock::mutex, 539 std::unique_lock::operator, 539 std::unique_lock::owns_lock, 539 std::unique_lock::release, 539 std::unique_lock::swap, 535, 536 std::unique_lock::try_lock, 536 std::unique_lock::try_lock_for, 537 std::unique_lock::try_lock_until, 538 std::unique_lock::unlock, 537 std::unordered_map, 207 std::unordered_multimap, 207 store(), 140, 143, 177 submit(), 326, 329, 335 swap(), 64 synchronizacja współbieżnych operacji, 93 definicja klasy kolejki, 100 funkcje otrzymujące limity czasowe, 121 komunikacja procesów sekwencyjnych, 126 mechanizmy przyszłości, 102 obietnice, 109 oczekiwanie na wiele wątków, 112 oczekiwanie na zdarzenie, 94 operacje atomowe, 137 porządek modyfikacji, 136 pozorne budzenie, 97 programowanie funkcyjne, 122 projektowanie uniwersalnej kolejki, 97 prosta kolejka komunikatów, 401 przekazywanie zadań pomiędzy wątkami, 107 upraszczanie kodu, 121 wiązanie zadania z przyszłością, 106

wynik obliczeń wykonywanych w tle, 103 wyścig danych, 112 wywołanie blokujące z limitem czasowym, 115 zapisywanie wyjątku na potrzeby przyszłości, 111 zmienna warunkowa, 95 sytuacja wyścigu, 58, 355 definicja klasy stosu, 68 przekazywanie referencji, 66 stosowanie konstruktora, 67 zwracanie wskaźnika, 67 szablon klasy std::atomic, 138, 140, 147, 460 std::chrono::duration, 117, 420 std::chrono::time_point, 118, 429 std::future, 103, 109, 112, 490 std::lock_guard, 60, 528 std::packaged_task, 106, 391, 501 std::promise, 109, 507 std::ratio, 421, 544 std::ratio_equal, 546 std::ratio_greater, 548 std::ratio_greater_equal, 548 std::ratio_less, 547 std::ratio_less_equal, 548 std::ratio_not_equal, 547 std::shared_future, 103, 113, 495 std::unique_lock, 79, 80, 530 TemplateDispatcher, 405 szczegółowość blokady, 82 szeregowanie, 185

T tablica mieszająca, 209 kubełek, 209 tablica posortowana, 209 tablica wyszukiwania, 207 operacje, 208 test_and_set(), 138, 141 testowanie współbieżnego kodu, 360 biblioteka podstawowych mechanizmów, 365 czynniki dotyczące środowiska testowego, 361 projektowanie struktury wielowątkowego kodu testowego, 366 identyfikacja odrębnych fragmentów poszczególnych testów, 366 przykład testu dla struktury kolejki, 367 scenariusze testowania kolejki, 360 testowanie symulowanych kombinacji, 364 wady, 365

574

Skorowidz

testowanie współbieżnego kodu testowanie wydajności, 369 skalowalność, 369 testy siłowe, 363 wady, 364 thread_a(), 76 thread_b(), 76 thread_guard, 48 thread_pool, 331 time_since_epoch(), 118 top(), 64 try_lock(), 76, 80 try_lock_for(), 120 try_lock_until(), 120 try_pop(), 98, 191, 197, 200, 202, 337 try_reclaim(), 230 try_steal(), 337 try-catch, 40 typy wywoływalne, 36

U unlock(), 60, 80, 348 update_data_for_widget, 44 uwięzienie, 223, 354

V void(), 106

W wait(), 96, 102, 318, 344, 348, 403 wait_and_dispatch(), 405, 407 wait_and_pop(), 98, 191, 202, 204 wait_for(), 115, 120, 344 wait_for_data(), 205 wait_until(), 115 warstwy abstrakcji, 30 wątki demonów, 42 wątki sprzętowe, 21 wnioskowanie typów zmiennych, 122 worker_thread(), 326, 335 wskaźniki ryzyka, 233 współbieżne struktury danych, 184 aktywne oczekiwanie, 261 algorytmy blokujące, 220 bezpieczeństwo przetwarzania wielowątkowego, 184 blokady wirujące, 221 definicja klasy kolejki, 190 definicja klasy stosu, 187

drzewo binarne, 209 implementacja listy z obsługą iteracji, 214 implementacja tablicy wyszukiwania, 210 jednowątkowa implementacja kolejki, 195 kolejka bez blokad, 252 uzyskiwanie nowej referencji, 259 zwalnianie licznika zewnętrznego węzła, 260 zwalnianie referencji do węzła, 258 kolejka nieograniczona, 206 kolejka ograniczona, 206 kolejka z mechanizmami blokowania i oczekiwania, 203 kolejka ze szczegółowymi blokadami, 198 kolejka ze sztucznym węzłem, 196 kolejką z jednym producentem i jednym konsumentem, 253 lista jednokierunkowa, 194 problem ABA, 266 projektowanie przy użyciu blokad, 186 stos bez blokad, 227, 250 struktury bez blokad, 220, 221 eliminowanie niebezpiecznych wycieków, 228 kolejka, 252 mechanizm odzyskiwania pamięci, 230 problem ABA, 266 stos, 227 uwięzienie, 223 wskazówki dotyczące pisania struktur, 264 zalety i wady, 222 zarządzanie pamięcią, 228 zliczanie referencji, 242 zwalnianie węzłów, 229 struktury bez oczekiwania, 222 szeregowanie, 185 tablica mieszająca, 209 kubełek, 209 tablica posortowana, 209 tablica wyszukiwania, 207 wskazówki dotyczące projektowania, 185 wskaźniki ryzyka, 233 implementacja funkcji odzyskujących węzły, 238 strategie odzyskiwania węzłów, 240 wykrywanie węzłów, 233 wywołania blokujące, 220 zagłodzenie, 221 zliczanie referencji, 242 współbieżność, 20 bezpieczeństwo przetwarzania, 184 mechanizm przyszłości, 102 modele, 22 nadsubskrypcja, 280 oczekiwanie na zdarzenie, 94

575

Skorowidz operacje atomowe, 136, 137 podniesienia wydajności, 26 podział zagadnień, 25 projektowanie struktury danych, 184 przełączanie kontekstu, 21 przełączanie zadań, 21, 22 przykład programu, 32 równoległość danych, 26 synchronizacja operacji, 93 w języku C++, 28 wątki sprzętowe, 21 współbieżne struktury danych, 184 z wieloma procesami, 23 z wieloma wątkami, 24 zarządzanie wątkami, 36 zmienna warunkowa, 95 zrównoleglanie zadań, 26 współdzielenie danych przez wątki, 55 blokowanie rekurencyjne, 90 definicja klasy stosu, 68 Double-Checked Locking, 85 elastyczne blokowanie muteksu, 79 leniwa inicjalizacja, 85 lista dwukierunkowa, 56 muteks, 59 niezmienniki, 56 ochrona danych za pomocą muteksów, 60 pamięć transakcyjna, 59 problemy, 56 programowanie bez blokad, 59 przenoszenie własności muteksu, 80 sytuacja wyścigu, 58 szczegółowość blokady, 82 wyścigu danych, 58 zakleszczenie, 71 współzawodnictwo, 286 wybór liczby wątków, 49 nadsubskrypcja, 51 wyrażenie lambda, 37 wysokie współzawodnictwo, 282 zwiększenie prawdopodobieństwa, 283 wyścig danych, 58, 112, 136, 355 niezdefiniowane zachowanie, 58 wywołanie blokujące z limitem czasowym, 115 funkcje otrzymujące limity czasowe, 121 limity bezwzględne, 115 maksymalny czas blokowania wątku, 115 okres, 117 punkt w czasie, 118 wymuszanie oczekiwania na podstawie okresu, 117

zastosowanie limitu czasowego, 120 zegar, 115

Z zaawansowane zarządzanie wątkami, 323 przerywanie wykonywania wątków, 340 monitorowanie systemu plików w tle, 350 obsługa przerwań, 349 oczekiwanie na zmienną, 343, 346 pozostałe wywołania blokujące, 348 przerywanie zadań wykonywanych w tle, 350 punkt przerwania, 341 wykrywanie przerwania, 342 pula wątków, 324 algorytm sortowania szybkiego, 330 implementacja algorytmu sortowania szybkiego, 332 implementacja prostej puli wątków, 325 kolejka umożliwiająca wykradanie zadań, 336 lokalne kolejki zadań, 334 z mechanizmem oczekiwania na zadanie, 327 z mechanizmem wykradania zadań, 337 unikanie współzawodnictwa w dostępie do kolejki, 333 wątek roboczy, 324 wykradanie zadań, 335 zakleszczenie, 71, 354 hierarchia blokad, 75 unikanie, 71, 73 zasada jednej odpowiedzialności, 277 zegar, 115 czasu rzeczywistego, 116 epoka zegara, 118 najkrótszy możliwy takt, 116 okres taktu, 116 stabilny, 116 zegar::now(), 116 zliczanie referencji, 242 licznik wewnętrzny, 243 licznik zewnętrzny, 243 stos bez blokad, 250 umieszczanie węzła na stosie bez blokad, 243 wykrywanie używanych węzłów, 242 zdejmowanie węzła ze stosu bez blokad, 245 zmienna warunkowa, 95 definicja klasy kolejki, 100 oczekiwanie na spełnienie warunku, 95 pozorne budzenie, 97 projektowanie uniwersalnej kolejki, 97 zrównoleglanie zadań, 26