Конкурентно програмиране : Задачи и Агенти


Elixir и Erlang са известни като конкурентно-ориентирани езици. Специализирани са за програми, които изпълняват множество различни операции по едно и също време. Знаем, че това става с помощта на процесите и досега бяхме съсредоточени в това, какво се случва в един такъв процес.

В един процес кодът се изпълнява последователно, изразното средство с което разполагаме е функционален език и данните, които ползваме са immutable. В тази публикация ще започнем да изпълняваме действия в други процеси, конкурентни на текущия и ще съхраняваме и ползваме данни от тях.

Ще се запознаем с две прости абстракции над процесите, които изпълняват точно определена роля и са лесни за ползване, а именно:

  1. Задачите (Task), които могат да изпълнят действие в нов процес и да върнат резултата от него в текущия.
  2. Агентите (Agent), които съхраняват и предоставят достъп до данни в процес.

Задачи

Най-лесният начин да изпълним нещо конкурентно на текущата ни логика е с Task. Можем да обвием действието в анонимна функция и да използваме Task.async/1.

task = Task.async(fn -> 1 + 1 end)
#=> %Task{
#=>   owner: #PID<0.89.0>,
#=>   pid: #PID<0.273.0>,
#=>   ref: #Reference<0.999173714.2842427393.228068>
#=> }

Тази функция връща структура, която представлява задачата. Трите ѝ полета включват owner, който е pid-а на текущия процес:

self()
#=> #PID<0.89.0>

Второто поле на структурата е pid, който е адреса (pid-a) на задачата, а третото поле, ref е специална уникална стойност, която се използва за мониторинг. Ще говорим за наблюдение между процеси в бъдеща публикация. Важното е, че можем да използваме тази структура по-късно за да прочетем резултата от изпълнението на задачата:

task = Task.async(fn -> 1 + 1 end)
#=> %Task{...}

# Друга логика може да се изпълни тук, и когато сме готови:

result = Task.await(task)
#=> 2

Това е най-основната идея за ползване на Task. Нека да разгледаме поведението на тези функции по-детайлно.

async и await

Функцията Task.async/1 всъщност създава нов Elixir-ски процес, чийто адрес можем да видим в структурата, върната от нея. Този процес е специализиран. Идеята му е да изпълни едно основно действие и да върне резултата от него.

Функцията Task.await/2 ще блокира текущия процес докато задачата завърши и резултата е наличен. Ако резултатът е наличен, когато я извикаме, направо ще го върне.

Какво ще стане, ако задачата отнеме твърде много време да се изпълни?

task = Task.async(fn ->
  Process.sleep(10_000) # Симулираме дълга задача

  1 + 2
end)
#=> %Task{...}

result = Task.await(task)
#=> ** (exit) exited in: Task.await(%Task{...}, 5000)
#=>     ** (EXIT) time out
#=>     (elixir) lib/task.ex:491: Task.await/2

Ще получим грешка. Вторият параметър на Task.await/2 е timeout, който по подразбиране е 5_000 или пет секунди. Ако извикваме await и това време изтече - грешка. Ако очакваме действието да се забави много, можем да подадем по-голям timeout:

task = Task.async(fn ->
  Process.sleep(10_000) # Симулираме дълга задача

  1 + 2
end)
#=> %Task{...}

result = Task.await(task, 11_000) # Ще почакаме около 10 секунди
#=> 3

А какво ще стане ако извикаме await повторно, след като вече имаме резултат?

task = Task.async(fn -> 2 + 2 end)
#=> %Task{...}

result = Task.await(task)
# => 4

result = Task.await(task)
#=> ** (exit) exited in: Task.await(%Task{...}, 5000)
#=>     ** (EXIT) time out

Повторното извикване ще блокира и ще чака резултат, тъй като задачата вече не съществува и резултатът от изпълнението ѝ е върнат, няма да има нов резултат. След пет секунди ще има timeout грешка. Изводът е, че едно извикване на async трябва да съответства на точно едно извикване на await.

Функцията Task.async/1 има и MFA (Module, Function, Arguments) версия - Task.async/3. Произволна функция от произволен модул с произволни аргументи може да бъде изпълнена в задача:

task = Task.async(Kernel, :+, [2, 3])
#=> %Task{...}

result = Task.await(task)
#=> 5

Сега имаме изразните средства да имплементираме версия на Enum.map/2, която изпълнява функцията, подадена ѝ като втори аргумент в нова задача за всеки елемент от колекцията, подадена ѝ като първи елемент:

defmodule TaskEnum do
  def map(enumerable, fun) do
    enumerable
    |> Enum.map(& Task.async(fn -> fun.(&1) end))
    |> Enum.map(& Task.await(&1))
  end
end

Функцията работи по следния начин:

  1. Ползваме Enum.map/2 за да създадем Task, който изпълнява подадената функция fun за всеки елемент в нова задача. Това значи, че създаваме нов процес за всеки елемент в enumerable в последователността, в която са елементите му.
  2. След първия Enum.map/2 имаме колекция от Task структури в реда, в който са създадени.
  3. Второто извикване на Enum.map/2 прилага Task.await/2 на тези структури подред, което значи, че първо ще изчакаме за резултата от fun върху първият елемент на enumerable първо, след това за резултата от втория и тн.
  4. Ако всичко е наред ще имаме колекция от елементи получени след прилагането на fun върху всеки елемент на enumerable.

Проверка за резултат на даден интервал

Абстракцията Task може да се ползва и с идеята на future стойностите, които може и да сте срещали в други езици и системи. За тази цел, бихме могли да ползваме функцията Task.yield/2. Подобно на Task.await/2, yield блокира текущия процес за дадено време (по подразбиране пет секунди), но ако няма резултат все още, връща атома nil:

task = Task.async(fn -> Process.sleep(5_000); 3 + 3 end)
#=> %Task{...}

Task.yield(task, 1_000)
#=> nil

Task.yield(task, 2_000)
#=> nil

{:ok, 6} = Task.yield(task, 3_000)
#=> {:ok, 6}

Изглежда, че все едно poll-ваме задачата за резултат, което обикновено се прави при други системи, но не е така. Процесите в Elixir комуникират помежду си чрез размяна на съобщения, което повече прилича на изпращане на push нотификации. Това, което прави Task.yield/2, а също и Task.await/2 е да проверят дали вече няма такъв резултат пристигнал в текущия процес, ако има да го върнат и да го премахнат от ‘пощенската кутия’ на текущия процес, а ако го няма, да накарат текущия процес да изчака за съобщението дадено време. Ако съобщение не пристигне за това време, двете функции се държат различно. В бъдеще ще говорим точно какво представлява тази ‘пощенска кутия’ на процеса и как можем да я ползваме. Засега, важното е да знаем, че когато задачата си свърши работата, резултатът ще бъде изпратен до текущия процес.

Друго важно нещо, свързано с горните особености е, че само процесът onwer на задачата може да извика Task.await/2 или Task.yield/2. Това има смисъл, защото задачата праща резултата си до onwer процеса (този, който я е създал) и никога няма да прати резултата си към друг процес.

task1 = Task.async(fn -> 4 + 3 end)
#=> %Task{owner: #PID<0.10199.0>, ...}

Task.async(fn -> Task.yield(task1) end)
#=> ** (EXIT from #PID<0.10199.0>) evaluator process exited with reason: an exception was raised:
#=>     ** (ArgumentError) task %Task{owner: #PID<0.10199.0>, ...} must be queried from the owner but was queried from #PID<0.10205.0>

Да се върнем на функцията Task.yield/2. Както видяхме при успех тя връща {:ok, result}. Ако все още няма резултат, връща nil. Друго нещо, което може да върне е {:exit, reason}, ако поради някаква причина, задачата е излязла, без да има резултат:

defmodule Calc do
  def sum(a, b) when is_number(a) and is_number(b), do: a + b
  def sum(_, _), do: Kernel.exit(:normal)
end

task = Task.async(Calc, :sum, [4, 4])
#=> %Task{...}

{:ok, 8} = Task.yield(task)
#=> {:ok, 8}

task = Task.async(Calc, :sum, ["5", 4])
#=> %Task{...}

{:exit, :normal} = Task.yield(task)
#=> {:exit, normal}

Ако има грешка в процеса на задачата, тя отново ще излезе, и резултатът от yield би бил {:exit, грешка}, но обикновено тази грешка, ще убие и процесът, създал задачата. Има начини това да не стане, за които ще говорим по-късно. Засега знаем, че ако дадена задача получи лоши стойности отвън, можем да излезем с Kernel.exit(:normal) и да pattern match-нем {:exit, :normal}, за да прихванем този случай.

Струва си да споменем, че Task.yield/2 има версия, за изчакване на резултатите от множество задачи - Task.yield_many/2, за нея можете да прочетете в документацията.

Освобождаване на задача

Възможно е да спрем и освободим ресурсите на задача, която отнема твърде много време. За това можем да ползваме Task.shutdown/2. Пример:

task = Task.async(fn -> Process.sleep(10_000) end)
#=> %Task{...}

case Task.yield(task) || Task.shutdown(task) do
  {:ok, result} -> result
  nil -> {:error, "Task took too much time!"}
end
#=> {:error, "Task took too much time!"}

Process.alive?(task.pid)
#=> false

Фукнцията Task.async_stream/3

Тази функция приема колекция и функция, която да изпълни на всеки елемент на колекцията. Подадената функция се изпълнява в различен Task за всеки елемент. Звучи познато? Да, това е много подобно на нашия TaskEnum.map/2, но връща Stream. Когато опитаме да консумираме този stream, всяка задача ще бъде изчакана дадено време.

defmodule TaskEnum do
  def map(enumerable, fun) do
    enumerable
    |> Task.async_stream(fun)
    |> Stream.take(100)
    |> Stream.map(fn {:ok, val} -> val end)
    |> Enum.to_list()
  end
end

Подобно на Task.yield/2 получаваме резултати от вида {:ok, result} при успех, затова ги “разопаковаме”.

Хубавото нещо на тази функция е, че връща поток. Това значи, че ако ползваме функции като Stream.take/2 ще изпълним само дадения брой действия.

Има MFA версия на тази функция - Task.async_stream/5. Последният параметър и на двете функции е keyword списък от опции. Повече за тези опции, прочетете в документацията.

Пример : Github

Ще завършим темата за задачите с един малък пример. Програма, която взима някаква информация от Github и ползва задачи за да постигне множество конкурентни един на друг HTTP request-и.

Да направим проекта с mix:

mix new github

Ще се нуждаем от библиотеки за HTTP заявки и JSON, защото ще ползваме API-то на github, затова нека зависимостите ни в mix.ex да изглеждат така:

  defp deps do
    [
      {:poison, "~> 3.1"},
      {:httpoison, "~> 1.0"}
    ]
  end

Ще видим за какво и как ще ползваме тези две библиотеки. Нека ги инсталираме:

mix deps.get

Сега можем да напишем функция за сваляне на всички stargazer потребители на repository:

defmodule Github do
  @api "https://api.github.com"

  def stargazers(user, repo) do
    HTTPoison.get("#{@api}/repos/#{user}/#{repo}/stargazers")
    |> decode_stargazers()
  end

  defp decode_stargazers({:error, _} = error), do: error
  defp decode_stargazers({:ok, %{body: body}}) do
    Poison.decode!(body)
    |> Enum.map(fn %{"login" => login} -> login end)
  end
end

Функцията Github.stargazers/2 връща списък от login-ите на всички потребители, които са дали звезда на дадено repo, притежавано от даден user.

Използваме HTTPoison.get да направим GET заявка към Github API-то. Тази функция ще върне {:error, грешка} при проблем или {:ok, структура} при успешен request. В тази структура има ключ :body, който сочи към съдържанието на HTTP response-а. Тъй като ползваме JSON API-то на Github, това :body e низ, който представлява JSON. Poison.decode! превръща този JSON в списък с елементи речници, в които знаем (защото познаваме Github API-то), че ще има по ключ "login", който съдържа това което ни трябва - nickname на хората оценили repository-то.

users = Github.stargazers("ElixirCourse", "blog")
#=> ["6desislava6", "lachezar", "victordraganov", "hrist-todorova", "triffon",
#=>  "NoHomey", "Nimor111", "tanyakavrakova", "meddle0x53", "IvanIvanoff"]

Искаме за всеки потребител да вземем подробна информация, колко последователя има, колко gist-а е направил и тн. Нека добавим нова функция към модула Github, която прави това:

defmodule Github do
  # Махнали сме горните функции за четимост...

  def user_info(users) do
    action = fn user ->
      HTTPoison.get("#{@api}/users/#{user}") |> decode_user()
    end
    max_concurrency = System.schedulers_online() * 2

    Task.async_stream(users, action, max_concurrency: max_concurrency)
    |> Stream.map(fn {:ok, data} -> data end)
    |> Enum.to_list()
  end

  # ... ще добавим останалите функции след малко ...
