Operační systémy
Obsah Soubory
Procesy

Vlákna

Aktualizováno 8 min čtení 1 410 slov

Vlákna a kdy se vyplatí

„Přidám vlákna a bude to rychlejší." Tuhle větu najdeš ve stovkách návodů a platí jen za dvou podmínek, které si skoro nikdo neověří: úloha se dá rozdělit na části, které na sebe nečekají, a je omezená procesorem, ne něčím jiným.

Osm vláken na úloze, která čeká na disk, není osmkrát rychlejší. Je stejně rychlé a hůř laditelné.

Tahle stránka říká, co vlákno je, jak ho vidí Linux, kdy si vybrat vlákno a kdy proces a kdy vlákna nepomůžou. Neřeší, jak se sdílený stav zamyká - to je na kritické sekci a zámcích a na semaforech a monitorech. Předpokládá, že víš, co je proces a co stojí přepnutí kontextu.

Jediný rozdíl proti procesu

Tohle je nejdůležitější věc na celé stránce: vlákna jednoho procesu sdílejí adresní prostor a tabulku otevřených souborů. Vlastní mají jen zásobník, registry a chybové číslo.

Zbytek stránky je rozvedení téhle věty. Výhoda plyne přímo: dvě vlákna si předají ukazatel na strukturu v haldě, žádné kopírování, žádná meziprocesová komunikace. Problémy taky: dvě vlákna si tu strukturu můžou přepsat pod rukama.

Sdílené celou skupinou vláken Vlastní každému vláknu
kód programu zásobník, implicitně 8 MiB
halda a všechno, co v ní je registry včetně čítače instrukcí
globální a statické proměnné errno
tabulka otevřených souborů signálová maska
obsluhy signálů ID vlákna (TID) a priorita
pracovní adresář, umask, UID a GID úložiště vlákna (TLS)

Signál doručený procesu proto dostane libovolné vlákno, které ho nemá zamaskovaný.

Jak je to v Linuxu doopravdy

Tvrzení, že „Linux vlákna nemá" nebo že „vlákno je něco úplně jiného než proces", je nesprávné. Vlákno i proces vznikají stejným systémovým voláním clone a liší se jen příznaky.

fork je clone bez CLONE_VM - potomek dostane vlastní adresní prostor. Vlákno je clone s CLONE_VM | CLONE_FILES | CLONE_FS | CLONE_SIGHAND | CLONE_THREAD: sdílená paměť, deskriptory, adresář i obsluhy signálů.

Jádro neplánuje procesy. Plánuje úlohy, a úloha je přesně to, čemu se v uživatelském prostoru říká vlákno. Vlákna jednoho procesu tvoří skupinu vláken (thread group), jejíž ID je TGID - to číslo vrací getpid(). Vlákno má navíc vlastní TID z gettid(), u hlavního vlákna se obě rovnají.

Proto ps ve výchozím stavu vlákna neukáže, vypisuje skupiny:

ps -eLf                         # sloupec LWP je TID, NLWP je počet vláken skupiny
ls /proc/1234/task/             # jeden adresář na vlákno, jméno adresáře je TID

Kdo vlákna plánuje

Tři modely

1:1, plánuje jádro. Každé uživatelské vlákno je jedna úloha jádra. Standard na Linuxu i na Windows; v glibc ho zavedla knihovna NPTL v roce 2003 spolu s jádrem 2.6. Plánovač o vláknech ví, rozloží je na jádra a blokující volání zablokuje jen to jedno.

N:1, zelená vlákna v knihovně. Přepínání řeší běhové prostředí, jádro vidí jediné vlákno. Přepnutí je levné, ale všechno běží na jednom jádře a jedno blokující volání zablokuje všechna vlákna naráz.

M:N. N uživatelských vláken na M vláknech jádra. Zkoušel to Solaris před verzí 9, staré Javy a knihovna NGPT. Dnes je opuštěné: dva plánovače nad sebou dělají rozhodnutí, o kterých ten druhý neví, a výsledek byl pomalejší než prostý model 1:1. Solaris přešel na 1:1 ve verzi 9 (rok 2002).

Doporučení: neřeš to. Na Linuxu dostaneš 1:1 a je to správná volba.

Kam patří korutiny a goroutiny

Goroutiny v Go, korutiny v Kotlinu a virtuální vlákna v Javě (od verze 21, rok 2023) jsou zelená vlákna nad modelem 1:1: runtime drží pár skutečných vláken jádra a na ně multiplexuje tisíce svých.

Rozdíl proti starým zeleným vláknům je zásadní: runtime umí blokující volání obejít. Když goroutina sáhne na síť, runtime ji odloží, čekání vloží do epoll a na uvolněné vlákno pustí jinou. Kde ten trik nefunguje: volání do knihovny v C, na kterém vlákno jádra skutečně stojí.

Kdy vlákno a kdy proces

Situace Vezmi
sdílení velké datové struktury vlákna
rozdělení jednoho výpočtu na jádra vlákna
izolace proti pádu jedné části procesy
nedůvěryhodný nebo cizí kód procesy

Když potřebuješ izolaci proti pádu, ber procesy. Vlákno, které sáhne na neplatný ukazatel, dostane SIGSEGV a s ním padá celý proces včetně ostatních vláken. Plyne to přímo ze sdíleného adresního prostoru.

Proto má prohlížeč záložku ve vlastním procesu, a ne ve vlákně - Chrome od vydání v roce 2008, Firefox od roku 2017. Cena je paměť, výnos je, že spadlá stránka nevezme s sebou zbylých třicet.

Když potřebuješ sdílet velkou datovou strukturu, ber vlákna. Předání ukazatele nestojí nic; sdílená paměť přes meziprocesovou komunikaci je práce navíc.

