Multithreading v C++ #
V předchozí lekci jsme se seznámili s historií a motivací paralelního programování. Nyní se podíváme na základy C++, které v našem předmětu budeme používat pro práci s vlákny.
C++ začalo jako rozšíření jazyka C, takže většina kódu napsaného v C je platným kódem v C++. To znamená, že pokud máte nějaký kód v C, můžete ho obvykle zkompilovat a spustit jako C++ bez jakýchkoliv změn.
To znamená, že pokud s něčím nevíte rady, můžete psát kód v C a zpravidla to bude fungovat.
Základní syntaxe a struktura programu #
Jak si vzpomínáme z jazyka C, tak i každý C++ program musí obsahovat funkci main(), která je vstupním bodem programu. Zde je jednoduchý příklad “Hello PDV!” v C++:
#include <iostream>
int main() {
std::cout << "Hello PDV!\n";
return 0;
}
Pojďme si vysvětlit klíčové prvky:
#include <iostream>: direktiva preprocesoru, která vloží hlavičkový soubor pro vstup a výstupstd::cout: standardní výstupní stream pro tisk textu<<: operátor, který se využívá pro posílání dat do streamu
Headery z C++ standardní knihovny opravdu nemají příponu .h. Například místo #include <stdio.h> v C používáme #include <iostream> v C++.
Poznámka: std::endl vs \n
V C++ můžeme také použít std::cout << "Hello, World!" << std::endl; pro výstup s novým řádkem. To je většinou méně preferovaný a neefektivní způsob, protože std::endl kromě nového řádku také provádí flush bufferu, což může být zbytečné a zpomalovat výkon.
std::cout << "Hello, PDV!\n"; // Rychlejší - jen nový řádek
std::cout << "Hello, PDV!" << std::endl; // Pomalejší - nový řádek + flush bufferu
Jmenné prostory (namespaces) #
Jedna z mála věcí, co jsme viděli na minulém příkladu, byl způsob výpisu na konzoli pomocí std::cout. Můžeme si všimnout, že cout je součástí tzv. jmenného prostoru std.
V C++ je jmenný prostor (namespace) používán k organizaci kódu a zabránění konfliktům jmen. Například, pokud máme dvě různé knihovny, které obsahují funkci s názvem remove(), můžeme je rozlišit pomocí jmenných prostorů:
namespace filesystem {
void remove(const char* path) { ... }
}
namespace timer {
void remove(uint32_t timer_id) { ... }
}
int main() {
filesystem::remove("file.txt"); // Volání funkce z jmenného prostoru filesystem
timer::remove(123); // Volání funkce z jmenného prostoru timer
return 0;
}
To se nám obzvlášť hodí v případě, když potřebujeme rozeznat, odkud daná funkce nebo typ pochází, například pokud máme v projektu vlastní implementaci string a potřebujeme vědět, kterou implementaci používáme.
namespace mylib {
class string { ... };
}
int main() {
mylib::string myStr; // Použití vlastní implementace stringu z jmenného prostoru mylib
std::string stdStr; // Použití stringu ze standardní knihovny
return 0;
}
V C++ standardní knihovně je většina funkcí a tříd umístěna v jmenném prostoru std, což nám umožňuje používat tyto funkce bez obav z konfliktů s našimi vlastními funkcemi.
// Příklady běžných funkcí a typů ze jmenného prostoru std
std::cout << "Hello, PDV!\n"; // Výstup na konzoli
std::string str = "Hello"; // Řetězec znaků
std::vector<int> vec = std::vector<int>{1, 2, 3}; // Vektor - dynamické pole
Vyhnutí se std:: prefixu
Pokud se chcete vyhnout opakovanému psaní std:: prefixu, můžete použít direktivu using namespace std; nebo importovat konkrétní symboly jako using std::cout;.
using namespace std; // Importuje celý jmenný prostor std
// ...
cout << "Hello World!" << endl; // Nyní můžeme používat cout a endl bez prefixu std
// ...
Ačkoliv je using namespace std; pohodlné, v profesionálním kódu se doporučuje vyhnout se jí, protože může vést ke konfliktům jmen mezi jmennými prostory.
POZOR: při definování vlastních funkcí a tříd v hlavičkových souborech (*.h) by se using namespace ...; nemělo používat, protože to ovlivní všechny soubory, které tento hlavičkový soubor includují, což může způsobit nechtěné konflikty jmen v jiných částech projektu.
Datové typy a proměnné #
Základní datové typy v C++ jsou stejné jako v jazyku C, navíc má C++ vestavěný typ bool pro logické hodnoty. Všechny ostatní typy jsou definované v rámci standardní knihovny, ne přímo samotným compilerem.
#include <iostream>
#include <string>
int main() {
int a = 42; // 32-bitové (typicky) celé číslo
double c = 2.71828; // 64-bitové číslo s pohyblivou řádovou čárkou
char d = 'A'; // Jeden znak
bool e = true; // Logická hodnota (true/false)
const char* str = "adieu"; // Řetězec znaků
return 0;
}
Pro zajištění přenositelnosti a jasnosti kódu se doporučuje používat pevně definované datové typy z hlavičkového souboru <cstdint>, jako jsou uint32_t, uint64_t a size_t:
#include <cstdint>
int main() {
uint32_t f = 123456789; // 32-bitový unsigned integer
uint64_t g = 1234567890123456789ULL; // 64-bitový unsigned integer
size_t h = 100; // Typ pro velikost a indexování
return 0;
}
Poznámka: Headery z C standardní knihovny jsou v C++ dostupné i ve variantě
c<puvodni-nazev>, např.<cstdint>místostdint.hnebo<csignal>místosignal.h. U moderního C++ se obvykle preferuje právě tato C++ varianta.
Automatické určení typu (auto) #
C++ umožňuje automaticky určit datový typ pomocí klíčového slova auto. Kompilátor přiřadí automaticky typ podle hodnoty na pravé straně přiřazení. To je zvlášť užitečné při práci s komplexnějšími typy:
auto i = 96; // int
auto j = 3.14; // double
auto vec = std::vector<int>{1, 2, 3}; // std::vector<int>
auto it = vec.begin(); // std::vector<int>::iterator
Třídy a objekty #
Podobně jako v Javě umožňuje C++ pomocí tříd definovat nové uživatelské typy, který má vlastní členské proměnné (fields) a funkce (methods). V C++ ji definujeme klíčovým slovem class.
Stejný typ lze ale v C++ definovat i pomocí
struct. Hlavní rozdíl je ve výchozí viditelnosti: ustructje defaultněpublic, uclassdefaultněprivate.
class MyClass {
private:
int data; // Soukromý field
int another = 1; // Soukromý field s výchozí hodnotou
public:
MyClass(int data) : data(data) {} // Konstruktor pro inicializaci dat
MyClass(int data, int another) : data(data), another(another) {} // Přetížený konstruktor
void display() const {
std::cout << "Data: " << data << ", another: " << another << "\n";
}
};
int main() {
auto obj = MyClass(42); // Pozor, opravdu zde není `new`, viz níže
auto obj2 = MyClass(42, 7);
obj.display(); // Výstup: Data: 42, another: 1
obj2.display(); // Výstup: Data: 42, another: 7
return 0;
}
// Struct je podobný, ale fieldy a metody jsou veřejné
struct MyStruct {
int data; // Ve structu je tento člen public
void display() const {
std::cout << "Data: " << data << "\n";
}
};
Konstruktor se zpravidla bez klíčového slova new, podobně jako v Pythonu. Použitím new by se instance alokovala na heapu a dostali byste pointer, což zpravidla není to, co chcete.
Klíčová slova public a private určují viditelnost: public je přístupné zvenčí třídy, private jen uvnitř. Ve výchozím nastavení jsou členy class private. Rozdílem oproti Javě je, že v C++ public:/private: platí pro všechny následující deklarace až do dalšího access specifieru (nebo konce třídy), ne jen pro jednu položku.
Poznámka: auto var_name = Type(arg); vs Type var_name(arg);
V cizím C++ kódu často narazíte na deklaraci a inicializaci proměnných pomocí syntaxe Type var_name(arg);. Toto je původní syntaxe pro inicializaci proměnných, která ale má několik problémů, ten hlavní je tzv. “most vexing parse”: pokud napíšete Type var_name();, compiler nemůže vědět, zda chcete zadefinovat funkci var_name, která vrací Type, nebo inicializovat proměnnou var_name typu Type zavoláním konstruktoru bez parametrů, v takové situaci se používá Type var_name{};.
Kvůli tomuto a dalším problémům vřele doporučuji zapomenout, že tato syntaxe existuje a všude používat auto var_name = Type().
Poznámka: Inicializační seznam
Syntaxe MyClass(int value) : data(value) {} se jmenuje inicializační seznam. Část za dvojtečkou přímo inicializuje datové členy před spuštěním těla konstruktoru. Toto je efektivnější než přiřazení uvnitř těla, protože jinak by se hodnota jednou inicializovala na výchozí hodnotu, která se pak přepíše přiřazením v těle konstruktoru.
// ✓ Preferováno - inicializační seznam
MyClass(int value) : data(value) {}
// ✗ Méně efektivní
MyClass(int value) { data = value; }
Destruktory #
Krátká motivace: na rozdíl od Javy C++ nemá garbage collector, ale zároveň se nechceme spoléhat na čistě manuální správu zdrojů jako v C, protože je snadné udělat chybu (zapomenout uvolnit paměť, soubor nebo zámek). Proto C++ staví na destruktorech a přístupu RAII.
Destruktor je speciální metoda, která se automaticky volá, když objekt zanikne, typicky při zániku proměnné, ve které je objekt uložený, tedy na konci aktuálního bloku ohraničeného {...}. Destruktor má stejný název jako třída, ale s prefixem ~.
Při práci se zdroji (paměť, soubory, zámky, atd.) je hlavní výhoda ta, že se destruktor zavolá automaticky, i když program opouští blok kvůli výjimce. Tím se vyhneme ručnímu free(),close(),unlock() v každé větvi kódu. Celý tento princip, kde třída alokuje zdroj v konstruktoru a uvolní ho v destruktoru, se nazývá RAII (Resource Acquisition Is Initialization) a je základem pro správu zdrojů v C++.
class MyClass {
public:
MyClass() {
std::cout << "Konstruktor zavolán\n";
}
~MyClass() {
std::cout << "Destruktor zavolán\n";
}
};
int main() {
auto obj = MyClass(); // Konstruktor se zavolá zde
return 0;
} // Destruktor se zavolá při opuštění bloku/funkce
Výstup:
Konstruktor zavolán
Destruktor zavolán
RAII — Resource Acquisition Is Initialization
RAII je klíčový návrhový vzor, který váže životnost zdrojů (paměť, soubory, zámky, atd.) na životnost objektů. V jednoduchosti to znamená, že když objekt vznikne, získá zdroj, a když objekt zanikne, uvolní zdroj.
Na rozdíl od jazyka C, kde musíte ručně volat malloc() a free(), RAII zajišťuje, že destruktor vždy uvolní zdroj, i když dojde k výjimce:
class FileHandler {
private:
std::string filename;
public:
FileHandler(const std::string& fname) : filename(fname) {
std::cout << "Otevírám file: " << filename << "\n";
// Simulace otevření souboru
}
~FileHandler() {
std::cout << "Zavírám file: " << filename << "\n";
// Automatické zavření - i při výjimce!
}
};
int main() {
try {
auto f = FileHandler("data.txt");
// Práce se souborem
throw std::runtime_error("Něco se pokazilo!");
// Destruktor se zavolá i přes výjimku!
} catch (const std::exception& e) {
std::cout << "Chyba: " << e.what() << "\n";
}
return 0;
}
Výstup:
Otevírám file: data.txt
Zavírám file: data.txt
Chyba: Něco se pokazilo!
Všechny standardní kontejnery jako std::vector, std::string, atd. využívají RAII — nemusíte nic dělat, správa paměti je automatická.
Generické třídy a funkce (templates) #
template umožňuje psát obecný kód pro více typů bez duplikace. Myšlenkou se to podobá generikám v Javě, které už známe, ale v C++ je implementace jiná: kompilátor při překladu vytváří konkrétní varianty podle použití.
Jak funguje expanze: kompilátor nejdřív dosadí konkrétní typy/hodnoty do šablony a až potom zkontroluje a přeloží vzniklou variantu kódu. Oproti makrům v C je tak zachována typová kontrola.
// Generická funkce pro sčítání dvou hodnot
template <typename T>
T add(T a, T b) {
return a + b;
}
// add(2, 3) -> int
// add(1.5, 2.5) -> double
// Generická třída pro pole s pevnou velikostí
template <typename T, std::size_t N>
class MyArray {
T arr[N];
public:
void set(std::size_t i, const T& v) {
arr[i] = v;
}
T get(std::size_t i) const {
return arr[i];
}
};
Když šablonu zavoláme s typem, který nepodporuje operator+, chyba se objeví až po dosazení, protože kompilátor zkontroluje až vzniklou variantu kódu při překladu.
struct NoPlus {
int x;
NoPlus(int x) : x(x) {}
};
int main() {
auto ok = add(1, 2); // funguje
auto fail = add(NoPlus(1), NoPlus(2)); // Chyba překladu: pro typ NoPlus není definovaný operator+
}
Kolekce a iterace #
C++ standardní knihovna nabízí různé kolekce pro různé případy použití:
std::vector- dynamické pole s rychlým přístupem na indexustd::array- pole s pevnou velikostí (compile-time)std::list- dvousměrný spojový seznam pro efektivní vkládání a mazánístd::forward_list- jednosměrný spojový seznam s menší spotřebou pamětistd::unordered_set- množina unikátních prvků založená na hashovánístd::unordered_map- asociativní pole (klíč-hodnota) založené na hashování
Velkým rozdílem od jazyka C je, že všechny tyto kolekce se automaticky starají o správu paměti díky RAII. Nemusíme ručně alokovat ani uvolňovat paměť – memory management je ošetřen pomocí destruktorů těchto objektů.
Poznámka: Neprefixované varianty
setamapjsou řazené (typicky podle<), ale pomalejší.unordered_varianty bývají implementované jako hashovací tabulka, nezachovávají pořadí prvků, ale jsou rychlejší.
#include <iostream>
#include <vector>
#include <array>
#include <list>
#include <forward_list>
#include <set>
#include <unordered_set>
#include <map>
#include <unordered_map>
int main() {
// Dynamické pole
auto vec = std::vector<int>{1, 2, 3, 4, 5};
vec.push_back(6);
vec.at(0) = 10; // Bezpečný přístup s kontrolou rozsahu
// Pole s pevnou velikostí
auto arr = std::array<double, 3>{1.1, 2.2, 3.3};
// Dvousměrný spojový seznam
auto lst = std::list<std::string>{"Hello", "World"};
lst.push_front("PDV");
lst.push_back("<3");
// Jednosměrný spojový seznam
auto fwd_lst = std::forward_list<double>{1.1, 2.2, 3.3};
fwd_lst.push_front(0.0);
// Množina s řazením
auto s = std::set<int>{5, 3, 1};
s.insert(4);
// Asociativní pole s řazením
auto m = std::map<std::string, int>{{"one", 1}, {"two", 2}};
m["three"] = 3;
return 0;
}
Bezpečný přístup k prvkům kolekcí pomocí `at()`
V C++ je důležité být opatrný při indexování kolekcí se spojitým bufferem (například std::vector a std::array): přístup mimo rozsah je nedefinované chování.
U unordered_set/unordered_map nejde o přístup přes pořadový index, ale o vyhledávání podle hashe klíče. unordered_set nemá operator[]; unordered_map ho má, ale při chybějícím klíči vytvoří nový záznam.
Proto je dobré vždy zkontrolovat velikost kolekce před přístupem k jejím prvkům nebo použít metody jako at(), které provádějí kontrolu rozsahu a vyhazují výjimku v případě neplatného přístupu:
auto vec = std::vector<int>{1, 2, 3};
vec[10]; // ✗ Nedefinované chování - bez kontroly rozsahu
vec.at(10); // ✓ Vyhazuje std::out_of_range výjimku
Iterace přes kolekce #
Pro iteraci přes kolekce můžeme použít klasický for nebo range-based for, který je obvykle jednodušší a méně náchylný k chybám.
// Klasický for loop pro iteraci přes vektor
auto vec = std::vector<int>{1, 2, 3, 4, 5};
for (size_t i = 0; i < vec.size(); ++i) {
std::cout << vec[i] << " ";
}
// Iterace přes vektor pomocí range-based for loop
for (auto value : vec) {
std::cout << value << " ";
}
// Iterace přes mapu pomocí range-based for loop
// Význam `&` si vysvětlíme v další části
auto m = std::map<std::string, int>{{"one", 1}, {"two", 2}, {"three", 3}};
for (auto& pair : m) {
std::cout << pair.first << ": " << pair.second << "\n";
}
Reference a pointery #
Podobně jako v C, při předání parametru hodnotou (by-value) dochází ke kopírování. U C++ ale kopírovaní zahrnuje i “vlastněná” data, např. u std::vector to znamená kopii celého bufferu na heapu, což může být velmi drahé. Proto pokud nepotřebujeme novou kopii, často parametry předáváme přes adresu nebo referenci.
Pointery (stejně jako v C) ukládají adresu odkazovaného objektu. Mohou být nullptr, takže je potřeba je kontrolovat, a při práci s nimi musíme explicitně dereferencovat (*, případně ->):
int value = 42;
int* ptr = &value; // Adresa proměnné value
(*ptr)++; // Dereference: zvýšení hodnoty
Reference jsou aliasy na proměnné, nemohou být nullptr a mají transparentní syntaxi (při práci s hodnotou nepíšeme dereferenci). Při volání funkce s referenčním parametrem navíc nepředáváme adresu přes &, ale přímo proměnnou. Reference nejsou jen pro parametry funkcí, můžeme je používat i jako lokální proměnné:
int value = 42;
int& ref = value; // Alias pro value
ref++; // Žádná dereference potřebná
Důležité: v deklaraci
int& refje&součást typu (reference), zatímco v&valueje&operátor získání adresy.
Proto se v běžném C++ často preferují reference. Pointer použijeme hlavně tehdy, když dává smysl možnost “neukazovat na nic” (nullptr) nebo když potřebujeme explicitně pracovat s adresami.
// Použití pointeru
void incrementByPointer(int* ptr) {
if (ptr != nullptr) { // Musíme kontrolovat null
(*ptr)++;
}
}
// Použití reference (referenční parametr => stačí předat jen proměnnou)
void incrementByReference(int& ref) {
ref++; // Jednodušší a bezpečnější
}
int main() {
int value = 42;
incrementByPointer(&value); // Nutné předat adresu
incrementByReference(value); // Přímo předáme proměnnou
return 0;
}
Lambda funkce #
Lambda je anonymní funkce, která může být definována přímo v místě, kde ji potřebujeme, a může zachytit proměnné z okolního kontextu. Její syntaxe je kompaktní a umožňuje nám psát funkce bez nutnosti pojmenování. Syntaxe lambda výrazu je
[capture](parameters) -> return_type { body }
auto add = [](int a, int b) { return a + b; };
std::cout << add(2, 3) << "\n"; // 5
int base = 5;
auto f = [base](int x) { return base + x; }; // capture: kopíruje base
std::cout << f(10) << "\n"; // 15
Pokud chcete v lambdě přistupovat k okolním proměnným, musíte to deklarovat přes tzv. captures v hranatých závorkách:
[x]– zachytí kopiix[&x]– zachytí referenci nax[&]– běžná varianta: zachytí reference na používané lokální proměnné
Pozor: proměnné zachycené referencí nesmí zaniknout dřív než lambda (např. návratem z funkce), jinak lambda přistupuje na neplatnou paměť.
int n = 0;
auto byRef = [&]() { return n; }; // lambda drží referenci na n
n = 10;
std::cout << byRef() << "\n"; // 10 (vidí aktuální hodnotu)
Parametry fungují jako u běžné funkce (včetně generických auto).
Jak lambda funguje interně #
Lambdu si můžeme představit jako malý anonymní struct, který:
- má datové členy pro zachycené proměnné,
- má přetížený
operator(), takže se dá volat jako funkce.
Pro lambdu [base, &n](int x) { return base + x + n; } si to můžeme představit takto:
// Ilustrační příklad (název typu je vymyšlený):
struct __lambda_15_16 {
int base; // kopie base
int& n; // reference na n
int operator()(int x) const { return base + x + n; }
};
Tento typ vzniká při překladu a v kódu nemá běžné jméno, takže ho nemůžeme přímo napsat do deklarace proměnné. Navíc každá lambda má vlastní unikátní typ. Proto se lambda výraz nejčastěji ukládá přes auto, aby kompilátor typ odvodil za nás.
// A a B jsou různé typy, i když mají stejnou implementaci
auto A = [](int x) { return x + 1; };
auto B = [](int x) { return x + 1; };
// Při překladu dostane každá z těchto lambd vlastní anonymní typ.
// <unikatni_typ_A> A = <unikatni_typ_A>{};
// <unikatni_typ_B> B = <unikatni_typ_B>{};
Druhou častou variantou je zabalení do šablony std::function, která lambda funkci uloží na heap, a tedy nemusí vědět přesný typ lambda funkce (tzv. “type erasure”) a umožní nám např. několik různých lambda funkcí uložit do vectoru, dokud mají stejné parametry a návratový typ.
Vícevláknové programování v C++ #
C++ nabízí několik nástrojů pro práci s vlákny, jako je std::jthread, std::mutex a další. Tyto nástroje nám umožňují vytvářet a spravovat vlákna, synchronizovat přístup k sdíleným zdrojům a řešit problémy spojené s konkurencí.
Vytváření a správa vláken #
V C++20 můžeme vytvářet vlákna pomocí třídy std::jthread, která je součástí standardní knihovny. Do konstruktoru std::jthread předáme funkci, kterou chceme v novém vlákně zavolat, a případné argumenty této funkce.
#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
void threadFunction(int id) {
std::cout << "Thread " << id << " is running.\n";
std::this_thread::sleep_for(std::chrono::seconds(1)); // Simulace práce
std::cout << "Thread " << id << " is finished.\n";
}
int main() {
int n = 1; // lokální proměnná pro lambda výraz
// vytvoření pomocí funkce
auto t1 = std::jthread(threadFunction, 1);
auto t2 = std::jthread(threadFunction, 2);
// kompaktnější zápis pomocí lambda výrazu
auto t3 = std::jthread([n](const std::string& str) {
std::cout << "Lambda thread is running.\n";
std::cout << str << " is no. " << n << " \n";
std::cout << "Lambda thread is finished.\n";
}, "Alysa Liu");
return 0;
}
std::jthread (C++20) vs std::thread (C++11)
Často se můžeme setkat s dvěma různými třídami pro práci s vlákny: std::thread a std::jthread. Hlavní rozdíl mezi nimi je v tom, že std::jthread automaticky spravuje životnost vlákna a zajišťuje, že se vlákno správně ukončí, když objekt std::jthread zanikne. To znamená, že nemusíme ručně volat join(), což snižuje riziko chyb spojených s nesprávným ukončením vláken.
#include <iostream>
#include <thread>
void threadFunction() {
std::cout << "Thread is running.\n";
}
int main() {
// Použití std::thread
auto t1 = std::thread(threadFunction);
t1.join(); // Musíme ručně zavolat join, jinak by došlo k chybě
// Použití std::jthread
auto t2 = std::jthread(threadFunction); // Není potřeba volat join, jthread se postará o správné ukončení vlákna
return 0;
}
Synchronizace a ochrana sdílených zdrojů #
Při práci s více vlákny je důležité zajistit, aby přístup k sdíleným zdrojům (jako jsou proměnné, soubory nebo databáze) byl správně synchronizován, aby nedošlo k nekonzistentním stavům nebo race-conditions.
std::mutex
#
V paralelizaci se to řeší pomocí mutexů (mutual exclusion), což jsou synchronizační primitiva, která zajišťují, že pouze jedno vlákno může přistupovat k určitému zdroji v daném okamžiku. Pokud jedno vlákno zamkne mutex lock(), ostatní vlákna, která se pokusí zamknout stejný mutex, budou blokována, dokud první vlákno mutex neodemkne unlock().
C++ poskytuje přes standardní knihovnu třídu std::mutex pro práci s mutexy. Zde je jednoduchý příklad, jak použít std::mutex k synchronizaci přístupu ke sdílenému čítači:
#include <iostream>
#include <thread>
#include <mutex> // header pro std::mutex
std::mutex mtx; // Mutex pro synchronizaci přístupu k sdílenému zdroji
int sharedCounter = 0; // Sdílený zdroj
void incrementCounter() {
for (int i = 0; i < 100000; ++i) {
mtx.lock(); // Zamkne mutex před přístupem ke sdílenému zdroji
++sharedCounter; // Zvýšení sdíleného čítače
mtx.unlock(); // Odemkne mutex po dokončení práce se sdíleným zdrojem
}
}
int main() {
{
auto t1 = std::jthread(incrementCounter);
auto t2 = std::jthread(incrementCounter);
} // Automatický join na konci bloku
std::cout << "Final counter value: " << sharedCounter << "\n";
return 0;
}
Final counter value: 200000
Poznámka: Vlákna
std::jthreadjsou záměrně uzavřená do bloku{...}takže se pak automaticky zavolájoin(). V praxi je to častý vzorec pro zkrácení životnosti proměnných a obvykle je přehlednější.
std::unique_lock
#
Nevýhoda čistého používání std::mutex je, že mutex může zůstat zamčený, pokud z kritické sekce odejdeme dřív, než zavoláme unlock() (například kvůli výjimce, return, break nebo continue). Jedno z řešení by bylo ručně odemykat mutex ve všech větvích kódu, ale to je nepraktické a snadno vede k chybám.
#include <mutex>
std::mutex mtx;
//...
{
mtx.lock(); // Zamknutí mutexu
throw std::runtime_error("Něco se pokazilo!"); // Vyvolání výjimky
mtx.unlock(); // Tento kód se nikdy nedostane, protože výjimka přeruší tok programu, což znamená, že mutex zůstane zamčený a může způsobit zablokování dalších vláken, která se pokusí získat tento mutex.
}
Z předchozí sekce víme, že v C++ máme k dispozici destruktory, které se automaticky volají při zničení objektu. To přesně využívá std::unique_lock, který je navržen tak, aby automaticky odemkl mutex, když se objekt unique_lock zničí (například při opuštění bloku).
#include <mutex>
std::mutex mtx;
//...
{
auto lock = std::unique_lock(mtx); // Zamknutí mutexu pomocí unique_lock
// mtx.lock();
throw std::runtime_error("Něco se pokazilo!"); // Vyvolání výjimky
// mtx.unlock(); // Nemusíme se starat o odemknutí mutexu, protože unique_lock to udělá automaticky
} // Když se objekt lock zničí při opuštění rozsahu, mutex se automaticky odemkne
Podmínkové proměnné (Condition variables) #
Při práci s více vlákny se můžeme setkat se situací, kdy jedno vlákno potřebuje počkat na určitou událost nebo podmínku, než bude pokračovat. Jeden ze způsobů, jak to řešit, by bylo neustále kontrolovat stav nějaké proměnné (busy waiting), ale to je neefektivní a může vést k vysoké spotřebě CPU.
C++ nabízí podmínkové proměnné, std::condition_variable, což je další synchronizační primitivum, které umožňuje vláknům čekat na určitou podmínku a být probuzena, když se tato podmínka stane pravdivou.
Syntaxe je velmi jednoduchá, jedno vlákno může čekat na podmínku pomocí cv.wait(lock, predicate), kde lock je std::unique_lock<std::mutex> a predicate je nějaká lambda funkce, pomocí které můžeme definovat podmínku, na kterou chceme čekat. Když se podmínka stane pravdivou, vlákno se probudí a může pokračovat ve své práci.
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx;
std::condition_variable cv;
bool dataReady = false; // Podmínka pro signalizaci, že data jsou připravena
int main() {
// vlákno t1 - vykonává nějakou práci a poté signalizuje, že data jsou připravena
std::jthread t1([]() {
std::cout << "t1: Starting work...\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout << "t1: Work completed, notifying consumer...\n";
std::unique_lock<std::mutex> lock(mtx);
dataReady = true;
cv.notify_one();
});
// vlákno t2 - čeká na signál, že data jsou připravena, a poté je zpracovává
std::jthread t2([]() {
std::unique_lock<std::mutex> lock(mtx);
std::cout << "t2: Waiting for data...\n";
cv.wait(lock, []() { return dataReady; });
std::cout << "t2: Data are ready, processing...\n";
});
return 0;
}
Další funkce pro práci s podmínkovými proměnnými:
cv.notify_one(): Probuzení jednoho čekajícího vláknacv.notify_all(): Probuzení všech čekajících vláken
Atomické operace #
Motivační příklad: Představme si, že máme sdílenou proměnnou counter, kterou obě vlákna inkrementují. Bez synchronizace se může stát, že obě vlákna načtou stejnou hodnotu, zvýší ji a uloží zpět — výsledkem je, že counter se zvýší jen o 1 místo o 2.
#include <iostream>
#include <thread>
int counter = 0; // Sdílená proměnná BEZ ochrany
void increment() {
for (int i = 0; i < 100000; ++i) {
counter++; // Race condition
}
}
int main() {
auto t1 = std::jthread(increment);
auto t2 = std::jthread(increment);
std::cout << "Očekáváno: 200000\n";
std::cout << "Skutečnost: " << counter << "\n"; // Výstup: 150000 (nebo jiné náhodné číslo v rozmezí <2, 200000>)
return 0;
}
Řešení pomocí mutexu? Bylo by to pomalé — každá operace by vyžadovala lock/unlock.
Procesory místo toho poskytují tzv. atomické operace, které nám umožní dělat některé základní operace se zamykáním přímo v hardwaru. Užitím atomické operace říkám compileru i procesoru, že potřebuji, aby se daná operace provedla “atomicky” – tak, aby žádné jiné jádro nikdy nevidělo žádný mezistav.
V C++ můžeme zabalit typ do třídy std::atomic, která zajistí, že všechny operace na typu budou atomické:
#include <iostream>
#include <thread>
#include <atomic>
auto counter = std::atomic<int>(0); // atomický čítač
void increment() {
for (int i = 0; i < 100000; ++i) {
counter++; // Atomicky, bez mutexu (operátor ++ je na třídě std::atomic přetížený)
}
}
int main() {
auto t1 = std::jthread(increment);
auto t2 = std::jthread(increment);
std::cout << "Výsledek: " << counter.load() << "\n"; // Vždy 200000
return 0;
}
Poznámka:
std::atomiclze použít i na jakékoliv typy, které se vejdou do registrů (např.structsložený ze dvou 4Bint; atomické aritmetické operace nad ním ale fungovat nebudou).
O atomických operacích se budeme obsáhleji bavit na 4. přednášce. Pro korektní fungování atomických operací na moderních procesorech a compilerech nám ještě chybí některé záruky, co zatím nebyly zmíněné.