Процессы

В Эликсире весь код запускается внутри процессов. Процессы изолированы друг от друга, запускаются параллельно и взаимодействуют через отправку сообщений. Процессы – не единственная основа параллельной работы в Эликсире, но они предоставляют базу для построения распределённых и отказоустойчивых программ.

Процессы Эликсира не следует путать с процессами операционной системы. Процессы в Эликсире очень легковесны в плане использования памяти и процессора (в отличие от потоков во многих других языках программирования). Поэтому запуск десятков или даже сотен тысяч процессов одновременно не проблема.

В этой главе мы изучим базовые конструкции для порождения новых процессов, а также отправку и приём сообщений между процессами.

Функция spawn

Основной механизм порождения новых процессов – автоматически импортируемая функция spawn/1:

iex> spawn fn -> 1 + 2 end
#PID<0.43.0>

Функция spawn/1 принимает функцию, которую она выполнит в другом процессе.

Обратите внимание, что функция spawn/1 возвращает идентификатор процесса PID. К этому моменту, процесс, который вы породили, скорее всего мёртв. Порождённый процесс выполнит переданную функцию и умрёт после её окончания:

iex> pid = spawn fn -> 1 + 2 end
#PID<0.44.0>

iex> Process.alive?(pid)
false

Вы скорее всего получите идентификаторы процессов, отличные от представленных в этом руководстве.

Мы можем получить PID текущего процесса, вызвав функцию self/0:

iex> self()
#PID<0.41.0>

iex> Process.alive?(self())
true

Процессы становятся намного интереснее, когда мы можем отправлять и получать сообщения.

Функции send и receive

Мы можем отправлять сообщения в процесс с помощью функции send/2 и принимать их через функцию recieve/1

iex> send self(), {:hello, "world"}
{:hello, "world"}

iex> receive do
...>   {:hello, msg} -> msg
...>   {:world, msg} -> "won't match"
...> end
"world"

Когда сообщение отправлено в процесс, оно хранится в почтовом ящике процесса. Блок функции recieve/1 проходит через почтовый ящик текущего процесса в поисах сообщения, которое подходит под переданный шаблон. Функция receive/1 поддерживает ограничительные условия и множественные варианты входа так же, как case/2.

Процесс, который отправляет сообщение, не блокируется на функции send/2, он помещает сообщение в почтовый ящик получателя и продолжается. В частности, процесс может отправлять сообщение сам себе.

Если в ящике нет сообщений, подходящих какому-либо из шаблонов, текущий процесс будет ждать, пока не появится подходящее сообщение. Время ожидания также может быть задано:

iex> receive do
...>   {:hello, msg}  -> msg
...> after
...>   1_000 -> "nothing after 1s"
...> end
"nothing after 1s"

Можно установить таймаут 0, если ожидается, что сообщение уже должно быть в ящике.

Давайте объединим всё и отправим сообщение между процессами:

iex> parent = self()
#PID<0.41.0>

iex> spawn fn -> send(parent, {:hello, self()}) end
#PID<0.48.0>

iex> receive do
...>   {:hello, pid} -> "Got hello from #{inspect pid}"
...> end
"Got hello from #PID<0.48.0>"

Функция inspect/1 используется для конвертации внутреннего представления структур данных в строки, обычно для вывода. Помните, что когда блок функции recieve выполняется, процесс отправитель может быть уже мёртв, т. к. единственная его инструкция – отправка сообщения.

Во время работы в консоли, вы можете найти достаточно полезным хелпер flush/0. Он получает и печатает все сообщения из ящика:

iex> send self(), :hello
:hello

iex> flush()
:hello
:ok

Ссылки

Большую часть времени, порождая процессы в Эликсире, мы порождаем их как связанные процессы. До того, как мы покажем пример с использованием функции spawn_link/1, давайте посмотрим, что произойдёт, когда процесс, начатый функцией spawn/1 завершится с ошибкой:

iex> spawn fn -> raise "oops" end
#PID<0.58.0>

[error] Process #PID<0.58.00> raised an exception
** (RuntimeError) oops
    :erlang.apply/2

Он просто выведет ошибку, но процесс-родитель будет всё равно запущен. Это происходит, т. к. процессы изолированы. Если мы хотим, чтобы отказ одного процесса приводил к отказу другого, следует связать их. Это можно сделать с помощью spawn_link/1:

iex> spawn_link fn -> raise "oops" end
#PID<0.41.0>

