Nauč se Python > Kurzy > Python a jeho knihovny > Vlákna a procesy — paralelní programování > Paralelní programování

Vlákna a procesy

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.

teorie vs realita

Sekvenční, konkurenční a paralelní programy

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 vs Vlákno

Proces

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

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.

Hlavní rozdíly mezi procesem a vláknem

Vytvoření

  • P: Vytvoření procesu je náročná operace, protože musí dojít ke kopii původního procesu (rodiče) se vším všudy, což může trvat docela dlouho.
  • V: Vytvoření vlákna je rychlé, protože vše, co má vlákno k dispozici je sdílené v rámci procesu, který vlákno vytvořil.

Přepínání

  • P: Přepnout kontext u procesů je poměrně náročné a „drahé“.
  • V: Přepínání vláken je rychlé a méně náročné.

Práce s pamětí

  • P: Proces má vlastní paměť a nikdo mu v ní nic nemůže změnit. Díky tomu jsou procesy více paměťově náročně, protože i „stejné“ procesy obsahují kopie proměnných svého rodiče. Existuje i výjimka v podobě sdílené paměti, která se používá pro komunikaci mezi procesy.
  • V: Vlákna sdílí paměť v rámci jednoho procesu, takže si ji mohou navzájem přepisovat. Je možné to využít jako výhodu při komunikaci mezi vlákny, ale je potřeba si dát pozor, aby z toho nevznikla chyba a nesmyslné výsledky.

Komunikace

  • P: Procesy mohou mezi sebou sdílet paměť, nebo si zasílat zprávy.
  • V: Vlákna mají sdílenou paměť automaticky, takže si jen v rámci paměti předávají data.

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.

Chyby

  • P: Chyba v procesu neovlivní žádný další proces (kromě případů zanesení chyby do sdílené paměti).
  • V: Chyba v jednom vlákně může shodit celý proces a ukončit tak všechna běžící vlákna.

GIL (Global Interpreter Lock)

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

Konkrétní příklady

Příklad 1. — Úloha nenáročná na procesor, ale na čas (čeká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.

In [1]:
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í.

In [2]:
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]
In [3]:
import random

PEPs = random.sample(all_PEPs, 20)
In [4]:
%%time

for PEP in PEPs:
    download(PEP)
PEP 358 -- The &quot;bytes&quot; Object | Python.org
PEP 523 -- Adding a frame evaluation API to CPython | Python.org
PEP 335 -- Overloadable Boolean Operators | Python.org
PEP 471 -- os.scandir() function -- a better and faster directory iterator | Python.org
PEP 415 -- Implement context suppression with exception attributes | Python.org
PEP 508 -- Dependency specification for Python Software Packages | Python.org
PEP 219 -- Stackless Python | Python.org
PEP 364 -- Transitioning to the Py3K Standard Library | Python.org
PEP 447 -- Add __getdescriptor__ method to metaclass | Python.org
PEP 10 -- Voting Guidelines | Python.org
PEP 489 -- Multi-phase extension module initialization | Python.org
PEP 450 -- Adding A Statistics Module To The Standard Library | Python.org
PEP 3101 -- Advanced String Formatting | Python.org
PEP 529 -- Change Windows filesystem encoding to UTF-8 | Python.org
PEP 391 -- Dictionary-Based Configuration For Logging | Python.org
PEP 474 -- Creating forge.python.org | Python.org
PEP 483 -- The Theory of Type Hints | Python.org
PEP 296 -- Adding a bytes Object Type | Python.org
PEP 230 -- Warning Framework | Python.org
PEP 494 -- Python 3.6 Release Schedule | Python.org
CPU times: user 696 ms, sys: 71.9 ms, total: 768 ms
Wall time: 12.2 s

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

In [5]:
PEPs = random.sample(all_PEPs, 20)
In [6]:
%%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()
PEP 333 -- Python Web Server Gateway Interface v1.0 | Python.orgPEP 365 -- Adding the pkg_resources module | Python.org

