Operační systémy
Obsah Soubory
Procesy

Procesy

Aktualizováno 9 min čtení 1 659 slov

Procesy, čili co je to vlastně spuštěný program

„Program" a „proces" se běžně používají zaměnitelně a je to zdroj nedorozumění, který se táhne až do produkce. Program je soubor na disku. Leží tam, nedělá nic a bude tam ležet i za rok.

Proces je běžící instance toho souboru. Stejný program může běžet padesátkrát naráz a každá instance má vlastní paměť, vlastní stav a vlastní osud. Napiš si ps aux | grep firefox - neuvidíš jeden proces, ale dvacet a víc, jeden rodičovský a k tomu další pro karty, grafiku a zvuk. „Jeden Firefox" je kolonie.

Stránka je o tom, co proces doopravdy je, jak vzniká, jak umírá a co se na tom láme. Neřeší, kdo dostane procesor - to je plánování procesů - ani mechaniku přepínání kontextu. Předpokládá systémová volání a rozdíl mezi režimem jádra a uživatelským.

Tři věci, nic víc

Tohle je nejdůležitější věc na celé stránce: proces je adresní prostor plus stav procesoru plus tabulka otevřených souborů.

To je celé. Zbytek stránky je jen rozvedení téhle věty.

Adresní prostor je mapa toho, co proces vidí v paměti - kód, data, halda, zásobník, namapované soubory. Detaily má správa paměti a stránkování.

Stav procesoru jsou registry, hlavně ukazatel instrukcí a ukazatel zásobníku. Odchází z procesoru - uloží se. Vrací se - obnoví se.

Tabulka otevřených souborů je pole deskriptorů: 0 vstup, 1 výstup, 2 chyby, dál všechno, co si proces otevřel.

Všechno, co jádro s procesy dělá, je ukládání a obnovování téhle trojice.

Co si jádro o procesu pamatuje

Struktura, kde je ta trojice zapsaná, se jmenuje PCB (Process Control Block), v Linuxu struct task_struct. Je v ní PID a PPID, stav, uložené registry, ukazatel na tabulky stránek, tabulka deskriptorů, UID, priorita, naběhaný čas, maska a fronta signálů a ukazatel na rodiče. Na x86-64 vyjde řádově na deset kilobajtů podle konfigurace jádra a ke každému úkolu patří navíc 16 KiB zásobníku pro běh v jádře.

V Linuxu je task_struct na jeden úkol, ne na proces. Proces je skupina úkolů, které sdílejí adresní prostor - proto se procesy i vlákna vytvářejí týmž voláním clone. Přečteš si ji přes cat /proc/1234/status.

Stavy procesu a přechody mezi nimi

Klasických stavů je pět: nový, připravený, běžící, čekající a ukončený. Procesor je jeden na jádro, takže drtivá většina procesů nikdy neběží, jen čeká.

flowchart TD
    N[Nový] --> P[Připravený]
    P -->|plánovač vybere| B[Běžící]
    B -->|vyprší kvantum| P
    B -->|čeká na I/O nebo zámek| C[Čekající]
    C -->|data dorazila| P
    B -->|exit| U[Ukončený, čeká na rodiče]
    U -->|rodič zavolá wait| K[Uklizeno]

Nejzajímavější je poslední hrana: proces po exit ještě chvíli existuje. V ps odpovídají stavům písmena ve sloupci STAT:

R - běží nebo je připravený; ps to nerozlišuje.

S - přerušitelný spánek, sem patří skoro všechno.

D - nepřerušitelný spánek uvnitř jádra.

T - zastavený po Ctrl+Z, rozběhne ho kill -CONT.

Z - zombie: skončil a nikdo nepřečetl návratový kód.

I - nečinné jádrové vlákno, do zátěže se nepočítá.

Stav D se vyplatí vysvětlit, protože se o něj lidi pravidelně zabíjejí. Proces sedí v místě jádra, které signály vůbec nekontroluje, typicky uprostřed čtení z disku. kill -9 na něj funguje jen zdánlivě: signál se uloží do fronty a čeká, až se proces vrátí do uživatelského režimu. Dokud operace neskončí nebo neselže, nevrátí se a nezabiješ ho ničím.

Proces v D není zaseknutý program, je to zaseknuté zařízení. Umírající NVMe, odpojený NFS server - hledej příčinu v I/O. Do zátěže se D počítá, takže load average 40 na nevytíženém procesoru je skoro vždycky tohle.

Jak proces vzniká

