Hlavní navigace

Erlang: trochu jiný přístup k programování

21. 7. 2014
Doba čtení: 15 minut

Sdílet

Programovací jazyk Erlang je určený k vytváření distribuovaných systémů pro zpracování velkého množství paralelních úloh (např. backend aplikace WhatsApp). Záměrem tohoto článku je ukázat, jak se v něm jeho autoři vypořádali s problémy, které navrhování a vytváření takových programů přináší.

Než si začneme podrobně popisovat vlastnosti jazyka Erlang, jak se v něm sčítá nebo dělají IFy, řekneme si něco o jeho historii a prostředí, ze kterého pochází. Tato exkurze napoví, proč jsou v něm věci řešeny tak, jak jsou, a co tím autoři Erlangu sledovali za cíle.

Začalo to na přelomu 80. a 90. let minulého století ve firmě Ericsson při snaze nalézt způsob, jak vytvářet software pro zařízení v telekomunikacích. Do té doby se v této oblasti používal proprietární hardware (včetně procesorů), do kterého se v psal specifickým způsobem software. Tato éra pomalu končila a „mozkem“ telefonních ústředen se měly stát běžné počítače s operačním systémem typu UNIX. Úkolem autorů Erlangu bylo nalézt způsob, jak pro takové systémy vytvářet software. Nejprve experimentovali s mnoha v té době známými jazyky, ale nenalezli nic co by jim umožnilo vytvářet aplikace s takovými vlastnostmi, které očekávali. A tak vytvořili Erlang.

O co se snažili:

  • Bezpečná práce s pamětí – nemožnost číst nebo měnit cizí či neplatnou paměť, automatické uvolňování paměti, memory leaky, buffer overflow, to vše automatizované a nezávislé na činnosti programátora (jsme cca v roce 1990)
  • Masivní paralelismus – v telefonní ústředně běží najednou mnoho na sobě nezávislých transakcí. Některé trvají zlomek sekundy (dotaz do databáze aktivních čísel), jiné mohou trvat déle (sestavování hovoru) nebo běžet trvale (stav pevné linky). Nedělají se při tom nějaké zvlášť náročné výpočty, spíše se čeká na odezvy databáze nebo okolních zařízení. V okamžiku, kdy taková odezva dorazí, je ale třeba jednat co nejrychleji, přepnout se na daný proces a vyřídit událost.
  • Možnost vyvíjet software v nezávislých blocích – každý blok je možno vyvíjet a testovat zvlášť
  • Možnost načíst novou verzi software bez výpadku – výše zmíněné bloky lze upgradovat za běhu bez nutnosti restartu. To, že je třeba provést upgrade software, nemusí být důvod k tomu aby se zákazníkům přerušovaly probíhající hovory
  • Tracing volání funkcí – možno kdykoliv se připojit k běžícímu systému, zapnout tracing volání funkcí s různými filtry bez restartu či rekompilace, pokud možno s minimálními nároky na výkon. U běžícího systému je možno zjišťovat obsah paměti (stavové proměnné, nahlížení do tabulek pro ukládání dat). To se hodí na hledání příčin problémů na produkčním prostředí, které se nedaří reprodukovat.
  • Mechanismy pro obnovu systému po softwarové nebo hardwarové chybě – pokud se v programu vyskytne chyba (chyba programu, chyba specifikace, nekorektní vstup od protistrany, selhání hardware …), tak by měla postihnout jen nezbytně nutnou část systému a v místě, kde se projevila, nechť zafungují nějaké samoopravovací mechanismy.
  • Distribuované aplikace – zejména kvůli spolehlivosti se hodí mít aplikaci rozloženou na více počítačů a v případě výpadku libovolného z nich nemít výpadek služby.
  • Realtimové chování – výsledný program musí být svižný a nenáročný na zdroje (jsme stále v 90. letech)

Některé z těchto vlastností jsou dnes u jiných jazyků běžné, jiné i po letech velmi vzácné.

Kromě výše uvedených vlastností má Erlang ještě jednu výhodu. Tou je doba, po kterou existuje. Má za sebou více než 20 let vývoje a testování v praxi.

Více v prezentaci Mika Williamse The True story about why we invented Erlang and A few things you don’t want to tell your Manager (resp. její první část).

Jak jsou tyto požadavky v Erlangu vyřešeny, se dozvíte dále.

Procesy

