Uploaded avatar of PercyGrunwald

Konkurencia és párhuzamosság Elixirben

@PercyGrunwald
Több, mint 7 éve ezelőtt

Ez Percy Grunwald Elixirről szóló sorozatának második része. Ne hagyd ki az Unicode-egyeztetés Elixirben című első cikket.

Az Exercism feladatai kicsik, szintetikusak, és gyakran látszólag triviálisak. Könnyű azt képzelni, hogy egy tapasztalt fejlesztőnek semmi tanulnivalója nincs bennük. A szintetikus feladatok megoldása azonban rávihet, hogy megtanulj és alkalmazz olyan részeit a nyelvnek, amelyeket esetleg még nem fedeztél fel. Ez az új tudás ahhoz vezethet, hogy a valós problémákat hatékonyabban vagy kifejezőbben oldod meg.

A Párhuzamos betűgyakoriság egy közepes nehézségű feladat az Exercism Elixir kurzusán, amely meglepően sok érdekes tanulságot tár fel. Ahhoz, hogy sikeresen megoldd ezt a feladatot, a megoldásodnak párhuzamosan, több worker folyamatban kell futnia. Az igazi párhuzamosság elérése Elixirben meglepően könnyű más nyelvekhez képest, de ha még soha nem írtál egyidejű kódot Elixirben, az elsőre kicsit ijesztőnek tűnhet. A feladat megoldása során az egyik dolog, amit felfedezhetsz, hogy az Elixir mennyire könnyűvé teszi az egyidejű vagy párhuzamos futtatásra képes kód írását. Ha ezeket a készségeket a saját kódodban alkalmazod, az jelentős hatással lehet az alkalmazásaid teljesítményére.

Ez a feladat megköveteli, hogy implementálj egy függvényt, Frequency.frequency/2, amely meghatározza a betűgyakoriságot egy stringekből álló listában. A számítást több worker folyamatban kell elvégezni, amelyek számát a workers argumentum adja meg:

iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
  "c" => 1,
  "d" => 1,
  "e" => 5,
  ...
  "ö" => 2
}

Ebben a bejegyzésben az Elixir egyidejűségét járjuk körül úgy, hogy egy működő szekvenciális megoldást alakítunk át egyidejűvé. Mielőtt azonban beleugranánk a kódba, szánjunk egy pillanatot arra, hogy megnézzük, mit is jelentenek valójában az „egyidejűség” és a „párhuzamosság” kifejezések, és hogyan érhető el mindkettő Elixirben a többi nyelvhez képest.

Egyidejűség és párhuzamosság

Az egyidejűség és a párhuzamosság rokon fogalmak, de nem pontosan ugyanazt jelentik. Egy egyidejű program olyan, amelyben több feladat lehet „folyamatban”, de egy adott pillanatban csak egy feladat fut a CPU-n (például az egyik feladat fut, míg egy másik IO-ra vár, például lemez- vagy hálózati olvasásra vagy írásra). Egy párhuzamos program viszont képes több feladatot egyszerre végrehajtani több CPU-magon.

Az egyidejű és a párhuzamos végrehajtás is jelentős sebességnövekedést hozhat, de az elérhető gyorsulás mértéke (ha egyáltalán van) sok tényezőtől függ. Vannak esetek, amikor az egyidejűség vagy a párhuzamosság egyáltalán nem lehetséges; elképzelhető, hogy az elvégezni kívánt feladat nem alkalmas sem egyidejű, sem párhuzamos végrehajtásra, vagy hogy a futtatókörnyezet nem támogatja azokat. Ha a te esetedben mégis lehetséges az egyidejű vagy párhuzamos végrehajtás, az elérhető gyorsulás mértéke nagyrészt attól függ, hogy a feladat IO- vagy CPU-kötött-e, és hogy egynél több CPU-mag áll-e rendelkezésre.

A sok befolyásoló tényező ellenére van néhány „ökölszabály” annak eldöntésére, hogy lehetséges-e az egyidejűség vagy a párhuzamosság, és mekkora gyorsulásra számíthatsz. Először is, az egyidejű végrehajtás egyetlen CPU-magon is lehetséges, a párhuzamos viszont nem. Másodszor, a párhuzamosság és az egyidejűség is jelentős gyorsulást hozhat az IO-kötött feladatoknál, és a gyorsulás nagyjából ugyanakkora kell legyen mindkettőnél. Végül, a CPU-kötött feladatok teljesítménye egyidejű végrehajtásnál ugyanaz (vagy lassabb) lesz, és a gyorsulás általában csak akkor lehetséges, ha több CPU-magon, párhuzamosan hajtjuk végre őket.

A feladat betűgyakoriság-számítása egy CPU-kötött feladat példája, így a fenti ökölszabályok szerint sebességnövekedés csak akkor lehetséges, ha a számítást több CPU-magon, párhuzamosan végezzük.

Egyidejűség és párhuzamosság Elixirben a többi nyelvhez képest

Sok népszerű nyelv kínál eszközöket egyidejű kód írásához, de a párhuzamosság elérése általában sokkal bonyolultabb, és tele van kompromisszumokkal.

