Бесплатный курс по Elixir. Зарегистрируйтесь для отслеживания прогресса →

Elixir: Связь процессов

Так как процессы изолированы друг от друга, то возникновение ошибки в одном процессе никак не отражается на работе остальных:

spawn(fn -> raise "boom" end)
# => 17:27:46.980 [error] Process #PID<0.277.0> raised an exception
# => ** (RuntimeError) boom
# =>     iex:156: (file)

Однако, в некоторых ситуациях нужно, чтобы процессы были связаны между собой для обработки ошибок. Создание связи происходит через функцию spawn_link:

self()
# => #PID<0.105.0>

spawn_link(fn -> raise "boom" end)
# => ** (EXIT from #PID<0.105.0>) shell process exited with reason: an exception was raised:
# =>     ** (RuntimeError) boom
# =>         iex:158: (file)

# => 17:30:45.109 [error] Process #PID<0.280.0> raised an exception
# => ** (RuntimeError) boom
# =>     iex:158: (file)

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

Благодаря связям, можно создавать процессы супервизоры (supervisors), которые следят за порожденными процессами рабочими (workers). При возникновении внештатных ситуаций процесс супервизор перезапускает наблюдаемый им процесс. Такая архитектура лежит в основе Elixir и Erlang приложений и называется дерево супервизии, внутри которого отдельные процессы супервизоры запускают другие процессы и следят за их работой, перезапуская при необходимости. Корнем в этом дереве выступает само приложение, внутри которого запущены остальные процессы. Выглядит дерево супервизии примерно так:

#                      супервизор игровой сессии       -- обработчики игровых сессий
#                    /
# процесс приложения  -- супервизор обработки платежей -- обработчики платежей
#                    \
#                      супервизор рассылки уведомлений -- отправители уведомлений

Теперь рассмотрим причины завершения процессов. Когда процесс заканчивает свою работу, он завершается (finishes) в нормальном (normal) режиме, по сути нормальное завершение процесса выглядит как прямой вызов функции exit(:normal) из модуля Process:

spawn_link(fn -> exit(:normal) end)
# => #PID<0.282.0>
spawn(fn -> exit(:normal) end)
# => #PID<0.283.0>

Результат работы функций идентичный из-за завершения процесса в режиме :normal. Если же причина будет другой, то завершение процесса будет расцениваться как аварийное:

spawn(fn -> exit(:boom_reason) end)
# => #PID<0.284.0>

spawn_link(fn -> exit(:boom_reason) end)
# => ** (EXIT from #PID<0.281.0>) shell process exited with reason: :boom_reason
#
# => Interactive Elixir (1.15.0) - press Ctrl+C to exit (type h() ENTER for help)

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

Именно так и работает дерево супервизии. Но для перезапуска нужно как-то понять, каким образом завершился наблюдаемый процесс. Для этих целей используется перехват выхода или перехват завершения процесса (trap exits) через специальный флаг, который выставляется для настройки процесса. Процессы можно конфигурировать, например размер кучи, приоритет, перехват завершения и многое другое. Нас интересует флаг перехвата, который конвертирует все сигналы завершения процесса в сообщения вида {'EXIT', from, reason}. Настройка процессов таким образом не является стандартной практикой, так как фреймворк OTP предоставляет специальную абстракцию под это, которая и называется Supervisor. С OTP фреймворком познакомимся чуть позже, когда разберемся как процессы работают на более низком уровне.

Теперь настроим процесс на перехват сигналов о завершении:

Process.flag(:trap_exit, true)
# => false

spawn_link(fn -> 1 + 1 end)
# => #PID<0.287.0>
Process.info(self(), :messages)
# => {:messages, [{:EXIT, #PID<0.287.0>, :normal}]}

Как видно из кода, процесс посчитал выражение и завершился в штатном режиме. Теперь завершим процесс с другим сигналом:

