2. Multithreading v C++

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ýstup
  • std::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ísto stdint.h nebo <csignal> místo signal.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: u struct je defaultně public, u class defaultně 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 indexu
  • std::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ěti
  • std::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 set a map jsou ř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& ref je & součást typu (reference), zatímco v &value je & 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í kopii x
  • [&x] – zachytí referenci na x
  • [&] – 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::jthread jsou 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ákna
  • cv.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::atomic lze použít i na jakékoliv typy, které se vejdou do registrů (např. struct složený ze dvou 4B int; 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é.