Jednou z hlavních předností Erlangu je nízká latence při vytváření paralelních úloh a přepínáním se mezi nimi. V tradičních programovacích jazycích se paralelní úlohy vytvářejí obvykle pomocí vláken operačního systému. Vytvoření vlákna je poměrně drahá a pomalá operace. Což se často obchází vytvořením zásoby uspaných vláken dopředu s nějakým managementem, co vlákna přidává a ubírá dle potřeby. Erlang na to jde jinak. Při startu systému si vytvoří vlákno pro každý procesor (resp. jádro), co najde, a jednotlivé paralelní úlohy, co při běhu aplikace vznikají, si pak na nich pouští sám vlastním schedulerem.

Paralelním úlohám v Erlangu se říká procesy (nebude-li řečeno jinak, tak od nyní budu termínem proces myslet proces ve smyslu Erlangu, nikoliv např. operačního systému). Jak bylo řečeno, tyto procesy jsou je možno vytvářet lacino a rychle. Dále jsou velmi malé (ve srovnání s procesy operačního systému) a je-li třeba, může jich být v systému hodně. Autoři programu WhatsApp se na firemním blogu již před lety chlubili více jak dvěma miliony TCP/IP spojení na jednom serveru. Vzhledem k filozofii Erlangu to pravděpodobně znamená i minimálně stejný počet procesů. Ve srovnání s procesy operačního systému či vlákny jsou mnohem jednodušší a proto se také nazývají odlehčené procesy (lightweight processes).

Komunikace mezi procesy

Když už máme procesy, je třeba zajistit, aby spolu komunikovaly. V tradičních jazycích se na to používá nějakým způsobem sdílená paměť. S tím souvisí riziko chyby souběhu (jeden proces s pamětí nějak pracuje a druhý mu ji mění). To se dá ošetřit použitím zámků. To zase přináší riziko deadlocků… Ne že se to nedá správně udělat, ale je možné v tom snadno udělat chybu. Tyto věci se velmi špatně debugují a ještě hůře se v nich hledají chyby.

Procesy v Erlangu jsou od sebe izolované a nemají žádnou možnost si navzájem koukat do paměti. Meziprocesová komunikace je řešena zasíláním zpráv. Každý proces má vlastní frontu zpráv, kde si čte, co poslaly jiné procesy.

Různé zprávičkové frameworky existují v řadě jiných jazyků. Zde se ale jedná o jeden ze základních prvků jazyka. Zprávy zajišťuje přímo runtime prostředí. V jazyku Erlang je na odeslání zprávy syntaktická konstrukce s vykřičníkem a na přijmutí klíčové slovo receive.

Přísné oddělení procesů a komunikace výhradně pomocí zpráv umožňuje mimo jiné snadno vytvářet distribuované aplikace. Je možné více runtime prostředí propojit a pak lze zasílat zprávy i procesům běžícím v jiném runtime (typicky na jiném počítači).

Práce s pamětí

V tradičních jazycích si program řekne operačnímu systému o paměť, uloží do ní data, čte je, mění je a pak paměť uvolní. V Erlangu se paměť stará runtime prostředí. To je běžné i v jiných vyšších jazycích. Rozdíl je v tom, že hodnotu lze do vyhrazeného paměťového místa uložit jen jednou. Ano čtete správně. Pokud je třeba hodnotu změnit, tak se nová hodnota (vypočtena např. aritmetickým výrazem) uloží do nového místa v paměti. Pokud se původní místo již nepoužívá (nevedou na něj jiné odkazy např. z jiných souběžně běžících procesů), při nejbližší příležitosti se automaticky uvolní.

Proč to tak je?

Pokud více procesů sdílí nějaká data, což je při paralelním programování o které v Erlangu jde běžné, tak změna dat na jednom místě v paměti je potenciální zdroj problémů (výše zmíněné riziko chyb souběhu, zámky, deadlocky …). Pokud je update jednou vytvořené hodnoty zakázané, je po problému. Jako bonus není třeba řešit zda jsou parametry do funkcí předávány hodnotou nebo odkazem. Oboje je stejné. Data při volání funkce nebo zasílání zprávy jinému procesu se obsahy proměnných nekopírují, pouze se předávají odkazy. Pokud skupina dat tvoří nějakou strukturu (např. pole), jedná se opět jen o skupinu odkazů a případná změna jen vymění odkazy kde je třeba. Hodnoty jsou v paměti uloženy jen jednou bez ohledu na to na kolika místech se používají. Není třeba se bát vedlejších efektů, kdy předáte nějaké funkci strukturu a ta vám v ní změní něco co by neměla.