end

За всеки nickname в списъка с потребители, който ѝ подадем, Github.user_info/1 прави конкурентна заявка към Github API-то. Използваме Task.async_stream/3 за това. Функцията, която ѝ подаваме като втори аргумент прави HTTP GET заявка до правилния endpoint и декодира получения JSON (след малко ще видим как).

Една от опциите на Task.async_stream/3 е max_concurrency. По подразбиране е System.schedulers_online(), което пък по подразбиране е броят на ядрата на процесора и може да се конфигурира. Тази опция указва колко задачи максимално могат да вървят конкурентно една на друга. Тъй като нашите задачи са по IO heavy пускаме повече наведнъж.

defmodule Github do
  # Махнали сме горните функции за четимост...

  defp decode_user({:error, _} = error), do: error
  defp decode_user({:ok, %{body: body}}) do
    body
    |> Poison.decode()
    |> extract_user_info()
  end

  defp extract_user_info({:error, _} = error), do: error
  defp extract_user_info({:ok, %{"message" => message}}), do: {:error, message}
  defp extract_user_info(
    {
      :ok,
      %{
        "login" => nick,
        "name" => name,
        "location" => location,
        "bio" => bio,
        "blog" => blog,
        "company" => company,
        "followers" => number_of_followers,
        "following" => number_of_following,
        "public_gists" => gists,
        "public_repos" => repos
      }
    }
  ) do
    %{
      nick: nick,
      name: name,
      location: location,
      bio: bio,
      blog: blog,
      company: company,
      number_of_followers: number_of_followers,
      number_of_following: number_of_following,
      gists: gists,
      repos: repos
    }
  end
end

Горните функции просто декодират JSON-а за даден потребител в речник и взимат каквото е нужно от него.

С този пример приключваме темата за задачите, но ще се върнем на него в края на следващата секция.

Агенти

Подобно на Task, Agent е абстракция над Elixir-ски процес. Агентът представлява състояние, което е съхранено в отделен процес, така че да е достъпно от други процеси или от един и същ процес на различни етапи от изпълнението му.

Агентът не е нещо, което ще ползваме често на практика, но е полезен при тестване, тъй като би могъл да симулира отдалечено състояние локално. Агентите могат да се ползват и за проста мемоизация при построяване на нещо.

Забележете, че когато използваме агенти е много лесно да имаме странични ефекти, макар че вътрешно те са чисто функционална конструкция, от гледна точка на друг процес, могат да се възприемат като глобално състояние. Тук именно излизаме над функционалния език Elixir и започваме да гледаме на всеки процес като на малък чисто функционален environment. Множеството от тези процеси е конкурентно-ориентиран environment, при който се допускат странични ефекти.

Всеки процес може да комуникира с външния свят чрез изпращане на съобщения. Дали този външен свят ще е друг процес или пък порт към операционната система, това няма значение.

Агентът като стойност

Нека разгледаме следния пример:

{:ok, agent} = Agent.start(fn -> 1 end)
#=> {:ok, #PID<0.187.0>}

Agent.get(agent, fn v -> v end)
#=> 1

Agent.update(agent, fn v -> v + 1 end)
#=> :ok

Agent.get(agent, fn v -> v end)
#=> 2

Този код изглежда подобен на:

a = 1
#=> 1

a
#=> 1

a = a + 1
#=> 2

a
#=> 2

Разликата е, че стойността е в друг процес, чиято единствена роля е да пази тази стойност. Този процес ще живее докато не бъде спрян или убит. Какво печелим от това?

Ето нещо интересно:

defmodule DependentTask do
  def execute(agent) when is_pid(agent) do
    agent |> Agent.get(&(&1)) |> run(agent)
  end

  defp run(n, agent) when n < 5, do: execute(agent)
  defp run(n, _), do: n * n
end

{:ok, agent} = Agent.start(fn -> 1 end)
#=> {:ok, #PID<0.241.0>}
task = Task.async(DependentTask, :execute, [agent])
#=> %Task{...}

nil = Task.yield(task, 100)
#=> nil
nil = Task.yield(task, 1000)
#=> nil

:ok = Agent.update(agent, fn _ -> 10 end)
#=> :ok
{:ok, val} = Task.yield(task, 100)
#=> {:ok, 100}

