multitredyngTakéVícevláknovost, Multi-threading, Vláknové programováníPokročilý
Definice
Multithreading je souběžné vykonávání více vláken uvnitř jednoho procesu, přičemž vlákna sdílejí stejný adresní prostor a otevřené soubory, ale mají vlastní zásobník a čítač instrukcí. Umožňuje využít víc jader procesoru najednou nebo pokračovat v práci, zatímco jiné vlákno čeká na disk či síť.
Nezaměňujte: V kontextu hardwaru označuje simultaneous multithreading (Intel Hyper-Threading) schopnost jednoho fyzického jádra vykonávat dvě instrukční vlákna, což je něco jiného než softwarová vlákna operačního systému.
Co vlákno odlišuje od procesu
Proces vlastní paměť, popisovače souborů a další zdroje operačního systému. Vlákno je jednotka plánování uvnitř procesu: má svůj zásobník, registry a čítač instrukcí, ale halda a globální proměnné jsou společné všem vláknům téhož procesu. Právě sdílená paměť dělá z multithreadingu levnou formu souběžnosti (přepnutí mezi vlákny je řádově lacinější než mezi procesy) a zároveň jeho největší nebezpečí.
Dvě různé motivace: propustnost a odezva
Vlákna se používají ze dvou velmi odlišných důvodů. První je paralelismus: rozdělit výpočet na víc jader a zkrátit dobu běhu. Druhý je souběžnost při čekání: aplikace nesmí zamrznout, když jedno vlákno blokuje na síťovém volání nebo čtení z disku. Desktopová a mobilní UI vlákna typicky spadají do druhé kategorie, těžké numerické výpočty do první. Rozlišení je praktické: úloha vázaná na I/O profituje z vláken i na jednom jádře, úloha vázaná na CPU nikoli.
Kde vzniká problém: souběžný přístup ke sdílenému stavu
Když dvě vlákna zapisují do stejné proměnné bez synchronizace, výsledek závisí na náhodném prokládání instrukcí. Tomu se říká souběh (race condition) a projevuje se nepravidelně, často až v produkci pod zátěží. Obranou jsou zámky (mutex), atomické operace, neměnné datové struktury nebo předávání zpráv místo sdílení. Zámky ale přinášejí vlastní rizika: uváznutí (deadlock) při nekonzistentním pořadí zamykání, hladovění vláken a ztrátu výkonu při přílišné granularitě.
Paměťový model
Procesor i kompilátor smějí instrukce přeuspořádat. Bez explicitní synchronizace proto nemá vlákno záruku, že uvidí zápis provedený jiným vláknem. Jazyky jako Java, C++ nebo Rust definují paměťový model, který popisuje, jaké uspořádání je zaručeno; bez něj by souběžný kód nebyl přenositelný.
Kdy runtime brzdí
Ne každý běhový systém dovolí skutečný paralelismus vláken. CPython má globální zámek interpreteru (GIL), takže bytekód vykonává jedno vlákno v čase; pro výpočty se tam sahá po procesech. JavaScript v prohlížeči i v Node.js běží jednovláknově a paralelismus řeší Web Workers, resp. worker threads, které komunikují zprávami.
Kolik vláken má smysl vytvářet
Počet vláken není zdarma: každé má vlastní zásobník (typicky stovky kB až megabajty) a vytváření i přepínání kontextu stojí čas. Pro výpočty se osvědčuje počet vláken blízký počtu jader, pro čekání na I/O může být vyšší. Moderní praxe se ale posouvá jinam: místo ručně spravovaných vláken se používají thread pooly, asynchronní modely nad událostní smyčkou nebo lehká vlákna spravovaná runtimem (goroutiny v Go, virtuální vlákna v Javě), kde je cena jednoho úkolu řádově nižší.
Příklady z praxe
Souběh při inkrementaci čítače
Dvě vlákna zvyšují stejný čítač milionkrát. Operace counter += 1 není atomická: skládá se ze čtení, přičtení a zápisu, takže se aktualizace vzájemně přepisují a výsledek je nižší než dva miliony. Zámek nebo atomický typ problém odstraní.
import threading counter = 0 lock = threading.Lock() def work(): global counter for _ in range(1_000_000): with lock: # bez zámku vyjde méně než 2 000 000 counter += 1 ts = [threading.Thread(target=work) for _ in range(2)] for t in ts: t.start() for t in ts: t.join() print(counter)Stahování stovky URL bez blokování
Webový skript stahuje 100 adres, každá odpovídá zhruba za 200 ms. Sériově trvá běh přes 20 sekund, protože procesor jen čeká na síť. Thread pool s dvaceti vlákny zkrátí čas přibližně na jednotky sekund, i když se výpočtu nic neubralo: vlákna se jen střídají v čekání.
from concurrent.futures import ThreadPoolExecutor import requests urls = [...] # 100 adres with ThreadPoolExecutor(max_workers=20) as pool: for resp in pool.map(requests.get, urls): print(resp.status_code)
Časté omyly
- MýtusKdyž program rozdělím na víc vláken, poběží úměrně rychleji.
- Ve skutečnostiZrychlení omezuje sériová část úlohy (Amdahlův zákon) a režie synchronizace. Zamykaný kód se s rostoucím počtem vláken může dokonce zpomalit, protože vlákna soupeří o stejný zámek.
- MýtusMultithreading a paralelismus jsou totéž.
- Ve skutečnostiVlákna vyjadřují souběžnost, tedy více rozpracovaných úloh najednou. Skutečný paralelismus vyžaduje víc jader a runtime, který jim dovolí běžet současně; na jednom jádře se vlákna pouze střídají.
- MýtusPython vlákna nemají smysl kvůli GIL.
- Ve skutečnostiGIL blokuje souběžné vykonávání bytekódu, ale uvolňuje se při čekání na I/O a v mnoha C rozšířeních. Pro síťové a diskové operace jsou vlákna v Pythonu plně použitelná, pro čistý výpočet se sahá po multiprocessingu.
Časté dotazy
- Kdy zvolit multithreading místo asynchronního programování?
- Multithreading se hodí, když je potřeba využít více jader pro výpočetně náročnou práci, nebo když kód volá blokující knihovny, které nemají asynchronní variantu. Asynchronní model nad událostní smyčkou naopak vyhrává u velkého množství souběžných síťových spojení, protože jedna úloha stojí jen několik kilobajtů místo celého zásobníku vlákna. V praxi se oba přístupy kombinují: událostní smyčka obsluhuje síť a blokující operace se odsouvají do thread poolu, aby smyčku nezdržely.
- Jak poznám, že chyba v programu je souběh?
- Souběh se pozná podle chování: chyba se objevuje nepravidelně, hůř pod zátěží nebo na stroji s více jádry a mizí při ladění krokováním či po přidání logování. Data bývají nekonzistentní, aniž by kód obsahoval zjevnou logickou chybu. Pomáhají nástroje jako ThreadSanitizer, Helgrind nebo detektory souběhu ve vestavěné podpoře jazyka. Spolehlivější než hledání je prevence: minimalizovat sdílený měnitelný stav, používat neměnné struktury a předávání zpráv místo společné proměnné.
- Kolik vláken by měl thread pool mít?
- Pro úlohy vázané na procesor se osvědčuje počet vláken zhruba rovný počtu logických jader; víc jich přinese jen režii přepínání kontextu. Pro úlohy vázané na I/O může být počet výrazně vyšší, protože vlákna většinu času čekají a nespotřebovávají procesor. Konkrétní číslo je vždy potřeba změřit na reálné zátěži, protože závisí na poměru čekání a výpočtu, na paměti dostupné pro zásobníky i na limitech cílové služby, kterou pool zatěžuje.
- Co je uváznutí a jak se mu vyhnout?
- Uváznutí (deadlock) nastane, když dvě nebo více vláken drží zámek a zároveň čekají na zámek, který drží to druhé, takže žádné nemůže pokračovat. Nejúčinnější prevencí je globální pořadí zamykání: všechna vlákna berou zámky vždy ve stejném pořadí. Dále pomáhá držet zámky co nejkratší dobu, nevolat pod zámkem cizí kód, používat varianty s časovým limitem a raději jeden hrubší zámek než složitou hierarchii jemných zámků, kterou nikdo neudrží v hlavě.
Zdroje
- threading: Thread-based parallelism(otevře se v novém okně)
- Concurrency (Java Tutorials)(otevře se v novém okně)
- Using Web Workers(otevře se v novém okně)
- Worker threads(otevře se v novém okně)
- Thread (computing)(otevře se v novém okně)