Исследуйте процессы эликсира
Отрывок из книги Стивена Басси «От рубина к эликсиру».
In this excerpt: * Spawn a Process * Process Messages Forever * Error Isolation in Processes * Process Memory Architecture * Garbage Collection
Процессы — это основа параллелизма в Elixir. Они маленькие, их легко создать, и вы можете запустить столько из них, сколько у вас есть памяти, поскольку в производстве от десятков до сотен тысяч было бы нормальным. Вам не нужно много знать об архитектуре процессов BEAM, чтобы использовать процессы, но детали действительно подчеркивают, насколько они эффективны.
В этом разделе мы рассмотрим основы процессов, а также рассмотрим некоторые интересные детали архитектуры процессов. Вы научитесь порождать процесс и передавать ему сообщения, а также создадите бесконечно работающий процесс, реагирующий на входящие сообщения. Мы также рассмотрим изоляцию ошибок, изоляцию памяти и сборку мусора.
Создать процесс
Эликсир позволяет очень легко начать новый процесс. Функции spawn/1 берут функцию и выполняют ее внутри нового процесса. Сделаем это в IEx:
iex> self () #PID<0.109.0> iex> pid = spawn( fn -> IO. put s ( "Hello from #{inspect self ()} " ) end) Hello from #PID<0.112.0> #PID<0.112.0> iex> Process.alive?(pid) false
Мы передаем функцию в spawn/1
, которая выводит некоторую информацию о выполняющемся процессе. Ваши точные числа будут отличаться от моих, но обратите внимание, что порожденная функция выводится как 0.112.0
, а исходный процесс — 0.109.0
. Это называется идентификатором процесса (PID) и является одним из основных типов данных в Elixir. Разница в PID доказывает, что порожденная функция на самом деле выполняется внутри другого процесса.
Этот порожденный процесс был бы полезен, если бы мы хотели запустить некоторый асинхронный код, но сейчас он не так уж полезен. Нам нужно иметь возможность отправлять сообщения в процесс и получать от него ответы, чтобы превратить его в полезный инструмент. Функция получения позволяет нам сделать именно это. А чтобы отправить сообщение процессу, воспользуемся функцией send
.
Введите этот код в сеанс IEx:
iex> pid = spawn(fn -> receive do :hello -> IO.puts("Hello World") {:hello, name} -> IO.puts("Hello #{name}") end end) iex> Process.alive?(pid) true iex> send(pid, :hello) Hello World :hello iex> Process.alive?(pid) false
Мы смогли обработать сообщение :hello
и увидели, что был напечатан правильный вывод. Если вы повторите пример с send(pid, { :hello, “Your Name”})
, вы увидите, что он отвечает другим сообщением. Функция получения использует сопоставление с образцом, чтобы определить, какой код запускать, точно так же, как вы уже знакомы с этим.
Мы не будем выполнять это упражнение здесь, но если вы хотите получить ответ от порожденного сервера, вы должны отправить его исходному процессу. Это требует, чтобы вы передали PID текущего процесса как часть сообщения, а затем получили ответ. Это довольно громоздко, но в следующем основном разделе вы увидите, как GenServer упрощает эту задачу.
Наш процесс больше не жив после одного сообщения. Давайте заставим его обрабатывать сообщения вечно.
Обрабатывать сообщения навсегда
Рекурсия очень полезна для создания бесконечных циклов. Обычно бесконечный цикл был бы плохой вещью, но он совершенно хорош, когда используется контролируемым образом.
Создайте lib/examples/spawn/infinite.ex
и добавьте следующий код:
defmodule Examples.Spawn.Infinite do def start do spawn(& loop/0) end defp loop do receive do {:add, a, b} -> IO.puts(a + b) ➤ loop() :memory -> {:memory, bytes} = Process.info(self(), :memory) IO.puts("I am using #{bytes} bytes") ➤ loop() :crash -> raise "I crashed" :bye -> IO.puts("Goodbye") end end end
Функция start/0
использует spawn/1
для запуска зацикленного процесса. Функция цикла очень проста, это всего лишь блок приема, в котором обрабатывается множество сообщений. Для всех сообщений, кроме :bye, функция loop/0
вызывается в последнюю очередь. Это создает рекурсивный цикл, который будет обрабатывать сообщения вечно. Давайте попробуем:
$ iex -S mix iex> pid = Examples.Spawn.Infinite.start() iex> send(pid, :memory) I am using 2608 bytes iex> send(pid, {:add, 1, 2}) 3 iex> send(pid, {:add, 50, 50}) 100
Вы можете расширить это упражнение, превратив loop/0
в loop/1
и сохраняя отслеживание состояния для каждого сообщения. Если бы вы сделали это, у вас был бы сервер, который меняет состояние на основе сообщений, полученных из внешнего мира. Это довольно небольшое изменение, но все еще довольно громоздкое. Не волнуйтесь, GenServer облегчит и нам эту задачу.
Прежде чем мы перейдем к GenServer, давайте рассмотрим некоторые интересные детали реализации процессов. Поначалу это может показаться неважным, но они существенно влияют на характеристики времени выполнения приложения Elixir.
Изоляция ошибок в процессах
Можно привести аргумент, что гениальность BEAM не в его способности к параллельному выполнению, а скорее в его способности изолировать ошибки. Давайте представим это в перспективе: если два запроса поступают на веб-сервер одновременно, и один из запросов дает сбой, то мы не ожидаем, что другой запрос также сработает.
Давайте создадим два процесса, а затем завершим один из них, чтобы создать базовую демонстрацию:
$ iex -S mix iex> p1 = Examples.Spawn.Infinite.start() #PID<0.161.0> iex> p2 = Examples.Spawn.Infinite.start() #PID<0.163.0> iex> send(p1, :crash) [error] Process #PID<0.161.0> raised an exception ** (RuntimeError) I crashed iex> [Process.alive?(p1), Process.alive?(p2)] [false, true]
Это простой пример, но он служит для демонстрации того, что нам не нужно было ничего делать, чтобы изолировать эту ошибку. На самом деле было бы невозможно отключить один из этих процессов от другого. Конечно, такое событие, как сбой базы данных, вызовет ошибки во всем приложении, но это будет связано с внешним фактором, а не с внутренним.
Легко принять изоляцию ошибок как должное. Умное программирование из фреймворков на языках, которые не обеспечивают изоляцию ошибок, создаст ощущение изоляции, но гарантия среды выполнения виртуальной машины — это еще один уровень уверенности.
Архитектура памяти процесса
Каждый процесс в Эликсире имеет собственное пространство памяти. Он состоит из кучи и стека, которые растут навстречу друг другу. В конце концов, если они не смогут расти, BEAM выделит процессу больше памяти.
Процессы запускаются с довольно небольшим объемом памяти. На моем компьютере я вижу 2608 байтов, занятых совершенно новым процессом:
$ iex -5 mix iex> pid = Examples.Spawn.Infinite.start() iex> Process.info(pid, :memory) {:memory, 2608}
Process.info(pid)
— очень полезный источник информации о любом активно работающем процессе. Он сообщает вам такие вещи, как размер кучи, размер стека, сокращения (которые примерно соответствуют использованию ЦП) и количество необработанных сообщений. Здесь мы использовали Process.info/2
, чтобы вернуть сфокусированную версию доступных данных.
Данные в Эликсире копируются между процессами. Поэтому, если вы отправляете сообщение процессу, эта память будет продублирована, а затем передана как сообщение. Это дает преимущества при работе с небольшими битами данных, но было бы напрасно копировать каждое сообщение между процессами. У Эликсира есть небольшая хитрость для оптимизации копий.
Elixir использует двоичную кучу для глобального хранения больших (> 64 байта) двоичных данных. Эта двоичная куча совместно используется процессами и использует подсчет ссылок, чтобы определить, когда можно очистить память. Поскольку BEAM использует неизменяемые данные, вам не нужно беспокоиться о том, что это вызовет ошибки в вашем приложении. Память здесь безопасна в использовании, и на нее могут без опасений ссылаться несколько процессов.
Небольшой объем памяти процессов является частью того, что делает их очень легкими для порождения и уничтожения. Но иногда вы обнаружите, что отлаживаете проблему, когда используется слишком много памяти. Итак, давайте рассмотрим, как работает сборка мусора.
Вывоз мусора
Сбор мусора — это не весело, верно? На самом деле, сборщик мусора BEAM весьма интересен. Я довольно подробно писал об этом в Фениксе в реальном времени, но в этой книге мы рассмотрим гораздо меньше.
Поскольку каждый процесс в Эликсире имеет свою собственную кучу памяти и стек, каждый процесс выполняет свою собственную сборку мусора. Двоичная куча, упомянутая в предыдущем разделе, является глобально общей, поэтому для ее обработки существует глобальный процесс сбора. Однако он относительно легкий, потому что двоичная куча использует двоичные файлы с подсчетом ссылок.
Каждый процесс сборки мусора выполняется быстро, потому что он имеет дело с меньшим объемом памяти, и это происходит в цикле в зависимости от того, сколько этот процесс перерабатывает данных. Если процесс очень активен или ему не хватает памяти, то цикл сбора данных у него будет происходить чаще, чем у процесса, который мало что делает.
Однако существует скрытая опасность, с которой вы должны быть осторожны. Долгоживущие процессы могут столкнуться с ситуацией, когда объем памяти, который они занимают, больше, чем им нужно, но они не будут подвергаться циклу сборки мусора, потому что они недостаточно активны, чтобы его запустить. В этой ситуации процесс может занимать больше памяти, чем ему нужно, в течение длительного периода времени. Давайте создадим искусственный пример:
$ iex -5 mix iex> pid = Examples.Spawn.Infinite.start() iex> Enum.each(l..1000, & send(pid, { : add , &1, & l})) iex> send(pid, :memory) I am using 62816 bytes
Ваши цифры могут отличаться здесь, но вы должны ожидать, что это число больше, чем 2,6 КБ, с которых оно начиналось. Это не сразу имеет смысл, потому что у нашего процесса нет состояния, и он обработал все свои сообщения. Итак, почему он занимает в тридцать раз больше памяти?
Проблема здесь в том, что почтовый ящик процесса находится в куче процесса. Поскольку мы завалили его сообщениями, ему пришлось выделить больше памяти для хранения этих сообщений. Процесс сборки мусора происходит только в случае исчерпания памяти или обработки заданного количества сокращений — ни то, ни другое не происходит.
Мы можем вручную запустить сборку мусора с помощью :erlang.garbage_collect(pid)
. Как только вы сделаете это и запросите память процесса, вы увидите, что она вернулась к своему начальному размеру.
iex> :er lang.garbage_collect(pid) iex> send(pid, :memory) I am using 2608 bytes
Количество долгоживущих процессов обычно достаточно мало, так что это не имеет значения. Но если это станет проблемой, то посмотрите на системную переменную ERL_FULLSWEEP_AFTER
и установите для нее число вроде 20
. Это приводит к более частому запуску сборки мусора за счет немного большей нагрузки на ЦП. Этот флаг включен на каждой производственной системе, над которой я работал, и он никогда не вызывал у меня проблем.
Есть еще один способ предотвратить раздувание памяти. Вы можете перевести отдельные процессы в состояние гибернации. Память спящего процесса максимально уменьшена. Но когда процесс получает сообщение, ему придется заплатить за выход из состояния гибернации и обработку сообщения. GenServer имеет опцию hibernate_after
, которая автоматически переходит в спящий режим, когда GenServer простаивает.
Оба метода важны, но вам, скорее всего, не понадобится их использовать в течение некоторого времени. Такие фреймворки, как Phoenix, используют спящий режим с нормальными настройками по умолчанию, так что вам часто не нужно об этом думать.
Теперь, когда вы знакомы с основами процессов, давайте посмотрим, как Elixir упрощает их с помощью GenServer.
Мы надеемся, что вам понравился этот отрывок из книги Стивена Басси From Ruby to Elixir. Вы можете приобрести электронную книгу непосредственно на The Pragmatic Bookshelf:
Во время бета-тестирования читатели могут оставлять комментарии и предложения на странице книги на DevTalk: