Vícevláknové aplikace vládnou světu. Každá interaktivní aplikace (desktopová či serverová) potřebuje pracovat v několika vláknech, abychom my, rozmazlení uživatelé, měli pocit, že všechny naše požadavky počítač provádí okamžitě a jenom pro nás. Dozvíte se něco obecně o vláknech, ale také, jak se s vlákny pracuje v Adě. Ada totiž pro tuto oblast nabízí speciální jazykové konstrukce. Zkrátka něco úplně jiného než vlákna známá z Linuxu, Win32, Javy a .NET.
Trocha terminologie
V terminologii je (jako obvykle) zmatek. Microsoft různé věci nazývá jinak než unixové systémy, něco jiného si přečtete v uživatelské dokumentaci, něco jiného v programátorské. Vlastní terminologii si zavádí i různé programovací jazyky. Do toho se ještě pletou různé překlady. Já budu používat pojem proces jako objekt v operačním systému, který obsahuje jedno nebo více vláken(threads) provádění. Všechna vlákna daného procesu sdílejí jeden adresový prostor, každé vlákno má však svůj vlastní zásobník. Jazyk Ada definuje termín task jako synonymum k pojmu vlákno.
K čemu jsou ta vlákna dobrá?
Představte si, že máte emailového klienta. Když napíšete mail a stisknete tlačítko Odeslat, musí tato aplikace provést nějaké akce. Musí navázat spojení s nějakým poštovním serverem a přenést mail. To může trvat třeba desítky sekund. Pokud by se všechno provádělo v jednom vlákně, váš klient by na tuto dobu jednoduše zamrzl, což by vás jako uživatele moc nepotěšilo, chcete přece psát další mail. Pomocí vláken se tato situace vyřeší velice elegantně. Po stisknutí vašeho Odeslat vytvoří váš emailový klient nové vlákno, které pověří odesláním mailu. Vytvoření nového vlákna proběhne velice rychle a vy můžete okamžitě začít psát další mail. Že nějaké další vlákno tráví dvacet sekund odesíláním mailu, vás nezajímá.
Co vlákna obnášejí?
Po spuštění procesu operační systém nastartuje právě jedno vlákno provádění. Další vlákna tedy musí nějak vytvořit programátor. Obvykle zavoláním nějaké funkce, která je přístupná v tom či onom programovacím jazyku a která má obvykle přímou návaznost na příslušnou rutinu operačního systému. Takové funkci se typicky předá adresa nějakého podprogramu, který se použije jako výchozí bod nového vlákna. Dále jsou potřeba nějaké prostředky pro řízení vytvořených vláken. Vlákna si potřebují předávat informace, často je třeba zaručit, že dvě vlákna nebudou najednou pracovat nad jednou globální proměnnou atp. K tomu slouží různé kritické sekce, zámky, semafory, mutexy, monitory. Nebudu se zde těmito termíny zabývat, důležité je, že pro práci s těmito objekty zase existují funkce jádra operačního systému a tím pádem i funkce v jazycích.
Kde, jak a čím
Pokud se programuje na nejnižší úrovni, pak se obvykle přímo volají funkce OS. Například pro vytvoření nového vlákna je to v Linuxu pthread_create, pod Win32 CreateThread. Pokud takto programujeme, výsledný kód není přenositelný. To se dá vyřešit nějakou knihovnou, která tuto nejnižší vrstvu překryje, ale my, leniví programátoři, chceme větší pohodlí. V Javě (a tedy i C#) je situace podobná, standardní knihovna poskytuje nějakou třídu, která je napojena na střeva Virtual Machine a umožňuje spuštění nějaké metody v novém vlákně. Java vícevláknovost podporuje i jazykovými konstrukcemi k synchronizaci vláken.
Na všechno zapomeňte, jdeme na Adu
Ano, přesně tak. V Adě je všechno jinak, její pohled na vlákna je mnohem komplexnější a na vyšší úrovni abstrakce. Vlákna (tasky) jsou v Adě naprosto speciální objekty. V některých ohledech lze na task pohlížet jako na typ a na jeho instance jako na proměnné. Začneme vícevláknovým Ahoj světe.
with Ada.Text_Io;
use Ada.Text_Io;
procedure Hello_Example is
task type Hello_Task is
-- Specifikace
-- Sem přijde definice rozhranní tasku,
-- ale ta je zde prázdná
end Hello_Task;
task body Hello_Task is
begin
-- Tělo tasku
Put_Line("Ahoj z vlákna");
end Hello_Task;
H1, H2, H3 : Hello_Task;
-- Zde jsou tři instance tasků, tedy tři vlákna,
-- která se spustí paralelně s hlavním vláknem
begin
-- Všechna čtyři jsou spuštěna za tímto beginem
Put_Line("Ahoj z primárního vlákna");
end Hello_Example;
Toto je kompletní funkční program. Nejprve nadefinujeme nový typ Hello_Task, který reprezentuje nějaké vlákno. Definice tohoto typu je rozdělena do dvou částí, specifikace a tělo. Specifikace je zde velice jednoduchá, vlastně definuje jenom jméno tasku. Tělo je též velice jednoduché, tento task prostě vypíše Ahoj z vlákna a skončí. Potom jsme vytvořili tři instance (H1, H2 a H3) typu Hello_Task, úplně stejně jako jakékoliv jiné proměnné. Řekli jsme tedy překladači, že chceme tři instance tasku Hello_Task a ten tyto tři instance spustí při vstupu do těla programu (tedy hned u následujícího beginu). V momentě kdy hlavní vlákno vypisuje Ahoj z primárního vlákna už běží celkem čtyři vlákna. Výstup z programu vypadá následovně:
Ahoj z vlákna Ahoj z vlákna Ahoj z primárního vlákna Ahoj z vlákna
Randezvous
Task, který jsme vytvořili, neumožňoval žádnou interakci s jinými tasky (jeho specifikace byla prázdná). Ada definuje elegantní systém pro předávání dat mezi jednotlivými tasky. Vychází se z toho, že pokud si dva tasky chtějí nějaká data vyměnit, musí k tomu dát oba souhlas, nějak se domluvit. Tomuto mechanismu se v Adě říká randezvous. Upravím tedy předešlý příklad tak, aby dělal něco užitečného, třeba počítal dvojnásobek celého čísla :-). Tedy bude potřeba předat tasku nějaký vstup a pak si od něj vyzvednout výsledek.
task type Zdvojnasob_Task is
-- Specifikace
entry Vstup(X : integer);
-- Randezvous pro výstup
entry Vysledek(X : out Integer);
-- Randezvous pro výstup
end Hello_Task;
task body Zdvojnasob_Task is
Task_X, Task_Vysledek : integer;
-- Zde jsou dvě proměnné, které patří tasku
begin -- Tělo tasku
accept Vstup(X : Integer) do
-- Přebrání vstupu a uložení do globální
-- proměnné tasku
Task_X := X;
end Vstup;
Task_Vysledek := 2*Task_X;
-- Zde se provedla ona užitečná akce
accept Vystup(X : out Integer) do
-- předání výsledku
X := Task_Vysledek;
end Vystup;
end Hello_Task;
Tělo hlavního programu pak vypadá takto:
Z1, Z2 : Zdvojnasob_Task;
Vysledek_1, Vysledek_2 : Integer;
begin
Z1.Vstup(5);
Z2.Vstup(50);
Z1.Vystup(Vysledek_1); -- Do výsledku 1 se uloží 10
Z2.Vystup(Vysledek_2); -- Do výsledku 2 se uloží 100
end;
Takže ve specifikaci přibyly dva řádky s klíčovými slovy entry. Tím dává task najevo svou schopnost interakce s okolím. Naopak v těle tasku přibyly dva bloky uvozené accept, které danou interakci implementují. Každému entry ve specifikaci odpovídá právě jeden accept v těle. V hlavním programu jsme vytvořili dvě instance typu Zdvojnasob_Task (Z1a Z2), kterým hlavní vlákno předá vstupy a vybere si výsledek.
Po spuštění programu situace vypadá asi takto. Nejprve OS nastartuje proces a s ním primární vlákno. Runtime Ady vytvoří tasky Z1 a Z2, takže při vstupu do bloku hlavního programu máme celkem tři vlákna. Tasky Z1 a Z2 doběhnou k prvnímu acceptua tam se zastaví. Čekají na vstup, čekají, až nějaké jiné vlákno aktivuje příslušnou sekci entry, kterou známe ze specifikace tasku. Vtom ale hlavní vlákno doběhne k řádce Z1.Vstup(5);. Jelikož task Z1 už čeká na svém acceptu, může proběhnout randevous a parametr 5 se předá z hlavního vlákna do vlákna Z1. Vlákno Z1 tak může pokračovat až k druhému acceptu. Tam se opět zastaví. Předání parametru proběhne zcela analogicky pro task Z2. Stejná situace nastane u výběru výsledků. Hlavní vlákno aktivuje entry Vystup pro tasky Z1 a Z2. Pokud vlákna Z1 a Z2 ještě nečekají na acceptech Vystup, hlavní vlákno na ně počká a pak teprve proběhne randevous s předáním výsledků.
Zkrátka někdo na místo schůzky vždycky přijde první a musí na svůj protějšek chvilku počkat. V tomto příkladu se čeká neomezeně dlouho. Je ale možné implementovat i nervózního milence, který čeká nějakou dobu a pak rande vzdá. Je také možno čekat na několika acceptech současně. Obojí demonstruje následující příklad.
task type Zdvojnasob_Task is
entry Vstup1(X : integer);
entry Vstup2(X : Float);
entry Vysledek(X : out Integer);
end Hello_Task;
task body Zdvojnasob_Task is
Task_X, Task_Vysledek : integer;
begin
select
accept Vstup1(X : Integer) do
Task_X := X;
end Vstup1;
or
accept Vstup2(X : Float) do
Task_X := Integer(X);
end Vstup2
or
delay 5.0;
Task_X := 1;
-- Počká 5 sekund a pak použije
-- svoji hodnotu.
end select;
Task_Vysledek := 2*Task_X;
accept Vystup(X : out Integer) do
X := Task_Vysledek;
end Vystup;
end Hello_Task;
Tento task po svém spuštění čeká hned na dvou acceptech současně – Vstup1 (s parametrem typu Integer) a Vstup2 (s parametrem typu Float). Ovšem čeká jenom pět sekund, pak mu dojde trpělivost a do proměnné Task_X si přiřadí jedničku a na výstupu tím pádem bude dvojka. To všechno je možné díky konstrukci select. Implementovat analogickou konstrukci pomocí klasických semaforů by jistě nebylo obtížné, ale chtělo by to víc přemýšlení a z výsledného kódu by nebylo na první pohled patrné, co se vlastně dělá. V paralelním programování často vznikají nepříjemné chyby, které se obvykle projevují jen za určitých, často neopakovatelných podmínek.
Čtenáři a písaři
Čtenáři a písaři jsou typický problém paralelního programování. Jde o to, že máme nějakou globální datovou strukturu (třeba nějaký strom), se kterou manipuluje několik vláken. Tato vlákna se dělí do dvou tříd. První třída jsou čtenáři, tedy vlákna, která onu sdílenou datovou strukturu čtou, ale nijak ji nepozměňují. Druhá třída jsou písaři, tedy vlákna, která mohou data číst i pozměňovat. Pokud se strukturou pracuje více čtenářů, všechno funguje bez problémů. Pokud s daty začne pracovat písař a začne je pozměňovat, může se stát, že data budou v nějaký okamžik nekonzistentní. Když v tento nešťastný okamžik začne nějaký čtenář číst, nastane chyba. Situace, kdy data mění několik písařů najednou, zavání průšvihem ještě víc.
Musíme tedy zajistit, aby s daty pracovalo buď několik čtenářů, nebo právě jeden písař. V konvenčních programovacích jazycích by se to dalo vyřešit použitím třeba jednoho mutexu a jednoho semaforu. Ada však má řešení tohoto problému zadrátováno přímo do jazykových konstrukcí.
Tím řešením jsou takzvané chráněné typy (protected types). V minulém článku jsem uvedl, že funkce v Adě nemohou modifikovat své parametry. Každá funkce je tedy vlastně takový čtenář svých parametrů. Naopak procedury jsou potenciální písaři svých argumentů. Autoři Ady tento princip poněkud zobecnili a použili v chráněných typech. Chráněný typ definuje nějaká data a nějakou množinu k nim přiřazených podprogramů. Přitom každá funkce může data jenom číst a každá procedura je může i pozměňovat. Runtime zaručuje, že pokud se provádí nějaká procedura chráněného typu, neprovádí se žádný jiný podprogram. Následující příklad demonstruje thread-safe čítač.
protected type Citac is -- specifikace
procedure Inkrementuj;
-- Procedura = písař
function Dej_Hodnotu return Integer;
-- Funkce = čtenář
private
Hodnota : Integer := 0;
-- Hodnota je v soukromé části,
-- je před okolím skryta.
end Citac;
protected body Citac is-- tělo
procedure Inkrementuj is
begin
Hodnota := Hodnota + 1;
-- tato inkrementace proběhne
-- vždy atomicky.
end;
function Dej_Hodnotu return Integer is
begin
return Hodnota;
-- Kdyby tu bylo: Hodnota := Hodnota + 1;
-- nastala by chyba při překladu
end
end Citac;
Kód, který takový čítač používá, může vypadat třeba takto:
declare
C : Citac;
begin
-- Tady si představte spoustu
-- vláken používající čítač C
if C.Dej_Hodnotu < 10 then
C.Inkrementuj;
end if;
end;
Kód je opět rozdělen na dvě části. Na specifikaci, která určuje rozhraní, a na tělo, ve kterém je implementace. Pokud máme globální proměnnou typu Citac, může libovolný počet vláken volat funkci Dej_Hodnotu a všechno se provede paralelně a bez zamykání. Pokud se z některého vlákna zavolá procedura Inkrementuj, počká se, až svou práci dokončí všichni písaři (a další příchozí se do práce nepouštějí), pak se provede skutečná inkrementace a celá struktura se opět odemkne.
Konec zvonec
Tak to byl adovský pohled na vlákna a paralelní programovaní, který asi z žádného běžně používaného jazyka neznáte. Ada je ve vláknech dle mého názoru neobyčejně silná a její používání omezuje vznik chyb, které bývají v této oblasti obzvláště nepříjemné. Další velká výhoda je, že uvedený kód je plně přenositelný na různé systémy (tedy přinejmenším na ty, které nějakou formu paralelismu podporují).
Tímto dílem bych si dovolil mé povídání o Adě a bezpečném programování zakončit. Pevně věřím, že mnozí zde našli informace když už ne užitečné, tak aspoň zajímavé :-)