Odolnost proti chybám

Co se dá dělat, pokud se v programu vyskytne chyba. Např. dělení nulou. Dá se nedělat nic a nechat program skončit. To není zas tak špatný nápad, pokud máte zařízený automatický restart programu v případě chyby, ale pokud program zpracovával více úloh najednou, přijdete i o ty, které neměly s chybou nic společného. Nebo lze chybu (výjimku) zachytit a zkusit nějak napravit, co se stalo, ale dopředu vymýšlet, co všechno se může stát, a psát na to opravné procedury je u reálných úloh z reálného života prakticky nemožné. V Erlangu je možnost nastavit, aby jeden proces sledoval jiný proces. Pokud sledovaný proces skončí, tak se to sledující proces dozví (dostane zprávu od runtime prostředí) a může na ni nějak reagovat. To je všechno. Co se s tím dá dělat?

Typicky se může zkusit spadlý proces znovu spustit. Restartovat proces znovu je něco jiného než jen zavolat funkci, co selhala, znovu se stejnými parametry, což by se mohlo dělat při chytání výjimek. Restart obvykle řeší problém s nekonzistentními stavovými daty, které mohly vzniknout o několik kroků dříve, než se objevila chyba, co vyvolala výjimku. Oblíbené restartování daemonů či celých počítačů při chybném chování systémů je vlastně stejný přístup, pouze ve větším měřítku.

Dohlížení (supervize) procesů je podobná činnost jakou dělají dělají různé supervizovací programy typu Daemon Tools. V počítači běží daemon, co má běžet nepřetržitě. Pokud kvůli chybě spadne, supervizovací program dostane signál od operačního systému a zareaguje na to tím, že pustí okamžitě program znovu (resp. jeho startovací script). V Erlangu je to stejné s tím rozdílem, že lze vytvářet navíc různé závislosti mezi procesy (spadne jeden, restartuje se celá skupina) dle logiky aplikace. Při správně navržených závislostech se při chybě restartuje právě tak velká část programu, jaká je potřeba.

Reakce na výjimku v sledovaném procesu může být jiná než jen restart. Např. uvolnění vzácného zdroje, který si spadlý proces vyhradil a neuvolnil.

Sledovat lze i procesy, co běží na jiném počítači. Pak se lze dozvědět i to, že vzdálené runtime prostředí má výpadek jako celek a zareagovat na to např. tím, že se příslušné úlohy spustí jinde.

Další úrovní toho přístupu (spadne to, nevadí, někdo jiný se postará o nápravu) je nečekat, až nějaká chyba vygraduje do takové míry, že způsobí výjimku a zastaví proces. Řada chyb se takto hlasitě projevit nemusí a „pouze“ vytvoří chybná data a pošlou se dále (data se např. uloží do databáze a pak dumejte, kde se tam vzala). Tomu lze předcházet tak, že v programu se budou co nejčastěji kontrolovat vstupy, zda odpovídají specifikacím (jsou v nich očekávané hodnoty). A pokud se program dostane mimo specifikace, tak ať se co nejdříve vyvolá chyba. Čím dříve se chyba vyvolá, tím snadněji půjde nalézt její příčinu. V jazyku Erlang se s touto filozofií lze setkat na více místech. Pokud se např. v rozhodovacích blocích (if a case) nenajde větev, kterou má program zavolat, vyvolá se automaticky výjimka.

  % zaporna hodnota vyvola vyjimku
  if
    N > 0  -> do_action1();
    N == 0 -> do_action2()
  end,

Pochopitelně je možné na konec připsat větev, co ošetří ostatní stavy. Ale to se tam musí napsat a předpokládá se, že programátor ví, co dělá, pokud to tam píše.

  if
    N > 0  -> do_action1();
    N == 0 -> do_action2();
    true   -> do_action_default()
  end,

Obdobně lze testovat vstupní data při volání funkcí. Automatickým validátorem hodnot proměnných se pak může stát každý IF či zavolání funkce. Chyba pak má méně šancí na to, aby proklouzla.

Pokud je naopak třeba zavolat nějakou funkci a mít jistotu, že ať to dopadne jak chce, proces poběží dále, lze to zařídit zachytáváním výjimek. To je v jazyce Erlang obdobné jako v jiných jazycích.

Programovací jazyk Erlang

