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íť.

Kategorie: Softwarový vývojAktualizováno

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

  1. 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)
  2. 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

  1. threading: Thread-based parallelism(otevře se v novém okně)Python Software Foundation
  2. Concurrency (Java Tutorials)(otevře se v novém okně)Oracle
  3. Using Web Workers(otevře se v novém okně)MDN Web Docs
  4. Worker threads(otevře se v novém okně)OpenJS Foundation
  5. Thread (computing)(otevře se v novém okně)Wikipedia

Související pojmy

Potřebujete to vyřešit v praxi?

Poradíme, jak na to ve vašem projektu

Vysvětlit pojem je jedna věc, navrhnout kolem něj funkční řešení druhá. Ozvěte se a probereme, co dává smysl u vás.