PEP 327 -- Decimal Data Type | Python.org
PEP 397 -- Python launcher for Windows | Python.org
PEP 404 -- Python 2.8 Un-release Schedule | Python.org
PEP 201 -- Lockstep Iteration | Python.org
PEP 421 -- Adding sys.implementation | Python.org
PEP 461 -- Adding % formatting to bytes and bytearray | Python.org
PEP 372 -- Adding an ordered dictionary to collections | Python.org
PEP 454 -- Add a new tracemalloc module to trace Python memory allocations | Python.org
PEP 451 -- A ModuleSpec Type for the Import System | Python.org
PEP 428 -- The pathlib module -- object-oriented filesystem paths | Python.org
PEP 382 -- Namespace Packages | Python.org
PEP 522 -- Allow BlockingIOError in security sensitive APIs | Python.org
PEP 453 -- Explicit bootstrapping of pip in Python installations | Python.org
PEP 525 -- Asynchronous Generators | Python.org
PEP 288 -- Generators Attributes and Exceptions | Python.org
PEP 264 -- Future statements in simulated shells | Python.org
PEP 271 -- Prefixing sys.path by command line option | Python.org
PEP 284 -- Integer for-loops | Python.org
CPU times: user 591 ms, sys: 114 ms, total: 704 ms
Wall time: 1.29 s

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ů.

In [7]:
PEPs = random.sample(all_PEPs, 20)
In [8]:
%%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()
PEP 382 -- Namespace Packages | Python.org
PEP 3141 -- A Type Hierarchy for Numbers | Python.org
PEP 263 -- Defining Python Source Code Encodings | Python.org
PEP 212 -- Loop Counter Iteration | Python.org
PEP 3134 -- Exception Chaining and Embedded Tracebacks | Python.org
PEP 479 -- Change StopIteration handling inside generators | Python.org
PEP 503 -- Simple Repository API | Python.org
PEP 5 -- Guidelines for Language Evolution | Python.org
PEP 233 -- Python Online Help | Python.org
PEP 469 -- Migration of dict iteration code to Python 3 | Python.org
PEP 310 -- Reliable Acquisition/Release Pairs | Python.org
PEP 431 -- Time zone support improvements | Python.org
PEP 496 -- Environment Markers | Python.org
PEP 3145 -- Asynchronous I/O For subprocess.Popen | Python.org
PEP 3100 -- Miscellaneous Python 3.0 Plans | Python.org
PEP 580 -- The C call protocol | Python.org
PEP 467 -- Minor API improvements for binary sequences | Python.org
PEP 3108 -- Standard Library Reorganization | Python.org
PEP 437 -- A DSL for specifying signatures, annotations and argument converters | Python.org
PEP 270 -- uniq method for list objects | Python.org
CPU times: user 42.9 ms, sys: 78 ms, total: 121 ms
Wall time: 1.27 s

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.

Příklad 2. — Úloha náročná na výpočetní výkon

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ů.

In [9]:
def calculate():
    x = random.randint(100000, 999999)
    y = random.randint(10000, 99999)
    result = x**y
    result = str(result)
    print(f"{x} ** {y} = {result[:10]}")
In [10]:
%%time

for _ in range(5):
    calculate()
452296 ** 29243 = 3368134854
990055 ** 61268 = 1135804142
990913 ** 44484 = 4409007575
314351 ** 56411 = 4665429890
648679 ** 38155 = 9965272562
CPU times: user 4.52 s, sys: 5.5 ms, total: 4.53 s
Wall time: 4.52 s

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.

In [11]:
%%time

import threading

threads = []

for _ in range(5):
    thread = threading.Thread(target=calculate)
    thread.start()
    threads.append(thread)

for thread in threads:
    thread.join()
528619 ** 93678 = 3700798126
951475 ** 90776 = 1001659490
366690 ** 95209 = 2237796407
451949 ** 93646 = 3196998348
109731 ** 80272 = 2072726100
CPU times: user 15.3 s, sys: 59.6 ms, total: 15.3 s
Wall time: 15.3 s

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?

In [12]:
%%time

import multiprocessing

processes = []

for _ in range(5):
    process = multiprocessing.Process(target=calculate)
    process.start()
    processes.append(process)

for process in processes:
    process.join()
197028 ** 39557 = 4386064607
735421 ** 47727 = 1462047894
128422 ** 86890 = 4786322493
273042 ** 91750 = 1128828926
630639 ** 90597 = 5538513813
CPU times: user 7.81 ms, sys: 17.8 ms, total: 25.6 ms
Wall time: 3.56 s

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ávěr

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.

Úkol

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.


Toto je stránka lekce z kurzu, který probíhá nebo proběhl naživo s instruktorem.