Dnes se společně podíváme na vlákna a procesy. Nejdříve si vysvětlíme hlavní rozdíly mezi nimi a teoreticky si popíšeme základy fungování a pak se vrhneme na ukázku implementace v Pythonu.
Nejdříve si vysvětlíme základní pojmy, aby se nám pak dobře chápaly rozdíly mezi koncepty.
Sekvenční programy známe ze všech nejlépe. Jejich implementace je sadou příkazů a výpočtů, které se provedou jeden po druhém a na konci je doručen nějaký výsledek.
Paralelní programy jsou napsané tak, aby výpočty v nich obsažené mohly běžet paralelně - tedy aby v jednu chvíli mohl probíhat i více než jeden výpočet či nějaká operace.
Představte si hromadu dřeva ke štípání na zimu. U sekvenčního programování na to bude člověk sám a bude si brát polena jedno po druhém a bude je štípat, dokud nebude hotov. U paralelního přístupu se ke štípání přidá celá rodina a polena jsou tak zpracovávána několika lidmi zároveň.
Konkurenční programy jsou něco mezi sekvenčními a paralelními. Jednotlivé úlohy ke zpracování se sice mohou překrývat — v tom smyslu, že mezi začátkem a koncem jedné úlohy se může pracovat i na jiné úloze — ale nikdy není možné pracovat na dvou úlohách v tu samou chvíli.
Jako dobrý příklad poslouží náš každodenní rytmus. Běžný člověk vždy dělá jen jednu věc, ale může její dělání přerušit a v mezičase se věnovat něčemu jinému, a pak se k původní činnosti zase vrátit. Například začnete pracovat na domácím projektu, v polovině práci přerušíte a jdete si uvařit čaj. Než se ohřeje voda, umyjete nádobí. Pak doděláte čaj a vracíte se zpět k počítači pracovat na domácím projektu. Máme tady tři činnosti, které se mezi sebou překrývají, ale vždy se pracuje jen na jedné z nich.
Proces je běžící instance nějakého programu. Každý program běžící na počítači, ať už o něm víte nebo ne, je procesem. Proces musí mít k dispozici celou řadu informací: např. instrukce, které má vykonat, svou paměť, alokované zdroje v počítači a další - souhrně se tento balík informací nazývá kontext. Operační systém pak mezi jednotlivými procesy přepíná a tím umožňuje, aby všechny běžely a mohly vykonávat svou práci. Pokud má procesor více jader, může běžet i více procesů najednou.
Vlákno je součástí procesu a nemůže existovat bez něj. Jeden proces může typicky spustit několik vláken a ty mohou v závislosti na implementaci a programovacím jazyce fungovat paralelně či konkurenčně. Protože existuje v rámci procesu, sdílí vlákno paměť, alokované zdroje a další informace s ostatními vlákny v témže procesu.
V obou případech, pokud se jedná o přístup ke sdílené paměti, je potřeba přístupy synchronizovat, aby se nestalo, že jeden proces/vlákno přepisuje data, která potřebuje jiný proces/vlákno.
Interpret Pythonu napsaný v jazyce C (CPython) — ten nejznámější a nejpoužívanější — spravuje paměť efektivním způsobem, ale jeho správa paměti není tzv. „thread-safe“. To znamená, že by mohlo dojít k situaci, kdy by jedno vlákno rozbíjelo paměť jiného vlákna. Aby se to nestalo, GIL zajistí, že vlákna v Pythonu nikdy nepoběží paralelně.
Protože GIL existuje hlavně kvůli ochraně paměti, mohou vlákna za určitých předpokladů běžet paralelně, ale nesmí při tom pracovat s (Python) objekty v paměti a spouštět příkazy Pythonu. Dobrým příkladem může být situace, kdy jedno vlákno čeká na informace z nějakého externího zdroje (z internetu například). V takovém případě se GIL odemkne a jiné vlákno může pracovat na svých úkolech a spouštět Python kód i s prací v paměti. Existují i knihovny pro různé výpočty (např. numpy), které při svých kalkulacích neuzamykají GIL a tak mohou běžet paralelně i ve vláknech. Odemykání a zamykání GILu je záležitostí implementace interpretru a není tedy možné to nijak ovlivnit z Pythonu přímo.
Jiné interpretry Pythonu — např. Jython napsaný v Javě nebo IronPython napsaný v C# — GIL nemají a je v nich tedy možné využít vlákna skutečně naplno i pro paralelní programování.
Prvním příkladem budeme demonstrovat vhodné použití vláken. Stáhneme si z internetu několik PEP dokumentů. Podstatné je, že v rámci zpracování dávky úloh bude program trávit spoustu času čekáním, takže by nám mohly vlákna pomoci zkrátit jeho běh. Zároveň víme, že při připojování se k webovým serverům bude odemknut GIL.
Zkusme si to nejdříve bez vláken. Definujeme si funkci, která nám zvládne dokument stáhnout a zjistit z něj titulek stránky.
import requests
import re
def download(PEP):
PEP = str(PEP)
PEP = PEP.zfill(4)
url = f"https://www.python.org/dev/peps/pep-{PEP}/"
html = requests.get(url).text
search = re.search('<title>(.*)</title>', html, re.IGNORECASE)
print(search.group(1))
A nyní si takových dokumentů stáhneme dvacet pěkně jeden po druhém.
Máme k dispozici seznam existujících PEP dokumentů resp. jejich čísel, ze kterého si oněch dvacet vždy náhodně vybereme, abychom zabránili vlivu ukládání mezivýsledků na naše měření.
all_PEPs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 20, 42, 100, 101, 102, 103, 160, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284, 285, 286, 287, 288, 289, 290, 291, 292, 293, 294, 295, 296, 297, 298, 299, 301, 302, 303, 304, 305, 306, 307, 308, 309, 310, 311, 312, 313, 314, 315, 316, 317, 318, 319, 320, 321, 322, 323, 324, 325, 326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 348, 349, 350, 351, 352, 353, 354, 355, 356, 357, 358, 359, 360, 361, 362, 363, 364, 365, 366, 367, 368, 369, 370, 371, 372, 373, 374, 375, 376, 377, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 389, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 424, 425, 426, 427, 428, 429, 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440, 441, 442, 443, 444, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466, 467, 468, 469, 470, 471, 472, 473, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486, 487, 488, 489, 490, 491, 492, 493, 494, 495, 496, 497, 498, 499, 500, 501, 502, 503, 504, 505, 506, 507, 508, 509, 510, 511, 512, 513, 514, 515, 516, 517, 518, 519, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530, 531, 532, 533, 534, 535, 536, 537, 538, 539, 540, 541, 542, 543, 544, 545, 546, 547, 548, 549, 550, 551, 552, 553, 554, 555, 556, 557, 558, 559, 560, 561, 562, 563, 564, 565, 566, 567, 568, 569, 570, 571, 572, 573, 574, 575, 576, 577, 578, 579, 580, 628, 666, 754, 801, 3000, 3001, 3002, 3003, 3099, 3100, 3101, 3102, 3103, 3104, 3105, 3106, 3107, 3108, 3109, 3110, 3111, 3112, 3113, 3114, 3115, 3116, 3117, 3118, 3119, 3120, 3121, 3122, 3123, 3124, 3125, 3126, 3127, 3128, 3129, 3130, 3131, 3132, 3133, 3134, 3135, 3136, 3137, 3138, 3139, 3140, 3141, 3142, 3143, 3144, 3145, 3146, 3147, 3148, 3149, 3150, 3151, 3152, 3153, 3154, 3155, 3156, 3333]
import random
PEPs = random.sample(all_PEPs, 20)
%%time
for PEP in PEPs:
download(PEP)
Při spuštění je celý průběh viditelně sekvenční a jednotlivé titulky se objevují jeden po druhém s prodlevou mezi nimi.
Nyní necháme stejnou úlohu zpracovat pomocí dvaceti vláken (pro každý náhodný dokument jedno vlákno).
PEPs = random.sample(all_PEPs, 20)
%%time
import threading
threads = []
for PEP in PEPs:
thread = threading.Thread(target=download, args=(PEP,))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Pomocí třídy Thread
z modulu threading
si vytvoříme vlákno. Tomu pomocí dvou parametrů předáme funkci, kterou má spustit, a parametr, který má při spuštění funkci předat. Vlákno spustíme a uložíme do seznamu, abychom se k němu mohli později vrátit.
Druhý for
cyklus nám pak prochází seznam vláken a čeká dokud nejsou jednotlivá vlákna hotova se svou prací. Takto si můžeme zajistit, že program bude pokračovat až poté, co všechna vlákna dokončila své úlohy.
Jak je vidět, zpracování pomocí vláken trvalo jen zlomek času. Je důležité mít na paměti, že vlákna mohla běžet paralelně jen v určitém okamžiku a jen díky tomu, že všechna vlákna čekala na spojení se serverem a odpovědi od něj. Při zpracování Python kódu už to kvůli GILu nebylo možné a v jednu chvíli fungovalo jen jedno vlákno. Při sekvenčním zpracování se ale spousta času tráví čekáním a tak použití vláken vedlo k razantnímu zrychlení.
Tato implementace je velice jednoduchá a užitečná především pro jednoduché úlohy, kde si vystačíme se spouštěním jedné funkce v každém vlákně. Druhou možností je implementovat si vlastní třídu jako podtřídu třídy Thread
reprezentující vlákno a vše potřebné implementovat do ní.
Zkusme si tentýž příklad implementovat pomocí procesů.
PEPs = random.sample(all_PEPs, 20)
%%time
import multiprocessing
processes = []
for PEP in PEPs:
process = multiprocessing.Process(target=download, args=(PEP,))
process.start()
processes.append(process)
for process in processes:
process.join()
Implementace je velmi podobná díky tomu, že moduly threading
a multiprocessing
mají velmi podobná rozhraní, což umožňuje v určitých fázích vývoje mezi těmito koncepty přepínat bez složitého přepisování kódu.
Výsledek ale není tak rychlý jako v případě vláken i když procesy nebrzdí žádný GIL (každý proces má vlastní) a mohou běžet skutečně paralelně po celou dobu. Na vině je hlavně vysoká náročnost vytvoření a ukončení každého procesu.
Druhý příklad bude zcela odlišný. V prvním jsme trávili čas čekáním na externí zdroje, ale druhý bude spíše náročný na náš vlastní výpočetní výkon. Procesor zaměstnáme jednoduchou matematickou kalkulací, která ale bude počítat s vysokými čísly.
Čísla pro výpočet si opět necháme generovat náhodně, abychom zabránili použití výsledků z předchozích výpočtů.
def calculate():
x = random.randint(100000, 999999)
y = random.randint(10000, 99999)
result = x**y
result = str(result)
print(f"{x} ** {y} = {result[:10]}")
%%time
for _ in range(5):
calculate()
Není překvapením, že takto složitý výpočet nějakou chvíli zabere. Sekvenční zpracování tomu také nepřidá a je znát, že každý výpočet může trvat trochu jinou dobu v závislosti na velikosti čísel, i když mají všechna alespoň stejný řád. Výsledná čísla jsou navíc tak dlouhá, že by jejich vypsání bylo delší než celá dnešní lekce.
Pojďme opět vyzkoušet vyřešit stejnou úlohu pomocí vláken.
%%time
import threading
threads = []
for _ in range(5):
thread = threading.Thread(target=calculate)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
Zdá se, že se zrychlením tohoto příkladu nám vlákna příliš nepomohou. Není se čemu divit. Veškeré operace v naší funkci calculate
se provádějí na úrovni Pythonu a není v ní jediný moment, kdy by se dal odemknout GIL. To způsobuje, že všechna vlákna mohou běžet konkurenčně, ale ani chvíli neběží paralelně. Když k tomu ještě přidáme nějaký čas, který vytvoření a ukončení vlákna zabere, máme výsledek ještě o kus horší než v případě sekvenčního zpracování.
Co na to procesy?
%%time
import multiprocessing
processes = []
for _ in range(5):
process = multiprocessing.Process(target=calculate)
process.start()
processes.append(process)
for process in processes:
process.join()
I když je vytvoření a ukončení procesu časově ještě náročnější než u vláken, stihl se výpočet rychleji než v případě sekvenčního zpracování. Paralelní výpočet mocniny totiž dokázal ušetřit tolik času, že výsledný součet časů je lepší než v předchozích dvou příkladech.
Z příkladů je vidět, že je vždy nutné si rozmyslet dopředu, zda budeme chtít pro naši aplikaci použít vlákna či procesy.
Vlákna v Pythonu se hodí tam, kde úloha není výpočetně náročná a tráví nějaký čas čekáním na externí zdroje, vstup uživatele nebo spaním. Komunikace mezi nimi je snadná, protože sdílí paměť a jejich vytváření a ukončování není moc časově náročné. Použitím vláken pro tu správnou úlohu se můžeme přiblížit paralelnímu zpracování i s jedním procesem.
Procesy se hodí všude tam, kde potřebujeme opravdové paralelní zpracování bez ohledu na GIL. Komunikace mezi nimi není tak jednoduchá a jejich vytváření a ukončování je časově náročné, takže se pro některé úlohy ani nemusí ve výsledku vyplatit.
V obou případech je nutné mít na paměti režii, paměťovou náročnost a další nevýhody, které z použití vláken či procesů plynou. Často je také fronta úloh ke zpracování obrovská a bylo by velmi nehospodárné vytvářet pro každou úlohu samostatné vlákno/proces, a proto se vytvoří skupina vláken/procesů, které si postupně berou zadání z fronty a vykonávají je.
Příště si ukážeme složitejší implementace a řízení toku paralelních programů a výměnu informací mezi vlákny/procesy.
Zkuste si naimplementovat podobně jednoduchou úlohu, jako jsme udělali dnes společně. Vyberte si pro její implementaci buď vlákna nebo procesy a svůj výběr zdůvodněte. Můžete navíc porovnat svou implementaci se sekvenčním během a zjistit, zda je aplikace méně časově náročná či nikoli.