Vlastní jazyk Erlang je poměrně jednoduchý funkcionální jazyk. Jedná se o tzv. deklarativní programování, kdy je snahou popsat, co se má udělat, namísto toho, jak se to má udělat. Známějšími příklady deklarativního programování jsou např. jazyky SQL nebo XSLT.

Programy v něm napsané jsou velmi kompaktní a stručné. Firma Ericsson dělala studii, při které vzala jisté telekomunikační zařízení napsané v jazyce C a nechala je přepsat do Erlangu. Kód v Erlangu obsahoval 20 % řádků ve srovnání s implementací v jazyce C.. Přitom program v Erlangu byl rychlejší. (prezentace, vlastní studie)

Malá odbočka k výkonu – Erlang neobsahuje uvnitř nějakou zázračnou formuli, díky které jsou aplikace v něm napsané rychlejší než cokoliv jiného. Konec konců sám je napsaný v jazyce C. Ale věci související s paralelním programováním jsou v něm udělány dobře, jsou hotové a odladěné a je možno z toho těžit. Cokoliv napsané v Erlangu lze napsat v jazyce C a bude to minimálně stejně rychlé. Jenom než se dostanete k psaní aplikace, bude třeba nejprve několik měsíců vyvíjet a ladit všelijaké knihovny a frameworky. Dobře (zdůrazňuji dobře) napsané programy v C mohou být o něco rychlejší, ale jsou pracnější, hůře se udržují a chyby v nich mohou mít fatální důsledky. Vyšší jazyky typu C#, Java a pod. – zde jsou rozdíly obvykle v magnitudách ve prospěch Erlangu.

Jazyk Erlang je úzce svázaný s příslušným runtime prostředím. Občas je dobré vědět, jak v něm věci fungují a proč něco nejde udělat tak, jak je člověk zvyklý odjinud. Autoři Erlangu na několika místech poněkud omezili programátory aplikací v rozletu v zájmu zjednodušení runtime prostředí. Za to dostali rychlost, spolehlivost a ostatní výše popsané zajímavé vlastnosti. Není to těžké, je to jen občas trochu jiné. Ne moc, ale dost na to, aby to mohlo zejména samouky odradit. Bez znalosti „co je za tím“ mohou některé vlastnosti jazyka Erlang vypadat velmi zvláštně.

Příklady vlastností jazyka. Nezaobírejte se detaily syntaxe, až přijde čas, bude vše vysvětleno.

Porovnávaní vzorů

Porovnávání vzorů se často využívala v rozhodovacích strukturách nebo při volbě, která funkce daného jména se má použít. Funkce se stejným jménem se mohou lišit nejen počtem a typem parametrů (jako je tomu v jiný jazycích), ale i obsahem parametrů.

  obsah({obdelnik, A, B}) -> A * B;
  obsah({ctverec, A}) -> A * A;
  obsah({kruh, R}) -> math:pi() * R * R;
  ...

V tomto případě je zde funkce s jedním parametrem a dle jeho obsahu se volí příslušná varianta.

Rekurze

Rekurze je v Erlangu často využívaný nastroj (připomínalo mi to programování XSLT šablon). Funkce na výpočet faktoriálu může vypadat např. takto

  fact(0) -> 1;
  fact(N) when N > 0 -> N * fact(N - 1).

Nevýhodou rekurze je, že při jejím používání roste stack a pokud dosáhne jistých limitů, program se ukončí (známé stack overflow). V Erlagu je možno používat tzv. tail rekurzi, kdy je rekurzivní volání sebe sama jako poslední instrukce před opuštěním funkce. V takovém případě není třeba zachovávat na stacku proměnné funkce, protože by stejně byly v následujícím kroku neplatné, a stack neroste. Díky tomu lze rekurzí vytvořit třeba i nekonečnou smyčku.

  loop(StateData) ->
    InputData = read_input(),
    NewStateData = process_data(InputData, StateData),
    loop (NewStateData).  % rekurzivni volani jako posledni operace

Práce s binárními daty