В този код задачата и текущия процес имат достъп до една и съща стойност, пазена в трети процес - агента. Задачата по дефиниция зависи от стойността в агента. Тя ще завърши работа тогава и само тогава, когато нещо промени тази стойност и я направи по-голяма или равна на 5. На практика тук можем да направим и пуснем друга задача, която има достъп до адреса на агента и може да променя тази стойност, по такъв начин получаваме нещо като споделено състояние.

Разликата е, че до това състояние е винаги immutable, макар и за външния свят да изглежда mutable. Също така, съобщенията които постъпват в процеса-агент от други процеси, се усвояват в реда в който са дошли. И да, възможно е да имаме race conditions. Ако един процес прочете дадена стойност и иска да я използва при update, друг прочете същата и иска да я използва при update, затова именно update функцията работи с функция, която има достъп до реалната текуща стойност. Нищо друго не може да промени стойността, ако кодът на функцията, подадена на update се изпълнява в момента. В агента всичко се изпълнява последователно, той е процес и вътрешността му е чисто функционална. Възможно е да счупим нещата, да, но ако използваме правилно интерфейса на агента това няма да стане.

Race conditions

Ето и един пример за как да не ползваме Agent:

defmodule Value do
  def init(v) do
    {:ok, value} = Agent.start(fn -> v end)
    value
  end

  def get(value), do: Agent.get(value, &(&1))
  def set(value, v), do: Agent.update(value, fn _ -> v end)
end

value = Value.init(5)

action = fn ->
  Process.sleep(50)
  v = Value.get(value)
  :ok = Value.set(value, v * 2)
end

{:ok, _} = Task.start(action)
{:ok, _} = Task.start(action)
{:ok, _} = Task.start(action)
{:ok, _} = Task.start(action)

Process.sleep(200)

Value.get(value)

Каква ще е стойността? Недетерминирана е. Защо? Защото дадена задача или задачи могат да изпълнят v = Value.get(value) след като друга или други вече са изпълнили :ok = Value.set(value, v * 2). Класически race conditions проблем. Как да оправим това? Лесно. Нека стойността не зависи от реда в който се изпълняват изразите в Task-овете. Ще го оставим като упражнение за вас.

Тук използвахме Task.start/1. Ползваме тази функция ако просто искаме да пуснем някаква логика в друг процес и не се интересуваме от резултата от нея.

Функциите на Agent

Видяхме, че Agent има функция за стартиране, на която се подава функция, връщаща състоянието, което агентът ще пази. Има и MFA версия на start, както и start_link версии. Версиите с link биха убили процеса на агента, ако този, който го е пуснал вече не съществува и обратно - пускащият процес ще бъде терминиран, ако процеса на агента завърши. Ще говорим за link-ове, когато говорим по-подробно за процеси в Elixir. Засега можете да видите функцията по-подробно в документацията.

Защо всичко се изпълнява във функции? Всъщност тези функции, подавани на функциите на модула Agent се изпълняват в процеса-агент. Затова именно, трябва да бъдат кратки, за да не взимат много от времето на този процес. Това би блокирало комуникацията с него. Всеки процес се изпълнява последователно.

Промяната на състоянието на даден процес би трябвало да става в самия процес. Именно затова промяната и инициализирането на състоянието на Agent-а става във функции, които се изпълняват в процеса му.

Можем да мислим за Agent-а и като за сървър. Процесите, които го ползват са клиенти, които комуникират с този сървър. Агентът трябва да е много прост сървър, неговата роля е да съхранява състояние. Това състояние може да бъде инициализирано, четено и променяно. Тежки изчисления свързани със състоянието на агента не са част от неговата роля.

Агентът има get, update и атомарната get_and_update функции. С първите две дадохме примери, а с третата ще дадем в следващата секция. За по-подробна информация - документацията.

Състоянието на агент може да се променя и асинхронно - функцията cast връща веднага и не чака отговор от процеса-агент, но може да промени състоянието му.

Последната функция, която ще споменем е Agent.stop/1. Тя спира агента и освобождава процеса му. Приема pid-а на агента. Може да се ползва за изчистване на ресурси.

Пример Github (продължение)

API-то на Github има ограничение на броя заявки за дадено време, не искаме да правим една и съща заявка множество пъти, след като вече сме я направили. Именно затова можем да си пазим резултатите в прост cache, имплементиран с Agent.

Ето как ще го създаваме:

defmodule Github do
  # Махнали сме другите функции за четимост...

  def new_cache, do: Agent.start(fn -> %{} end) |> Kernel.elem(1)
