[Erlang/OTP, Elixir/Phoenix] Стреляем себе в ногу с помощью GenServer'а, или как мы фиксили неуловимый баг в Elixir проекте
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Привет, Хабр! Меня зовут Иван, я — техлид в Каруне.В команде мы активно используем Elixir в одном из самых нагруженных проектов.Мы уделяем особое внимание тому, что за код выполняется в коллбеках GenServer'а, особенно если это код третьесторонних библиотек.В этой статье я расскажу, почему это настолько важно, и продемонстрирую, как с помощью простейших механизмов, которые предоставляют нам Elixir и Erlang, мы можем сломать поведение GenServer'a и породить трудноуловимые баги. Ещё расскажу, как мы боролись с таким багом в реальной жизни.Поехали!Key-value хранилищеНачнём с небольшого синтетического примера. Абстракция GenServer'а является одной из основных концепций в Erlang/OTP и Elixir. Принцип работы GenServer'а довольно прост.Это процесс, который хранит определённое состояние и последовательно обрабатывает входящие сообщения, изменяя это состояние и, возможно, посылая ответные сообщения.Перед тем, как перейти к тонкостям работы GenServer'а, давайте напишем один из них.В качестве примера рассмотрим простенькое key-value хранилище, которое может хранить и отдавать значения по ключу.Ничего сложного: пример, на котором демонстрируют работу GenServer'а во множестве статей и туториалов.
defmodule SimpleKeyValue do
use GenServer
def start do
GenServer.start(__MODULE__, %{})
end
def put(pid, key, value) do
GenServer.cast(pid, {:put, key, value})
end
def get(pid, key) do
GenServer.call(pid, {:get, key})
end
# Callbacks
@impl true
def init(state) do
{:ok, state}
end
@impl true
def handle_cast({:put, key, value}, state) do
{:noreply, Map.put(state, key, value)}
end
@impl true
def handle_call({:get, key}, _from, state) do
{:reply, Map.fetch!(state, key), state}
end
end
Запустим этот код в консоли. Как видим, всё работает, как задумано. Мы сохранили значение и смогли впоследствии получить его по ключу
Ломаем GenServerПришло время узнать некоторые детали внутренней кухни GenServer'a.Я считаю, что один из лучших способов понять, как что-то работает — попробовать это что-то сломать.А для этого давайте посмотрим, что случится с нашим GenServer'ом, если в коллбэке мы запустим свой собственный receive loop:
def run_receive_loop(server) do
GenServer.cast(server, :run_receive_loop)
end
@impl true
def handle_cast(:run_receive_loop, state) do
receive do
msg -> IO.inspect(msg, label: "RECEIVE LOOP")
end
{:noreply, state}
end
При запуске обновлённой версии мы получаем такое сообщение об ошибке:
Внимательный читатель уже догадался, что же тут происходит. Как мы знаем, в Elixir'e процессы общаются друг с другом с помощью посылки сообщений (message passing). Вызовы GenServer.call и GenServer.cast внутри себя используют кортежи с ключами :"$gen_call", :"$gen_cast" в качестве сообщений от клиентского процесса процессу GenServer'а. Затем, при помощи pattern matching, receive loop процесса GenServer'а вызывает соответсвующий коллбек. Опустим не важные нам сейчас детали. Выглядит это следующим образом:
def loop(state) do
receive msg do
{:"$gen_call", {from, ref}, msg} ->
callback_result = handle_call(msg, from, state)
# handle callback result, maybe reply to the client process, call loop again
{:"$gen_cast", {from, ref}, msg} ->
callback_result = handle_call(msg, from, state)
# handle callback result, call loop again
end
end
Однако в нашем примере handle_cast(:run_receive_loop, state) коллбек запускает вложенный receive loop и перехватывает следующее :"$gen_call" сообщение, адресованное на самом деле внешнему receive loop'у самого GenServer'а. В итоге handle_call коллбэк не отрабатывает, и клиентский процесс крашится по таймаут (по умолчанию 5000 ms), так и не дождавшись ответа на свой запрос SimpleKeyValue.get(pid, :foo).Пример из жизниМы наглядно показали, что запускать вложенный receive loop — не самая хорошая идея, которая может приводить к неопределённому поведению системы и трудноуловимым багам. С одним из них мне как-то и пришлось бороться.Конечно же, никто не запускал receive loop в коллбеке в явном виде. В реальности всё обычно скрыто за несколькими слоями абстракций.На одном легаси проекте в качестве HTTP клиента использовалась tesla поверх mint. Периодически в error логах проскакивали сообщения вида Encounter unknown error, иногда после них система крашилась. В общем, баг был плавающий и довольно неприятный.Грепнув код, я нашёл источник сообщений об ошибке в коде тесловского адаптера к mint'у:
defp stream_response(conn, opts, response \\ %{}) do
receive do
msg ->
case HTTP.stream(conn, msg) do
{:ok, conn, stream} -> ...
{:error, _conn, error, _res} -> ...
:unknown ->
{:error, "Encounter unknown error"}
end
after
opts |> Keyword.get(:adapter) |> Keyword.get(:timeout) ->
{:error, "Response timeout"}
end
end
У меня закралось подозрение, что этот receive loop мог перехватывать "чужие" сообщения, и не распарсив их как валидные HTTP ответы, сваливаться в :unknown clause. Потратив ещё какое-то время на поиск, я обнаружил, что в коллбеке одного GenServer'а выполнялся HTTP запрос. В итоге через цепочку вызовов выше указанный receive loop из stream_response/3 запускался напрямую в коллбеке и иногда перехватывал сообщения, адресованные непосредственно GenServer'у — до того, как получал ожидаемое сообщение от сокета. В общем, всё как я описывал в нашем синтетическом примере.В итоге HTTP запрос мы обернули в Task, чтобы receive loop выполнялся в контексте отдельного процесса, и проблема разрешилась:
def handle_call(msg, from, state) do
task = Task.async(fn -> HTTPClient.get_something() end)
case Task.await(task) do
{:ok, data} ->
# handle data
{:error, reason} ->
# handle error case (logging, etc)
end
# ...
end
ЗаключениеПонимание того, как работает receive loop, а также того, что за интерфейсами GenServer.call и GenServer.castскрывается обычная посылка сообщений процессу GenServer'а помогли нам отловить серьёзный баг и впоследствии проектировать системы более надежным способом.Я надеюсь, что эта история показалась вам интересной и поучительной. А также приоткрыла некоторую внутреннюю кухню Elixir'а и Erlang'а.Всем добра и спасибо за внимание!
===========
Источник:
habr.com
===========
Похожие новости:
- [PostgreSQL, Программирование, SQL, Администрирование баз данных] Опыт хранения IP-адресов в PostgreSQL
- [Программирование, Go] Дженерики в языке Go
- [Анализ и проектирование систем, Erlang/OTP, Параллельное программирование, Elixir/Phoenix] To spawn, or not to spawn? (перевод)
- [Erlang/OTP, Функциональное программирование, Elixir/Phoenix] Отправляем SMS из Erlang/Elixir. Короткая инструкция
- [Open source, Erlang/OTP, Elixir/Phoenix] Типы в рантайме: глубже в крольчью нору
- [Open source, Erlang/OTP, Elixir/Phoenix] Типы, где их не ждали
- [Программирование микроконтроллеров, Разработка на Raspberry Pi, DIY или Сделай сам, Здоровье] Затерянные в тумане, или Увлекательные приключения в мире АПР *
- [Функциональное программирование, Конференции, Elixir/Phoenix] Исследование экосистемы Elixir в СНГ 2020 и анонс очередного Elixir Meetup Online
- [Elixir/Phoenix, Erlang/OTP, Open source] «O tempora, o mores!»
- [Ненормальное программирование, PHP, JavaScript, Erlang/OTP] 20_20 — год в котором подчеркивание в числовых литералах победило
Теги для поиска: #_erlang/otp, #_elixir/phoenix, #_elixir, #_elixirlang, #_elixir_otp, #_genserver, #_blog_kompanii_karuna (
Блог компании Karuna
), #_erlang/otp, #_elixir/phoenix
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:15
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Привет, Хабр! Меня зовут Иван, я — техлид в Каруне.В команде мы активно используем Elixir в одном из самых нагруженных проектов.Мы уделяем особое внимание тому, что за код выполняется в коллбеках GenServer'а, особенно если это код третьесторонних библиотек.В этой статье я расскажу, почему это настолько важно, и продемонстрирую, как с помощью простейших механизмов, которые предоставляют нам Elixir и Erlang, мы можем сломать поведение GenServer'a и породить трудноуловимые баги. Ещё расскажу, как мы боролись с таким багом в реальной жизни.Поехали!Key-value хранилищеНачнём с небольшого синтетического примера. Абстракция GenServer'а является одной из основных концепций в Erlang/OTP и Elixir. Принцип работы GenServer'а довольно прост.Это процесс, который хранит определённое состояние и последовательно обрабатывает входящие сообщения, изменяя это состояние и, возможно, посылая ответные сообщения.Перед тем, как перейти к тонкостям работы GenServer'а, давайте напишем один из них.В качестве примера рассмотрим простенькое key-value хранилище, которое может хранить и отдавать значения по ключу.Ничего сложного: пример, на котором демонстрируют работу GenServer'а во множестве статей и туториалов. defmodule SimpleKeyValue do
use GenServer def start do GenServer.start(__MODULE__, %{}) end def put(pid, key, value) do GenServer.cast(pid, {:put, key, value}) end def get(pid, key) do GenServer.call(pid, {:get, key}) end # Callbacks @impl true def init(state) do {:ok, state} end @impl true def handle_cast({:put, key, value}, state) do {:noreply, Map.put(state, key, value)} end @impl true def handle_call({:get, key}, _from, state) do {:reply, Map.fetch!(state, key), state} end end Ломаем GenServerПришло время узнать некоторые детали внутренней кухни GenServer'a.Я считаю, что один из лучших способов понять, как что-то работает — попробовать это что-то сломать.А для этого давайте посмотрим, что случится с нашим GenServer'ом, если в коллбэке мы запустим свой собственный receive loop: def run_receive_loop(server) do
GenServer.cast(server, :run_receive_loop) end @impl true def handle_cast(:run_receive_loop, state) do receive do msg -> IO.inspect(msg, label: "RECEIVE LOOP") end {:noreply, state} end Внимательный читатель уже догадался, что же тут происходит. Как мы знаем, в Elixir'e процессы общаются друг с другом с помощью посылки сообщений (message passing). Вызовы GenServer.call и GenServer.cast внутри себя используют кортежи с ключами :"$gen_call", :"$gen_cast" в качестве сообщений от клиентского процесса процессу GenServer'а. Затем, при помощи pattern matching, receive loop процесса GenServer'а вызывает соответсвующий коллбек. Опустим не важные нам сейчас детали. Выглядит это следующим образом: def loop(state) do
receive msg do {:"$gen_call", {from, ref}, msg} -> callback_result = handle_call(msg, from, state) # handle callback result, maybe reply to the client process, call loop again {:"$gen_cast", {from, ref}, msg} -> callback_result = handle_call(msg, from, state) # handle callback result, call loop again end end defp stream_response(conn, opts, response \\ %{}) do
receive do msg -> case HTTP.stream(conn, msg) do {:ok, conn, stream} -> ... {:error, _conn, error, _res} -> ... :unknown -> {:error, "Encounter unknown error"} end after opts |> Keyword.get(:adapter) |> Keyword.get(:timeout) -> {:error, "Response timeout"} end end def handle_call(msg, from, state) do
task = Task.async(fn -> HTTPClient.get_something() end) case Task.await(task) do {:ok, data} -> # handle data {:error, reason} -> # handle error case (logging, etc) end # ... end =========== Источник: habr.com =========== Похожие новости:
Блог компании Karuna ), #_erlang/otp, #_elixir/phoenix |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 13:15
Часовой пояс: UTC + 5