Telekomunikační aplikace často používají různé binárními protokoly. Proto má Erlang na práci s binárními daty efektivní syntaktické konstrukce. Pracovat tak v Erlangu s nějakým binárními daty je radost. V následujícím příkladu jde o kódováni a dekódování hlavičky zprávy, která se skládá ze 4 bajtů (verze protokolu, reserved, třída a typ zprávy) a jednoho 32bitového integeru (délka zprávy).

  0                   1                   2                   3
  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2
  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  | Version       | Reserved      | Message Class | Message Type  |
  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  | Message Length                                                |
  +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Funkce převádějí binární data na strukturu m3ua_header a zpět mohou vypadat takto:

  decode_header (<<Version:8, _Reserved:8, MsgClass:8, MsgType:8 Legth:32>>) ->
    #m3ua_header{version = Version, msg_class = MsgClass, msg_type = MsgType, length = Legth}.

  encode_header(#m3ua_header{version = Version, msg_class = MsgClass, msg_type = MsgType, length = Legth}) ->
    <<Version:8, 0:8, MsgClass:8, MsgType:8 Legth:32>>

Kromě práce s binárními daty a strukturami je zde opět vidět porovnávání vzorů (pattern matching). V prvním případě se popíše 64bitový kousek binárních dat a zároveň se jeho části nakopírují do proměnných. Těmi se v dalším kroku naplní struktura. Jinak dlouhý binární vstup by na tento popis vstupního parametru nepasoval a funkce by se nevybrala. V druhém případě se očekává že vstupem je struktura m3ua_header a rovnou se z ní vyčtou hodnoty do proměnných, ze kterých se v dalším kroku sestaví binární data. Je to podobný princip, který se používá v regulárních výrazech. Jednak se vyhodnocuje, zda vstup na regulární výraz pasuje, a pokud ano, je možné v něm pomocí kulatých závorek označit části, co se dají následně načíst do proměnných. Všimněte si, že proměnná Reserved se dále nepoužívá (podtržka před jménem jen říká kompilátoru, aby na to neupozorňoval varováním) a na jejím místě (druhý byte) se při vytváření binárních dat vkládá konstanta 0.

Práce se seznamy

Na práci se seznamy se často využívá rekurze. Spolu s operátorem |, kterým se seznam rozdělí na první prvek a zbytek. Příklad – vstupem je pole čísel, výstupem je jejich součet.

  sum([]) -> 0;                       % prazdne pole ma soucet nula
  sum([N | Tail]) -> N + sum(Tail).   % ostatni se dopocte rekurzi

ETS a DETS tabulky

Pro ukládání většího množství dat lze využít tzv. ETS tabulky (Erlang term storage). Jedná se úložiště typu klíč-hodnota umístěné v paměti (DETS tabuly jsou uložené v souboru na disku). Součástí distribuce Erlangu je databáze Mnesia, která k těmto úložištím přidává transakční vrstvu, vazby mezi tabulkami, indexy, distribuci dat přes více uzlů apod.

ETS tabulky jsou docela rychlé. Příklad – řešil jsem směrování národních telefonních čísel v ČR. Což znamená databáze, která řekne, jakému operátorovi patří zadané číslo. Vstupem jsou rozsahy čísel, která ČTU přidělil jednotlivým operátorům (intervaly) a seznamy přenesených čísel (u pevný linek mohou být intervaly, u mobilních čísel se jedná jen o jednotlivá čísla). Celkem cca 2 miliony záznamů. Při vhodném uspořádání ETS tabulek se doba odpovědi pohybovala mezi 3 až 5 mikrosekundami.

Erlang – the movie

Co by to bylo za úvodní povídání o Erlangu bez tohoto historického videa, kde autoři Erlangu popíší jeho vlastnosti a pak jej předvedou v praxi.

CS24_early

Po vzoru mého oblíbeného filmového recenzenta Františka Fuky (a programátora v Erlangu) vyzradím zásadní dějové zvraty a pointy filmu. Zhruba v druhé půlce filmu si začne Joe (Joe Armstrong) telefonovat s Mikem (Mike Williams) a občas zavolají Robertovi (Robert Wirding). Joe zavolá Mikeovi a nechají hovor běžet (telefony nezavěsí). Pak se s jiným telefonem snaží spojit s Roberte do třístranného hovoru a při tom hovor spadne. Robert najde v programu chybu, opraví ji, zkompiluje a načte novou verzi programu. Pokus s třístranným hovorem tentokrát dopadne dobře. Nyní se Joe a Mike vrátí k telefonu, na kterém běží hovor od začátku pokusu, a hovor stále běží, i když software v ústředně byl aktualizován.

S pozdravem „Declarative realtime programming Now!“ na shledanou u dalších dílů, kde si již začneme ukazovat, jak psát v Erlangu programy.

Byl pro vás článek přínosný?

Autor článku