1. Wprowadzenie
Kiedy po raz pierwszy słyszysz „dane współdzielone”, w głowie zwykle pojawia się jeden obraz: „dwie funkcje manipulują jedną zmienną”. To prawda, ale to za wąskie spojrzenie.
W praktyce zasób współdzielony to wszystko, co może być użyte przez kilka wątków i jednocześnie ma stan lub zachowanie widoczne z zewnątrz (np. wyjście na konsolę). I tutaj zaczynają się niespodzianki: zasób współdzielony to nie tylko int counter, ale także std::cout, plik na dysku, socket, a nawet „wspólny porządek działań”.
Ważne, by złapać intuicję: jeśli dwa wątki mogą wpływać na ten sam „obiekt świata” (pamięć/plik/konsola/urządzenie), to potrzebujesz odpowiedzi na pytanie: na jakich zasadach się tym dzielimy? Jeśli odpowiedzi brak, to nie jest „trochę ryzykownie”, tylko „prędzej czy później będzie bolało”.
Kategorie zasobów współdzielonych
Gdy mówimy „zasób”, warto to rozłożyć na kategorie, inaczej będziesz szukać data race tylko w counter, a ono może pojawić się np. w logowaniu. Spójrzmy na praktyczną mapę zasobów współdzielonych.
Poniżej tabela, która pomaga szybko sklasyfikować, co dokładnie jest „wspólne” i dlaczego może się psuć.
| Kategoria zasobu współdzielonego | Przykład | Co może pójść nie tak | Typowa „pierwsza pomoc” (bez mutex-ów) |
|---|---|---|---|
| Pamięć współdzielona (dane w RAM) | |
Data race, UB, „czasami działa” | Nie modyfikować równocześnie; wymieniać dane tylko na granicy (po join()) |
| Wyjście (I/O) | |
Pomerane linie, utrata części komunikatu | Składać komunikat w string i drukować jednym kawałkiem; drukować z jednego wątku |
| Pliki na dysku | |
Uszkodzony plik, „mieszanka” linii | Zapisywać tylko z jednego wątku (tymczasowo) |
| Zewnętrzne serwisy | Baza danych, API | Stan zmienia się niespodziewanie, konflikt operacji | Redukować zmiany do jednego „właściciela” (jeden wątek decyduje) |
| Czas i porządek zdarzeń | „najpierw policz, potem pokaż” | Race condition: „pokażono” wcześniej niż „policzono” | Wyraźnie ustalać punkt „gotowe” (join()) i nie polegać na opóźnieniach |
Zauważ ważny psychologiczny punkt: std::cout — to też zasób współdzielony. I to nie jest „drobnostka”: bardzo wielu ludzi pierwszy raz zauważa konkurencyjność właśnie dlatego, że linie na konsoli nagle się mieszają.
Dodatkowo interesujące jest to, że sama biblioteka standardowa poważnie traktuje temat unikania data race wokół kontenerów — nawet sformułowania i wymagania dla kontenerów są rozważane przez pryzmat unikania data race.
Dlaczego zasób współdzielony wymaga projektu, a nie paru prowizorek
Bardzo kusi, by zrobić tak: „Och, mam wyścig — zaraz wstawię sleep_for(10ms) (albo „po prostu poczekam”), i wszystko się ustabilizuje”. Niestety, to klasyczna droga do świata bugów, które pokazują się tylko w piątkowe wieczory, gdy myślowo jesteś już w domu.
Problem polega na tym, że w wielowątkowości przeszkadza ci nie tylko prędkość, ale nieprzewidywalność planisty. Wątki mogą być przełączane między instrukcjami w dowolnym momencie. Dlatego:
Jeśli nie umówisz się, kto i kiedy modyfikuje zasób współdzielony, otrzymasz race condition: „czasami uda się poprawnie, czasami nie”.
Jeśli jednocześnie czytasz/zapisujesz tę samą pamięć bez protokołu — otrzymujesz data race. W C++ to szczególnie poważne: formalnie program wchodzi w UB, czyli kompilator i procesor mogą zachować się w sposób, który ci się nie spodoba.
Ważne jest odczucie różnicy między „logika się zepsuła” a „język powiedział: to poza regułami”. Race condition często można „złapać” testami i debugowaniem. Data race może nie ujawniać się tygodniami, a potem zamienić się w dziwne crashy w miejscu, które „w ogóle nie jest powiązane”.
2. Synchronizacja zaczyna się od granic własności
Zanim w przyszłości sięgniemy po mutex, trzeba przyswoić bardziej fundamentalną myśl: synchronizacja to nie narzędzie, lecz umowa. Narzędzia (mutexy, atomiki, zmienne warunkowe) to już „jak technicznie zapewnić umowę”. A najpierw trzeba zrozumieć „o czym jest umowa”.
Dobry projekt kodu wielowątkowego prawie zawsze odpowiada na trzy pytania.
Pierwsze pytanie — kto jest właścicielem danych. Właściciel to ten, kto ma prawo zmieniać stan. Jeśli „właścicieli” jest dwóch, to musisz z góry wymyślić protokół, inaczej po prostu otworzyłeś drzwi dla data race.
Drugie pytanie — kto jest czytelnikiem. Czytanie też może być niebezpieczne, jeśli równocześnie zachodzi zapis. Dlatego często łatwiej umówić się, że czytelnik dostaje „snapshot” (kopię) albo czyta dopiero po zakończeniu pracy pisarza.
Trzecie pytanie — gdzie jest granica wymiany. Najprostsza granica dostępna już teraz to join(): dopóki wątek nie zakończył pracy, inny wątek nie dotyka jego wyników. Z perspektywy początkującego to prawie jak „oddanie pracy nauczycielowi i dopiero potem otrzymanie oceny”. Brak „podglądania, co ktoś napisał w połowie rozwiązania”.
Narysujmy to jako schemat własności:
flowchart LR
M[main thread<br/>właściciel tasks] -->|robi snapshot| S[local snapshot<br/>kopia tasks]
S --> W[worker thread<br/>pracuje tylko na kopii]
W -->|tworzy wynik| R[report string]
R -->|po join| M
Idea jest prosta: zasób współdzielony (tasks) nie staje się „wspólny” tylko dlatego, że uruchomiliśmy drugi wątek. Specjalnie organizujemy projekt tak, aby tasks pozostał pod kontrolą jednego właściciela.
4. Strategie bez mutex-ów: nie udostępniać modyfikowalnego
Na tym etapie kursu nie mamy jeszcze pełnego „arsenału synchronizacji” (będzie później). Dobra wiadomość: wiele zadań można rozwiązać bez wchodzenia w walkę o pamięć współdzieloną.
Thread confinement: dane żyją w jednym wątku
Najprostsza strategia brzmi prawie dziecinnie: „niech robi to jeden wątek”. I nagle to bardzo działa.
Jeśli masz strukturę danych (np. std::vector<Task> tasks), możesz umówić się, że tylko wątek main ją modyfikuje. Wątek tła albo w ogóle nie dotyka tasks, albo dostaje kopię (snapshot). To nazywa się „izolacją danych przez wątek”: dane są jakby „zamknięte” wewnątrz jednego wątku.
Zaletą podejścia jest prostota i niezawodność. Minusem jest to, że czasem chcemy, by przetwarzanie w tle zmieniało stan. Ale dopóki nie mamy mutexów i kolejek, to uczciwy i bezpieczny wariant.
Snapshot: kopia zamiast dostępu współdzielonego
Jeśli wątkowi trzeba coś odczytać, niech odczytuje kopię. Tak, kopiowanie kosztuje. Ale na początkowych etapach wielowątkowości kopiowanie często jest tańsze niż debugowanie data race (i zdecydowanie tańsze dla twojego układu nerwowego).
Mini-idea w kodzie: bierzemy tasks, kopiujemy do snapshot i pracujemy na nim w wątku.
#include <vector>
#include <string>
struct Task {
int id{};
std::string text;
bool done{};
};
std::vector<Task> make_snapshot(const std::vector<Task>& tasks) {
return tasks; // kopia: snapshot żyje osobno
}
Tutaj ważne, że snapshot już nie zależy od tego, co dzieje się z tasks w wątku main.
Wymiana wyniku po zakończeniu: join jako granica
Kolejna „żelazna” strategia: wątek oblicza wynik, zapisuje go do zmiennej, a drugi wątek odczytuje dopiero po join().
To nie magia, to dyscyplina: dopóki wątek działa, jego wynik traktujemy jako „roboczy” i go nie ruszamy.
Koooperacyjne zatrzymanie: dlaczego stop_token jest zaprojektowany poprawnie
std::stop_token jest interesujący, bo to przykład „mechanizmu współdzielonego”, który zaprojektowano od razu z myślą o wielowątkowości. Kod zewnętrzny może poprosić o zatrzymanie, kod wewnętrzny może sprawdzić, czy żądanie zatrzymania zostało zgłoszone.
Czyli stop_token to nie „weźmy bool cancel i udostępnijmy”. To specjalny typ, który żyje według zasad wielowątkowości i jest przeznaczony jako kanał do kooperatywnego anulowania. W standardzie nawet poprawiają i doprecyzowują części dotyczące stop token/stopsource, bo to realnie używany mechanizm konkurencyjności.
Praktyczna morał: jeśli wydaje ci się, że „wystarczy udostępnić flagę”, zatrzymaj się i pomyśl. Często poprawnym rozwiązaniem jest użycie mechanizmu, który już ma poprawny model wielowątkowości, zamiast wymyślać „wspólny bool”.
5. Przykład: raport w tle w TaskBoard
Załóżmy, że podczas kursu pisaliśmy konsolową aplikację TaskBoard: zadania trzymane są w std::vector<Task>, potrafimy dodać zadanie, oznaczyć jako wykonane i wydrukować listę. Teraz chcemy dodać „raport” (np. „wykonano 7 z 10”), i niech on jest liczony w oddzielnym wątku, bo w realnym życiu raport może być ciężki (czytanie z pliku, agregacja, parsing itp.).
Podstawa: model Task i funkcja budująca raport
Zacznijmy od małej funkcji, która z listy zadań tworzy string raportu. Na razie bez wątków.
#include <string>
#include <vector>
struct Task {
int id{};
std::string text;
bool done{};
};
std::string build_report(const std::vector<Task>& tasks) {
int done_count = 0;
for (const auto& t : tasks) if (t.done) ++done_count;
return "Done: " + std::to_string(done_count) + "/" + std::to_string(tasks.size());
}
Zwróć uwagę: funkcja przyjmuje const std::vector<Task>&. Wyraźnie mówimy: „czytam, ale nie zmieniam”. To jeszcze nie „bezpieczeństwo wielowątkowe”, ale to ładny kontrakt.
Zły wariant: udostępnić tasks i „niech czyta”
Teraz zróbmy to, co początkujący umysł proponuje najpierw: uruchomimy wątek, który czyta tasks, podczas gdy main wątek je modyfikuje.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<Task> tasks; // main może zmieniać tasks
std::thread t([&] {
std::cout << build_report(tasks) << '\n'; // NIEBEZPIECZNE: tasks wspólny i modyfikowalny
});
tasks.push_back(Task{1, "Learn threads", false}); // main zmienia tasks
t.join();
}
Na zewnątrz wygląda niewinnie: „no wątek tylko drukuje”. A w istocie zrobiliśmy współdzielony modyfikowalny zasób bez protokołu. Wątek czyta tasks, a main w tym czasie robi push_back. To klasyczna droga do data race i dziwnych efektów.
I tu ważny fakt: biblioteka standardowa nie obiecuje, że kontener typu std::vector będzie „szczęśliwy”, jeśli jeden wątek zmienia jego rozmiar, a inny jednocześnie czyta. Nawet dyskusje o wymaganiach i „data race avoidance” w standardzie wokół kontenerów nie biorą się znikąd.
Dobry wariant: snapshot i praca tylko na kopii
Naprawmy projekt: wątek main pozostaje właścicielem tasks, a worker dostaje kopię snapshot i buduje z niej raport.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<Task> tasks;
tasks.push_back(Task{1, "Learn threads", true});
const std::vector<Task> snapshot = tasks; // kopia
std::thread t([snapshot] {
std::cout << build_report(snapshot) << '\n'; // Done: 1/1
});
tasks.push_back(Task{2, "Write report", false}); // zmieniamy oryginał, ale wątek go nie widzi
t.join();
}
Jest tu niewielki koszt: kopiowanie. Ale ogromna zaleta: nie zależysz już od kolejności przełączeń wątków. Wątek pracuje na „zamrożonym obrazie” danych.
Dobry wariant: drukować raport po join
Jeszcze jeden schludny sposób: wątek tworzy string raportu, main czeka na zakończenie, potem drukuje. Chodzi o to, że nie ma dostępu równoległego: dopóki wątek pisze report, main go nie czyta.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<Task> tasks;
tasks.push_back(Task{1, "Learn join()", true});
std::string report;
std::thread t([&] {
report = build_report(tasks); // OK, jeśli main nie rusza tasks i report równolegle
});
t.join();
std::cout << report << '\n'; // Done: 1/1
}
Zwróć uwagę na niuans: ten wariant jest bezpieczny tylko jeśli main nie zmienia tasks, dopóki wątek buduje raport. Czyli w praktyce znów dochodzimy do „umowy”: albo robimy snapshot, albo zamrażamy zmiany na czas pracy wątku.
W realnej aplikacji „zamrażanie” często jest niewygodne — dlatego snapshot zwykle jest praktyczniejszy.
O postępie: to już kolejny poziom
W tym momencie zwykle pojawia się pomysł: „Niech worker aktualizuje progress, a main pokaże go w procentach!”. I tu prawie zawsze pojawia się współdzielony int progress, który oba wątki modyfikują.
Zła wiadomość: jeśli progress to zwykły int, to jednoczesne odczyty/zapisy bez protokołu znowu prowadzą do data race. Dobra wiadomość: słusznie czujesz potrzebę synchronizacji — narzędzia do poprawnego śledzenia postępu omówimy później (to będzie oddzielny temat).
Na dziś lepiej przyjąć uczciwe ograniczenie: albo wątek sam drukuje postęp (i tak, wyjście może się mieszać), albo pokazujemy postęp po zakończeniu. Nieładne, ale poprawne.
6. Typowe błędy
Błąd nr 1: uważać za dane współdzielone tylko zmienne, a std::cout pominąć.
Początkujący często szukają problemów tylko w pamięci, zapominając, że wyjście to też zasób współdzielony. W efekcie dostają pomieszane linie i zaczynają „leczyć” to opóźnieniami. Dużo lepiej od razu przyjąć: wyjście jest wspólne i jeśli kilka wątków pisze jednocześnie, kolejność linii nie jest gwarantowana.
Błąd nr 2: „ja tylko czytam, więc bezpiecznie”.
Czytanie jest bezpieczne tylko wtedy, gdy nikt równocześnie nie zapisuje. Jeśli jeden wątek robi push_back do std::vector, a inny równocześnie czyta elementy, to to już nie jest „czytanie nie zmienia” — to potencjalne data race. Logika „czytanie nie zmienia” tu nie ratuje, bo kontener może modyfikować wewnętrzną pamięć.
Błąd nr 3: przekazać do wątku referencje do obiektów bez jasnej gwarancji czasu życia.
Nawet jeśli używasz join(), łatwo przypadkowo złapać przez referencję coś, co wyjdzie ze scope wcześniej niż wątek skończy pracę (zwłaszcza jeśli kiedyś używałeś detach()). Zasada ratująca początkujących: domyślnie przekazuj dane do wątku przez wartość lub rób kopię-snapshot.
Błąd nr 4: próbować synchronizować się przez sleep_for.
Opóźnienie nie ustala reguł dostępu do danych. Tylko zmienia prawdopodobieństwo, że błąd się ujawni. Czasem nawet gorzej: bug staje się „rzadki” i żyje własnym życiem. Jeśli potrzebujesz kolejności „najpierw zrób, potem użyj”, uczciwy sposób na tym poziomie to postawić wyraźną granicę (join()) lub przesłać kopię.
Błąd nr 5: „szybko zrobić wspólny znacznik bool cancel”.
Prawie zawsze prowadzi to do jednoczesnych odczytów/zapisów tej flagi i do data race. Jeśli potrzebujesz kooperatywnego zatrzymania — użyj std::stop_token, który do tego służy. Jeśli potrzebujesz bardziej złożonej koordynacji — to już temat kolejnych wykładów o synchronizacji.
GO TO FULL VERSION