** (EXIT from #PID<0.41.0>) an exception was raised:
    ** (RuntimeError) oops
        :erlang.apply/2

Т. к. процессы связаны, мы видим сообщение о том, что процесс-родитель, а это процесс консоли, получил сигнал EXIT от другого процесса, что привело к завершению работы консоли. IEx определяет такую ситуацию и начинает новую сессию.

Связывание также можно сделать вручную, вызвав функцию Process.link/1. Мы рекомендуем взглянуть на модуль Process, чтобы узнать о другой функциональности процессов.

Процессы и ссылки играют важную роль в создании отказоустойчивых систем. Процессы Эликсира изолированы и ничего не делят между собой по умолчанию. Таким образом, завершение процесса с ошибкой никогда не завершит и не нарушит другой процесс. Однако, ссылки позволяют установить зависимость на случай ошибки. Мы часто связываем наши процессы с супервизором, что позволяет обнаруживать смерть процесса и начинать новый на его месте.

В то время, как другие языки предусматривают отлов и обработку исключений, в Эликсире мы на самом деле нормально относимся к ошибкам и ожидаем, что супервизор просто перезапустит систему. «Failing fast» – основная философия написания ПО на Эликсире!

Функции spawn/1 и spawn_link/1 самые основные функции порождения процессов в Эликсире. Хотя мы их и используем, большую часть времени мы будем использовать абстракции, основанные на них. Давайте разберём одну из основных, которая называется задачи.

Задачи

Задачи основаны на функциях порождения и предоставляют лучшие отчёты об ошибках:

iex(1)> Task.start fn -> raise "oops" end
{:ok, #PID<0.55.0>}

15:22:33.046 [error] Task #PID<0.55.0> started from #PID<0.53.0> terminating
** (RuntimeError) oops
    (elixir) lib/task/supervised.ex:74: Task.Supervised.do_apply/2
    (stdlib) proc_lib.erl:239: :proc_lib.init_p_do_apply/3
Function: #Function<20.90072148/0 in :erl_eval.expr/5>
    Args: []

В отличии от функций spawn/1 и spawn_link/1 мы используем функции Task.start/1 и Task.start_link/1, которые возвращают {:ok, pid}, а не только PID. Это то, что позволяет использовать задачи в деревьях супервизоров. Более того, модуль Task предоставляет удобные функции, такие как Task.async/1 и Task.await/1, и функциональность для облегчения организации распределённой работы.

Мы будем разбирать эту функциональность в Руководстве по Mix и OTP, сейчас достаточно запомнить, что Task используется для получения лучших отчётов об ошибках.

Состояние

Мы до сих пор не говорили о состояниях в этом руководстве. Когда вы разрабатываете приложение, которому нужно состояние, например, для хранения конфигурации приложения, или вам нужно распарсить файл и держать его в памяти, где бы вы хранили это?

Процессы – наиболее частый ответ на этот вопрос. Мы можем написать процессы, которые зациклены бесконечно, хранят состояние, принимают и отправляют сообщения. В качестве примера давайте напишем модуль, который создаёт новые процессы, которые работают как хранилище ключ-значение в файле с названием kv.exs:

defmodule KV do
  def start_link do
    Task.start_link(fn -> loop(%{}) end)
  end

  defp loop(map) do
    receive do
      {:get, key, caller} ->
        send caller, Map.get(map, key)
        loop(map)
      {:put, key, value} ->
        loop(Map.put(map, key, value))
    end
  end
end

Обратите внимание, что функция start_link начинает новый процесс, который запускает функцию loop/1 с пустым словарем. Функция loop/1 затем ждёт сообщения и выполняет подходящее действие на каждое сообщение. В случае получения сообщения :get, она отправляет сообщение назад и вызывает loop/1 снова, в ожидании нового сообщения. Тогда как сообщение :put запускает loop/1 с новой версией словаря, сохранив переданные key и value.

Давайте опробуем это на практике, запустив iex kv.exs:

iex> {:ok, pid} = KV.start_link
{:ok, #PID<0.62.0>}
iex> send pid, {:get, :hello, self()}
{:get, :hello, #PID<0.41.0>}
iex> flush()
nil
:ok

В начале словарь процесса не имеет ключей, поэтому отправка сообщения :get и затем чтение почтового ящика текущего процесса возвращает nil. Давайте отправим сообщение :put и попробуем получить значение снова:

iex> send pid, {:put, :hello, :world}
{:put, :hello, :world}
iex> send pid, {:get, :hello, self()}
{:get, :hello, #PID<0.41.0>}
iex> flush()
:world
:ok

Теперь мы видим, как процесс хранит состояние и мы можем получить и обновить его, отправляя процессу сообщения. Фактически, любой процесс, который знает pid выше, сможет отправлять сообщения и изменять состояние.

Также можно зарегистрировать pid, присвоив ему имя, и позволить всем, кто знает это имя, отправлять ему сообщения:

iex> Process.register(pid, :kv)
true
iex> send :kv, {:get, :hello, self()}
{:get, :hello, #PID<0.41.0>}
iex> flush()
:world
:ok

Использование процессов для хранения состояний и регистрация имён – очень распространённые шаблоны в приложениях на Эликсире. Однако, в основном мы не будем вручную реализовывать эти шаблоны, как показано выше, а будем использовать одну из многих абстракций, которые поставляются вместе с Эликсиром. Например, Эликсир предоставляет агентов, которые являются простой абстракцией вокруг состояний:

iex> {:ok, pid} = Agent.start_link(fn -> %{} end)
{:ok, #PID<0.72.0>}
iex> Agent.update(pid, fn map -> Map.put(map, :hello, :world) end)
:ok
iex> Agent.get(pid, fn map -> Map.get(map, :hello) end)
:world

Опция :name также может быть передана в Agent.start_link/2 и оно будет автоматически зарегистрировано. Помимо агентов, Эликсир предоставляет API для создания генсерверов (generic servers, GenServer), задач и прочего, всё это основано на процессах. Всё, что связано с деревом супервизора, будет рассмотрено детально в Руководстве по Миксу и OTP, в котором будет рассказано о создании законченного Эликсир-приложения от начала до конца.

Теперь давайте продолжим и поговорим о вводе/выводе (I/O) в Эликсире.

© 2020 / Россия Любые мысли и вопросы пишите на elixir@wunsh.ru.