Kdy vlákna nepomůžou vůbec

Úloha omezená diskem nebo sítí. Osm vláken, která čekají na read z /dev/nvme0n1, čeká osmkrát vedle sebe. Když je úzké hrdlo ve vstupu a výstupu, vlákna přidají jen režii na přepínání.

GIL v CPythonu. Globální zámek interpretu dovolí vykonávat bajtkód vždycky jen jednomu vláknu. Osm vláken na výpočtu v čistém Pythonu běží na jednom jádře, kvůli přepínání pomaleji než jedno.

Na výpočet tedy vezmi procesy přes multiprocessing, na čekání vlákna nech - GIL se při blokujícím volání uvolňuje. Od Pythonu 3.13 (rok 2024) je volitelný i sestavovací režim bez GIL, ale je nový a část rozšíření v C s ním neběží.

Co se s vlákny nedá dělat bezpečně

Nereentrantní funkce. strtok, gmtime nebo getpwnam drží výsledek ve statické proměnné, kterou ti druhé vlákno přepíše mezi zavoláním a přečtením. Používej varianty s _r.

Sdílený stav bez zámku. Ani i++ není atomické, je to načtení, přičtení a zápis, a mezi tím se dá přepnout. Nedá se to obejít tím, že „ta operace je přece rychlá" - patří na to zámek z kritické sekce.

Ozařovač Therac-25 měl v letech 1985 až 1987 šest známých nehod a nejméně tři mrtvé pacienty. Příčinou byl souběh mezi obsluhou klávesnice a nastavovací úlohou: když operátor přepsal zadání dost rychle, stroj vystřelil plnou dávku bez rozptylovače. Poučení: souběh se projeví jen při určitém časování a běžné testy ho nenajdou.

fork ve vícevláknovém programu

fork zkopíruje adresní prostor, ale potomek zdědí jen to vlákno, které fork zavolalo. Ostatní vlákna v potomkovi neexistují.

Zámky zdědí všechny, včetně zamčených. Když jiné vlákno zrovna drželo zámek uvnitř alokátoru, v potomkovi zůstane zamčený navždy - nikdo ho už neodemkne. První malloc pak zamrzne a nikdo neví proč.

POSIX to řeší tvrdě: mezi fork a exec smíš volat jen funkce bezpečné vůči signálům, což vylučuje malloc i printf. Doporučení: spouštěj potomka přes posix_spawn. Udělá obojí naráz a nedá ti šanci mezi tím něco pokazit.

Kolik vláken vlastně

Výpočet omezený procesorem: tolik vláken, kolik je jader. Na osmijádrovém stroji osm. Víc jich znamená jen víc přepínání kontextu a horší využití mezipaměti.

Čekání na vstup a výstup: víc vláken než jader, ale ne o řád. Začni dvojnásobkem počtu jader a změř to.

Deset tisíc vláken není plán. Každé dostane zásobník o implicitní velikosti 8 MiB - jen rezervace ve virtuálním adresním prostoru, fyzická paměť se přidělí podle dotčených stránek. Pořád je to ale deset tisíc úloh pro plánovač.

ulimit -s                          # velikost zásobníku v KiB, na Debianu 8192
cat /proc/sys/kernel/threads-max   # strop pro celý systém

Deset tisíc spojení se řeší asynchronním vstupem a výstupem - epoll nebo io_uring a hrstka vláken obsluhujících hotové události. Nginx tak drží desítky tisíc spojení osmi procesy.

Diagnostika

top -H -p 1234                     # vlákna procesu na samostatných řádcích
ps -eLf                            # všechna vlákna v systému
grep Threads /proc/1234/status     # neroste jejich počet?
gdb -p 1234                        # připojení k běžícímu procesu, běh se zastaví

V gdb pak stačí thread apply all bt - vypíše zásobník všech vláken naráz. Tohle je nejrychlejší cesta k uváznutí: když dvě vlákna stojí v pthread_mutex_lock, máš odpověď a jdeš na uváznutí.

Příznak Kde je problém
Program stojí, procesor na nule čeká se na zámek nebo na vstup a výstup, uváznutí
Procesor na 100 %, nic se neděje aktivní čekání ve smyčce nebo živé uváznutí
Výkon klesá s přibývajícími vlákny rvačka o jeden zámek nebo falešné sdílení
Jedno vlákno na 100 %, ostatní nic práce se nerozdělila, nebo GIL
Chyba nastane jen pod zátěží souběh nad sdíleným stavem bez zámku
Potomek po fork zamrzne zděděný zamčený zámek
Paměť roste s počtem vláken zásobníky, zkontroluj ulimit -s

Falešné sdílení je z té tabulky nejméně čitelná položka. Dvě vlákna zapisují do různých proměnných, které leží ve stejném řádku mezipaměti (na x86-64 64 bajtů), a hardware ten řádek posílá mezi jádry tam a zpět. Lék je odsadit proměnné na hranici řádku.

Co si odnést

Vlákno je úloha, která sdílí adresní prostor. Vlastní má jen zásobník, registry a errno.

V Linuxu je vlákno i proces clone, jen s jinými příznaky. Jádro plánuje úlohy, ne procesy.

Vlákna zrychlí jen rozdělitelnou úlohu omezenou procesorem. Na čekání na disk nebo síť nepomůžou.

Izolace proti pádu se dělá procesy. Jeden SIGSEGV položí celou skupinu vláken.

Sdílený stav bez zámku je chyba, i když ti to zatím vychází. Testy souběh nenajdou.

Osm jader znamená osm výpočetních vláken, ne osmdesát. Na deset tisíc spojení patří asynchronní vstup a výstup.

fork ve vícevláknovém programu je past. Použij posix_spawn.

Kam dál