end

Нека преработим функцията stargazers да го ползва:

defmodule Github do
  @api "https://api.github.com"
  @timeout 10_000

  def new_cache, do: Agent.start(fn -> %{} end) |> Kernel.elem(1)

  def stargazers(user, repo, cache \\ nil) do
    action = fn _ ->
      HTTPoison.get("#{@api}/repos/#{user}/#{repo}/stargazers")
      |> decode_stargazers()
    end

    case cache do
      pid when is_pid(pid) ->
        Agent.get_and_update(pid, cache_updater(:stargazers, action), @timeout)
      nil ->
        action.(nil)
    end
  end

  defp cache_updater(key, action) do
    fn state ->
      new_state = Map.put_new_lazy(state, key, fn -> action.(key) end)

      {Map.get(new_state, key), new_state}
    end
  end

  # Махнали сме другите функции за четимост...
end

Първо променяме stargazers да може да получава за трети параметър cache, който може да е атома nil ако не искаме да го ползваме. Правим си анонимна функция action/1, която изпълнява логиката на досегашната версия на Github.stargazers/2. Ако кеша е nil, просто я извикваме с nil, тъй като не ни интересува параметъра, ако кешът е pid, ползваме Agent.get_and_update/3.

Функцията Agent.get_and_update/3 взима pid на агента, функция, която връща и променя състояние като една операция и опционално timeout. Ние извикваме функцията от по-висок ред cache_updater/2 която приема ключ в кеша и действие, което да изпълни, ако ключът не съществува и връща функция, която би могла да се използва от Agent.get_and_update/3. Една такава функция трябва да приема състоянието на агента като аргумент и да връща наредена двойка с първи елемент, това, което Agent.get_and_update/3 ще върне като стойност и втори елемент новото състояние на агента.

Нека да видим как резултатът от cache_updater/2 променя състоянието. Очакваме state-ът да е Map, и използваме Map.put_new_lazy/3, която ще върне първият си аргумент непроменен, ако той съдържа дадения ключ, или ще изпълни функцията - трети аргумент само ако ключа го няма и ще върне нов Map получен от стария при прибавянето на тези нови ключ и стойност. Тоест състоянието ще се промени тогава и само тогава, когато няма нищо кеширано за този ключ (в случая :stargazers). Всеки следващ път, когато извикаме Agent.get_and_update(pid, cache_updater(:stargazers, action), @timeout) няма да правим HTTP request и няма да променяме състоянието на агента-кеш.

Връща се стойността на дадения ключ (в случая :stargazers), която на този етап би трябвало да е резултатът от HTTP заявката.

Следващите промени са:

defmodule Github do
  # Махнали сме другите функции за четимост...

  def user_info(users, cache \\ nil) do
    max_concurrency = System.schedulers_online() * 2

    Task.async_stream(users, &(get_user_info(&1, cache)), max_concurrency: max_concurrency)
    |> Stream.map(fn {:ok, data} -> data end)
    |> Enum.to_list()
  end

  defp get_user_info(user, cache) do
    action = fn user ->
      HTTPoison.get("#{@api}/users/#{user}") |> decode_user()
    end

    case cache do
      pid when is_pid(pid) ->
        Agent.get_and_update(pid, cache_updater(user, action), @timeout)
      nil ->
        action.(user)
    end
  end

  # Махнали сме другите функции за четимост...
end

Функцията get_user_info е същата като преди, но сега може да приеме cache и да го подаде на get_user_info. От своя страна get_user_info прави същото като Github.stargazers/3. Действието е друго - ползва за ключ login-а на потребителя и прави заявката с него. Извикваме cache_updater/2 с login-а на всеки потребител като ключ, като ги пазим като низове. Сами си отговорете защо не атоми.

Това е прост пример за употребата на агенти. В реалния живот за cache няма да ползвате агенти, защото има по-добри инструменти за това (ETS например), но за тестване и междинна работа агентите са добър избор.

Заключение

В тази публикация се запознахме с абстракциите Task и Agent и за първи път използвахме комуникация между процеси. Запознахме се и видяхме някои от употребите на няколко функции свързани с процеси като Process.sleep/1. Това ни дава малка идея как се работи с процеси и за какво са полезни те, но в никакъв случай не разкрива цялата картина на този слой от езика Elixir.

Ще си говорим за това какво наистина представляват процесите и как работят в няколко бъдещи публикации.