spawn_link(fn -> exit(:not_okay_reason) end)
# => #PID<0.288.0>
Process.info(self(), :messages)
# =>{:messages, [{:EXIT, #PID<0.288.0>, :not_okay_reason}]}

Теперь мы можем перехватывать любые сообщения о завершении и реагировать на них:

receive do
  {:EXIT, pid, :normal} -> "Process #{inspect(pid)} exited normally"
  {:EXIT, pid, reason} -> "Process #{inspect(pid)} exited abnormally #{reason}"
end

Более того, теперь мы можем перехватывать и исключения:

spawn_link(fn -> raise "boom!" end)
# => #PID<0.107.0>
#
# => 02:15:35.982 [error] Process #PID<0.107.0> raised an exception
# => ** (RuntimeError) boom!
# =>     iex:4: (file)

Process.info(self(), :messages)
# => {:messages, [{:EXIT, #PID<0.107.0>, {%RuntimeError{message: "boom!"}, [{:elixir_eval, :__FILE__, 1, [file: ~c"iex", line: 4]}]}}]}

Задание

В файле с решением описан модуль Worker. Для работы с ним, нужно создать и связать процесс с этим модулем следующим образом:

number = 5
spawn_link(fn -> Worker.work(number) end)

После этого, Worker проверяет число по правилам классической задачи с собеседований FooBar и процесс завершается с соответствующим сигналом:
- число кратно 3 и 5 - :foobar;
- число кратно 3 - :foo;
- число кратно 3 - :bar;
- в ином случае процесс завершается в штатном режиме :normal.

Создайте функцию supervise_foobar которая принимает число, вызывает Worker и на основе сигнала о завершении формирует строку, где вместо переданного числа подставляется Foo, Bar, FooBar либо ничего, затем число увеличивается на единицу и процесс проверки числа и сбора строки идет дальше.

Если переданное число больше 100 или меньше 1, то продолжать сбор строки не нужно. Не забудьте о Process.flag(:trap_exit, true), так как иначе не получится собрать информацию о сигналах завершения Worker. Примеры:

Solution.supervise_foobar(0)
# => ""

Solution.supervise_foobar(-10)
# => ""

Solution.supervise_foobar(11123)
# => ""

Solution.supervise_foobar(80)
# => "Bar Foo Foo Bar Foo FooBar Foo Bar Foo Foo Bar"

Solution.supervise_foobar(100)
# => "Bar"

Solution.supervise_foobar(75)
# => "FooBar Foo Bar Foo Foo Bar Foo FooBar Foo Bar Foo Foo Bar"
Упражнение не проходит проверку — что делать? 😶

Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:

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

Тесты устроены таким образом, что они проверяют решение разными способами и на разных данных. Часто решение работает с одними входными данными, но не работает с другими. Чтобы разобраться с этим моментом, изучите вкладку «Тесты» и внимательно посмотрите на вывод ошибок, в котором есть подсказки.

Мой код отличается от решения учителя 🤔

Это нормально 🙆, в программировании одну задачу можно выполнить множеством способов. Если ваш код прошел проверку, то он соответствует условиям задачи.

В редких случаях бывает, что решение подогнано под тесты, но это видно сразу.

Прочитал урок — ничего не понятно 🙄

Создавать обучающие материалы, понятные для всех без исключения, довольно сложно. Мы очень стараемся, но всегда есть что улучшать. Если вы встретили материал, который вам непонятен, опишите проблему в «Обсуждениях». Идеально, если вы сформулируете непонятные моменты в виде вопросов. Обычно нам нужно несколько дней для внесения правок.

Кстати, вы тоже можете участвовать в улучшении курсов: внизу есть ссылка на исходный код уроков, который можно править прямо из браузера.

Полезное


Нашли ошибку? Есть что добавить? Пулреквесты приветствуются https://github.com/hexlet-basics
Если вы столкнулись с трудностями и не знаете, что делать, задайте вопрос в нашем большом и дружном сообществе