Például az egyidejűség első osztályú állampolgár a Node.js futtatókörnyezetben futó JavaScriptben, és az IO-hoz kapcsolódó függvények alapértelmezett változatai szinte mindig „aszinkronok” (azaz egyidejűek). Például az fs.ReadFile egy egyidejű függvény, és ez a szabványos módja egy fájl tartalmának beolvasására. Ez nagyszerű, de megvannak a hátulütői is: a callback-pokol vagy a Promise-ok szükségessége formájában. Ráadásul, mivel a Node nem osztja el egyenletesen a CPU-időt a feladatok között, egyetlen CPU-igényes feladattal is blokkolható a végrehajtás. A végrehajtás párhuzamosítása Node-ban lehetséges, de korántsem könnyű. Mivel a Node egyszálú, a párhuzamosság elérésének egyetlen módja, hogy manuálisan worker folyamatokat indítasz a cluster modullal, vagy több példányban futtatod a programodat, és manuálisan megvalósítod a köztük lévő kommunikációt.

A Python a Node-dal ellentétben alapértelmezés szerint nem egyidejű, de több eszközt is kínál egyidejű és párhuzamos kód írásához. Az egyes lehetőségeknek azonban megvannak a maguk kompromisszumai, és a választás nem feltétlenül egyszerű. Használhatod a threading modult, és együtt élhetsz azzal, hogy a global interpreter lock (GIL) egyszerre egyetlen szálra korlátozza a végrehajtást, ami lehetetlenné teszi a párhuzamosságot. A másik lehetőség a Python multiprocessing modulja, amely operációs rendszer szintű folyamatok indításával kerüli meg a GIL korlátját (szálak helyett). A multiprocessing használata lehetővé teszi a párhuzamosságot, de azzal a kompromisszummal jár, hogy az operációs rendszer szintű folyamatok lassabban indulnak, és több memóriát használnak, mint a szálak.

Az egyidejű és párhuzamos kód írása Elixirben sokkal egyszerűbb, mivel az Elixir futási szinten egyidejű. Az Elixir hatalmas skálázhatóságról szerzett hírneve onnan ered, hogy a BEAM virtuális gépen fut, amely az összes kódot rendkívül könnyűsúlyú „folyamatokban” hajtja végre, és ezek mind egyidejűleg futnak a VM-en belül. A BEAM folyamatai elhanyagolható költséggel indulnak, és töredéknyi memóriát használnak a Python egyidejűségi moduljai által indított operációs rendszer szintű szálakhoz és folyamatokhoz képest. Továbbá, a Node eseményciklusával ellentétben a BEAM VM-nek vannak ütemezői, amelyek a rendelkezésre álló CPU-időt kiosztják az összes folyamat között, ami biztosítja, hogy egyetlen CPU-igényes feladat se blokkolja más folyamatok végrehajtását.

E felépítés miatt a folyamatok egyidejű futtatásától a párhuzamos futtatásig csupán annyi a lépés, hogy több CPU-magot adunk hozzá. Valójában a BEAM már sok éve automatikusan aktiválja a szimmetrikus többprocesszoros (SMP) képességeket a többmagos rendszereken, ami lehetővé teszi a VM ütemezői számára, hogy az összes mag CPU-idejét a futó folyamatokhoz osszák ki. Az Elixirben nemcsak az egyidejűség első osztályú, hanem nincs is különbség az egyidejű és a párhuzamos kód között. Nincs más dolgod, mint egyidejű kódot írni, és a VM automatikusan és alapértelmezés szerint párhuzamosítja azt, ha egynél több CPU-mag áll rendelkezésre.

Egyidejű Elixir-kód írása a Task modullal

Ahogy fentebb említettük, az Elixirben az egyidejűséget úgy érjük el, hogy a műveleteket több BEAM folyamatra osztjuk el. Nagyon könnyedén indíthatsz folyamatokat olyan függvényekkel, mint a Kernel.spawn_link/1, de sokkal jobban jársz, ha a Task modul félelmetesen erős absztrakcióit használod:

A [Task] leggyakoribb felhasználási esete, hogy szekvenciális kódot alakítunk át egyidejű kóddá úgy, hogy egy értéket aszinkron módon számítunk ki.

A Task modullal hihetetlenül tiszta egyidejű kódot írhatsz Elixirben: callback-pokol nélkül és Promises nélkül.

Ehhez a feladathoz a Task.async_stream/3 remek választás:

async_stream(enumerable, function, options \\ [])

A Task.async_stream/3 egy streamet ad vissza, amely az adott function függvényt egyidejűleg futtatja az enumerable minden elemén. Alapértelmezés szerint az indított folyamatok (workerek) száma megegyezik az enumerable elemeinek számával. Ez egyszerű módot ad a párhuzamosság szintjének szabályozására a workers argumentummal (feltéve, hogy van elég CPU-magunk). Nincs más dolgunk, mint a betűk listáját a megfelelő számú darabra bontani, és a Task.async_stream/3 segítségével minden darabot külön workerben feldolgozni.

A szekvenciális betűgyakoriság-függvény egyidejűvé tétele

Kezdjük egy működő szekvenciális implementációval a saját megoldásomból:

def frequency(texts, _workers) do
  texts
  |> get_all_graphemes()
  |> count_letters()
end

defp get_all_graphemes(texts) do
  texts
  |> Enum.join()
  |> String.graphemes()
end

defp count_letters(graphemes) do
  Enum.reduce(graphemes, %{}, fn grapheme, acc ->
    if String.match?(grapheme, ~r/^\p{L}$/u) do
      downcased_letter = String.downcase(grapheme)
      Map.update(acc, downcased_letter, 1, fn count -> count + 1 end)
    else
      acc
    end
  end)
end

A fenti implementációt a következőképpen tehetjük egyidejűvé:

  1. Bontsd fel a get_all_graphemes/1 által visszaadott graféma-listát a workers számával megegyező számú darabra
  2. Dolgozd fel az egyes darabokat a count_letters/1 függvénnyel egy workerben, a Task.async_stream/3 használatával
  3. Vond össze az egyes workerek eredményeit egyetlen eredménnyé

Íme a fenti lépések ábrája:

A betűgyakoriság-számítás egyidejűvé tétele

Mindössze 2 új segédfüggvényt kell implementálnunk az egyidejű logika megvalósításához: az egyik a grafémák darabokra bontásához (split_into_chunks/2), a másik a workerek eredményeinek stream-jének összevonásához (merge_results/1). Íme az egyik módja e segédfüggvények implementálásának:

defp split_into_chunks(all_graphemes, num_chunks) do
  all_graphemes_count = Enum.count(all_graphemes)
  graphemes_per_chunk = :erlang.ceil(all_graphemes_count / num_chunks)

  Enum.chunk_every(all_graphemes, graphemes_per_chunk)
end

defp merge_results_stream(results_stream) do
  Enum.reduce(results_stream, %{}, fn {:ok, worker_result}, acc ->
    Map.merge(acc, worker_result, fn _key, acc_val, worker_val ->
      acc_val + worker_val
    end)
  end)
end

Miután ez a 2 függvény elkészült, ahhoz, hogy a frequency/2 függvény egyidejűleg fusson, már csak meg kell hívni az új segédfüggvényeket, és a count_letters/1 közvetlen hívását le kell cserélni a Task.async_stream/3-ra:

def frequency(texts, workers) do
  texts
  |> get_all_graphemes()
  |> split_into_chunks(workers)
  |> Task.async_stream(&count_letters/1)
  |> merge_results_stream()
end

A fenti függvény egy teljesen működő egyidejű implementáció, amely minden teszten átmegy. Bár egyidejű, a kód pontosan úgy olvasható, mint a hagyományos szekvenciális kód, ami jól mutatja a Task-ban rejlő absztrakciók erejét.

Valóban párhuzamos az egyidejű verzió?

Ahogy korábban említettem a bejegyzésben, az Elixirben nincs különbség az egyidejű és a párhuzamos kód között. Ha a workers értékét 1-nél nagyobbra állítjuk, és egynél több CPU-mag áll rendelkezésre, a BEAM VM automatikusan párhuzamosítja az indított folyamatok végrehajtását.

Alapértelmezés szerint a BEAM minden rendelkezésre álló (logikai) CPU-maghoz indít egy ütemezőt. A BEAM által indított ütemezők számát a :erlang.system_info/1 függvénnyel ellenőrizheted:

iex> :erlang.system_info(:schedulers_online)
8

Ez az egyszerre futó VM-folyamatok maximális számát jelöli. Ha a workers értékét az ütemezők számánál nagyobbra állítod, az nem növeli a párhuzamosságot, és ronthatja a teljesítményt.

Összegzés

A kód szekvenciálisról egyidejűre alakítása az Elixir Task moduljával sokkal könnyebbnek bizonyul a vártnál. Az egyidejű kód némi plusz bonyolultságot hoz a graféma-lista felbontásában és a workereredmények összevonásában, de a végeredmény meglepően tiszta.

Mielőtt megoldottam ezt az Exercism-feladatot, hallottam már a Task-ról, de soha nem használtam. Miután alkalmaztam a megoldásomban, ma már az Elixir eszköztáram nélkülözhetetlen részének tartom.

Ezt az új eszközt sokféleképpen használhatod az alkalmazásaid teljesítményének optimalizálására. A legtöbb webalkalmazás IO-kötött, ezért még akkor is profitálna az egyidejűségből, ha csak egyetlen CPU-mag áll rendelkezésre. A webalkalmazás felgyorsításának egy meglehetősen megbízható módja, ha az HTTP-kéréseket egyidejűleg indítod:

def call_apis_async() do
  ["https://api.example.com/users/123", ...]
  |> Task.async_stream(&HTTPoison.get/1)
  |> Enum.into([], fn {:ok, res} -> res end)
end

A fenti kód a Task.async_stream/3-at használja, hogy a listában szereplő összes URL-t egyidejűleg hívja meg, ahelyett hogy megvárná minden kérés befejeződését, mielőtt a következőt elindítaná, ami jelentős gyorsulást eredményezhet a kérések időtartamától függően.

3. Apr 2019 · Hasznosnak találtad?