Nový proces vyrobí jedině jiný proces - kromě toho jediného, který nastartuje jádro při startu systému.

fork a exec: proč dvě volání a ne jedno

fork udělá kopii volajícího procesu. exec v aktuálním procesu zahodí adresní prostor a nahradí ho jiným programem. Spuštění programu je tedy fork a po něm exec.

fork kopíruje adresní prostor, tabulku deskriptorů, pracovní adresář, masku signálů a prostředí. Nekopíruje PID, ostatní vlákna, zámky souborů ani naběhaný čas.

Adresní prostor se přitom doopravdy nekopíruje. Obě strany dostanou tytéž fyzické stránky jen pro čtení a teprve zápis vyrobí soukromou kopii jedné stránky. Tomu se říká copy-on-write a je to důvod, proč jde forknout i proces s osmi gigabajty haldy.

Návratová hodnota je celý trik: potomek dostane nulu, rodič PID potomka. Jedno volání, dva návraty.

pid_t pid = fork();
if (pid == 0) {
    close(1);                         // zavře standardní výstup
    open("vystup.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);  // dostane deskriptor 1
    execlp("ls", "ls", "-l", NULL);   // odsud dál je to jiný program
    _exit(127);                       // sem se dojde, jen když exec selhal
}
waitpid(pid, &status, 0);             // rodič si vyzvedne návratový kód

A tady je pointa. Mezi fork a exec je okamžik, kdy potomek existuje, ale nový program ještě neběží - a v něm se dá s procesem cokoliv udělat. Přesně to dělá shell u ls -l > vystup.txt. Program ls o přesměrování neví a nemusí umět nic než psát na výstup.

Co tím Windows ztrácí

Windows nemá fork. Má CreateProcess, které postaví proces a rovnou do něj nahraje program - jedno volání s deseti parametry. Přesměrování se řeší vyplněním struktury STARTUPINFO a povolením dědění deskriptorů.

Ztrácí se ten mezikrok: co nejde vyjádřit parametry CreateProcess, nejde vůbec. Model fork a exec je obecnější, ale platí se za něj kopírováním tabulek stránek.

Jak proces končí

Zombie a sirotci

Když proces skončí, jádro uvolní paměť, zavře soubory a zahodí adresní prostor. Nechá si ale task_struct s návratovým kódem, protože ta informace patří rodiči. Do té doby je proces zombie.

Zombie se nedá zabít, protože už je mrtvý; kill -9 na něj nemá žádný efekt. Uklidí ho jediná věc: rodič zavolá wait nebo waitpid a přečte si návratový kód.

Když rodič skončí dřív než potomek, je z potomka sirotek a jádro ho přepojí pod PID 1, který volá wait ve smyčce. Odtud plyne zdánlivý paradox: hromada zombie zmizí, jakmile zabiješ jejich rodiče.

Proč v kontejneru zůstávají zombie

V kontejneru je PID 1 tvoje aplikace, ne init. Node ani Java o té roli nic nevědí, wait ve smyčce nevolají, a tak se pod nimi zombie hromadí, dokud nevyčerpají kvótu úkolů.

Druhá půlka problému je horší: PID 1 má potlačené výchozí obsluhy signálů. Bez vlastní obsluhy SIGTERM aplikaci docker stop nezabije a čeká deset vteřin na SIGKILL. Řešení je pro obojí stejné - dej jako vstupní bod docker run --init nebo tini. Zbytek má virtualizace a kontejnery.

Strom procesů, skupiny a sezení

Každý proces má rodiče, takže procesy tvoří strom. Kořenem je PID 1, na Debianu systemd; podívej se na něj přes pstree -p 1234.

Nad stromem leží druhé členění, o kterém většina lidí neví, dokud je nekopne. Procesy se sdružují do skupin a skupiny do sezení. Jedna roura je jedna skupina - sort data.txt | uniq -c | head jsou tři procesy v jedné skupině a Ctrl+C míří na celou skupinu popředí. Proto se roura zastaví celá.

Sezení je svázané s terminálem. Když spadne SSH, dostane skupina popředí SIGHUP a implicitně umře. Proto existuje nohup, který SIGHUP ignoruje, a setsid, který proces posadí do vlastního sezení. U dlouhoběžících věcí je ale správná odpověď udělat z toho službu systemd.

Signály jsou nejjednodušší způsob, jak procesu něco říct: jedno číslo, žádná data. SIGTERM znamená ukonči se, SIGKILL nejde odchytit, SIGCHLD ohlásí konec potomka. Výklad je na meziprocesové komunikaci.

Kolik proces stojí a kdy sáhnout po vlákně

Prázdný proces stojí v jádře řádově desítky kilobajtů. Triviální céčkový program přidá pár set kilobajtů obsazené paměti a vytvoření vyjde na desítky až stovky mikrosekund.

Vlastnost Proces Vlákno
Adresní prostor vlastní sdílený
Tabulka deskriptorů kopie při fork sdílená
Cena vytvoření desítky až stovky µs jednotky až desítky µs
Pád jedné části ostatní běží dál strhne celý proces

Doporučení: procesy tam, kde spolu části komunikují málo, vlákna jen tam, kde sdílejí velkou datovou strukturu. Nginx obsluhuje spojení procesy, jeden na jádro - pád workeru nepoloží server. Výpočet nad dvougigabajtovou maticí naopak dělej vlákny.

Fork bomba a proč pořád funguje

Nejznámější ukázka toho, že proces je zdroj jako každý jiný, se vejde na řádek:

:(){ :|:& };:

Funkce : zavolá dvakrát sama sebe a obě volání pošle na pozadí. Počet procesů roste exponenciálně, tabulka procesů se zaplní a systém přestane reagovat. Zabít to nejde, protože i kill potřebuje ke spuštění fork, který už neprojde.

2. listopadu 1988 vypustil Robert Tappan Morris červa, který se šířil po tehdejším internetu. Ochranu proti opakované nákaze měl, ale schválně děravou - v jednom případě ze sedmi se nainstaloval i tam, kde už kopie běžela. Stroje se plnily kopiemi, dokud nebyly nepoužitelné; postihlo to zhruba 6 000 z nich. Poučení: nepoložila je chyba v kódu, ale nekontrolované vytváření procesů.

Obrana je dvojí a zapni obojí. ulimit -u omezí počet procesů na uživatele, trvale přes /etc/security/limits.conf. Služby a kontejnery hlídá kontrolér pids v cgroups - v systemd TasksMax=, v Dockeru --pids-limit.

systemd-run --user --scope -p TasksMax=50 bash  # shell s tvrdým stropem, na vyzkoušení

Co se dnes už nedělá

Démonizace dvojitým fork. Návody pořád ukazují dvojí fork, setsid a zápis PID do souboru. Pod systemd napiš Type=simple a zůstaň na popředí.

kill $(cat /var/run/foo.pid). PID se recykluje a číslo může mezitím dostat někdo jiný; strop ukáže sysctl kernel.pid_max. Používej systemctl stop, v kódu pidfd_open od jádra 5.3 (rok 2019).

Diagnostika

Příznak Kde je problém
Proces v D, kill -9 nezabírá čeká v jádře na I/O, signál dojde až po návratu
Tisíce zombie rodič nevolá wait
Zombie v kontejneru aplikace je PID 1 a nesklízí; nasaď --init
Proces po odhlášení zmizel dostal SIGHUP; chce to nohup nebo službu
kill zabil něco jiného recyklovaný PID, nespoléhej na PID soubory
ps a top hlásí jiný počet ps aux počítá procesy, top -H vlákna
fork: Resource temporarily unavailable vyčerpaný ulimit -u nebo pids.max

Tři příkazy pokryjí většinu případů:

ps -eo pid,ppid,stat,wchan:20,comm --sort=stat   # kdo v jakém stavu a v čem visí
ps -eo stat= | sort | uniq -c                    # rozložení stavů, hledej D a Z
cat /proc/1234/status                            # stav, rodič, počet vláken, signály

Sloupec wchan říká, ve které funkci jádra proces čeká, a u stavu D je to jediná užitečná informace. Zbytek má nástroje a diagnostika.

Co si odnést

Proces je adresní prostor, stav procesoru a tabulka deskriptorů. Zbytek je jen správa téhle trojice.

Program je soubor, proces je jeho instance. Jeden program, padesát procesů, padesát osudů.

fork vrací nulu potomkovi a PID rodiči. Mezikrok před exec je důvod, proč v shellu funguje přesměrování.

Copy-on-write dělá fork použitelným. Paměť se kopíruje až při zápisu a po stránkách.

Zombie není proces, je to nevyzvednutá účtenka. Sklidí ji wait rodiče, nebo po jeho smrti PID 1.

Stav D znamená zaseknuté zařízení. kill -9 je tam ztráta času.

Počet procesů je zdroj a musí mít strop. ulimit -u a pids v cgroups nastav předem.

Kam dál