В Эликсире весь код запускается внутри процессов. Процессы изолированы друг от друга, запускаются параллельно и взаимодействуют через отправку сообщений. Процессы – не единственная основа параллельной работы в Эликсире, но они предоставляют базу для построения распределённых и отказоустойчивых программ.
Процессы Эликсира не следует путать с процессами операционной системы. Процессы в Эликсире очень легковесны в плане использования памяти и процессора (в отличие от потоков во многих других языках программирования). Поэтому запуск десятков или даже сотен тысяч процессов одновременно не проблема.
В этой главе мы изучим базовые конструкции для порождения новых процессов, а также отправку и приём сообщений между процессами.
Функция 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) в Эликсире.