Как избежать состояния гонки при использовании GenServer

Предположим, вам необходимо разработать многопользовательскую игру, и состояние игры вы решили хранить в структуре Game. Планируется запускать игру без игроков: они будут присоединяться к ней в индивидуальном порядке. Введём максимальное количество игроков @max_players, а список игроков представим в виде списка строк (ников игроков).

defmodule Game do
  @max_players 4
  defstruct players: []

  def add_player(game, nick) do
    Map.update!(game, :players, &([nick | &1]))
  end

  def max_players?(%{players: ps}) when length(ps) < @max_players, do: false
  def max_players?(_game), do: true
end

Попробуем выполнить это в iex.

iex(1)> game = %Game{}
%Game{players: []}
iex(2)> game = Game.add_player(game, "ben")
%Game{players: ["ben"]}
iex(3)> game = Game.add_player(game, "harry")
%Game{players: ["harry", "ben"]}
iex(4)> game = Game.add_player(game, "ralph")
%Game{players: ["ralph", "harry", "ben"]}
iex(5)> Game.max_players?(game)
false
iex(6)> game = Game.add_player(game, "tom")
%Game{players: ["tom", "ralph", "harry", "ben"]}
iex(7)> Game.max_players?(game)
true

Кажется, работает! Теперь хорошо бы обернуть всё это в процесс. OTP предоставляет несколько способов для решения этой задачи. Один из них — это GenServer. Для каждой игры организуем свой процесс, идентифицировать который будем по PID. Создадим свой GenServer и назовём его GameServer.

defmodule GameServer do
  use GenServer

  def start_link do
    GenServer.start_link(__MODULE__, %Game{})
  end

  def get_state(pid) do
    GenServer.call(pid, :get_state)
  end

  def add_player(pid, nick) do
    game = get_state(pid)
    if Game.max_players?(game) do
      {:error, :max_players}
    else
      GenServer.call(pid, {:add_player, nick})
    end
  end

  # Callbacks

  def handle_call(:get_state,  _from, game) do
    {:reply, game, game}
  end

  def handle_call({:add_player, nick}, _from, game) do
    {:reply, :ok, Game.add_player(game, nick)}
  end
end

У нас будет две функции:

  1. Функция get_state, которая возвращает текущее состояние игры;

  2. Функция add_player, которая возвращает {:error, reason}, если игрока невозможно добавить в игру и :ok в противном случае.

Снова запустим iex:

iex(1)> {:ok, pid} = GameServer.start_link
{:ok, #PID<0.91.0>}
iex(2)> GameServer.add_player(pid, "ben")
:ok
iex(3)> GameServer.add_player(pid, "harry")
:ok
iex(4)> GameServer.add_player(pid, "ralph")
:ok
iex(5)> GameServer.add_player(pid, "tom")
:ok
iex(6)> GameServer.add_player(pid, "richard")
{:error, :max_players}

Скрытое состояние гонки

В интерактивной оболочке это работает, но при развёртывании в продакшн вы можете столкнуться с тем, что время от времени каждый 5-й или 6-й игрок будет входить в игру вне очереди. А вот и состояние гонки! Докажем это, реализовав временную функцию в GameServer, которая добавляет n игроков одновременно:

defmodule GameServer do
  # ...
  def add_players_simultaneously(pid, nicks) do
    Enum.each(nicks, fn nick ->
      Task.async(fn ->
        GameServer.add_player(pid, nick)
      end)
    end)
  end
  # ...
end

Данная функция обращается к списку ников и параллельно с этим вызывает add_player, используя отдельный процесс для каждого ника. Предположим, пятеро игроков пытаются присоединиться к игре.

iex(1)> {:ok, pid} = GameServer.start_link
{:ok, #PID<0.100.0>}
iex(2)> nicks =  ["ben", "harry", "ralph", "tom", "richard"]
["ben", "harry", "ralph", "tom", "richard"]
iex(3)> GameServer.add_players_simultaneously(pid, nicks)
:ok
iex(4)> GameServer.get_state(pid)
%Game{players: ["richard", "tom", "ralph", "harry", "ben"]}

Подождите, каким образом в список попал richard?

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

Чтобы увидеть, как это работает, добавим несколько операторов puts в функцию GameServer.add_player.

defmodule GameServer do
  # ...
  def add_player(pid, nick) do
    game = get_state(pid)
    IO.puts("Players in game: #{inspect game.players}")
    if Game.max_players?(game) do
      {:error, :max_players}
    else
      IO.puts("Add player #{nick}")
      GenServer.call(pid, {:add_player, nick})
    end
  end
  # ...
end

А теперь снова запустим.

iex(1)> {:ok, pid} = GameServer.start_link
{:ok, #PID<0.119.0>}
iex(2)> nicks =  ["ben", "harry", "ralph", "tom", "richard"]
["ben", "harry", "ralph", "tom", "richard"]
iex(3)> GameServer.add_players_simultaneously(pid, nicks)
Players in game: []
Players in game: []
Players in game: []
Players in game: []
Players in game: []
:ok
Add player ben
Add player harry
Add player ralph
Add player tom
Add player richard

Это типичное состояние гонки «проверь-затем-действуй» (check-then-act). Важно помнить, что GenServer обрабатывает по одному сообщению из почтового ящика в порядке их получения. Например, приходят пять сообщений get_state, GenServer обрабатывает их и возвращает текущее состояние. При этом игроки видят устаревшее состояние, каждый из них пытается войти в игру, и поступают ещё пять сообщений.

Чтобы это исправить, нужно превратить операцию add_player в атомарную. С GenServer это сделать достаточно просто, так как всё, что происходит в функции обратного вызова, в нём по умолчанию атомарно! Всё, что нам остаётся сделать, — это перенести эту логику в функцию обратного вызова add_player.

defmodule GameServer do
  # ...

  # Removed the max_players? check
  def add_player(pid, nick) do
    GenServer.call(pid, {:add_player, nick})
  end

  # ...

  # Checking inside the callback now
  def handle_call({:add_player, nick}, _from, game) do
    if Game.max_players?(game) do
      {:reply, {:error, :max_players}, game}
    else
      {:reply, :ok, Game.add_player(game, nick)}
    end
  end

  # ...
end

Попробуем ещё раз.

iex(1)> {:ok, pid} = GameServer.start_link
{:ok, #PID<0.137.0>}
iex(2)> nicks =  ["ben", "harry", "ralph", "tom", "richard"]
["ben", "harry", "ralph", "tom", "richard"]
iex(3)> GameServer.add_players_simultaneously(pid, nicks)
:ok
iex(4)> GameServer.get_state(pid)
%Game{players: ["ralph", "tom", "harry", "ben"]}

Получаем устойчивое состояние, как и ожидалось! Если бы richard попытался присоединиться к игре, он бы получил ошибку max_players. В семантике GenServer примечательно то, что можно представить свой код в виде клиента и сервера. Все открытые API-функции, перечисленные в начале модуля, находятся в вызывающем процессе, то есть на стороне клиента, а все функции обратного вызова handle_* — в процессе GenServer или на стороне сервера. Если держать в голове такую модель, то сразу становится понятно, к какой стороне принадлежит ваш код.

Узкие места

Конечно, есть искушение в целях безопасности скинуть всё на сервер, но не стоит забывать, что это скажется на производительности. Создавая функции обратного вызова, вы должны задать себе вопрос:

«Каково минимально возможное количество операций, которые мне необходимо выполнить, чтобы определить следующее состояние?»

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

Комплексное решение

Несмотря на то, что в приведённом примере всё прекрасно работало, при разработке структуры Game была допущена ошибка. Создавая новую структуру данных, хорошо бы подумать о том, как предотвратить возможность приведения её в недопустимое состояние. Для обеспечения устойчивости состояния логика должна прослеживаться на уровне модуля. Работая с новой структурой, следует использовать только такой интерфейс для работы со структурами и возвращать ошибку, если что-то пошло не так. Можно изменить интерфейс модуля Game примерно следующим образом:

defmodule Game do
  @max_players 4
  defstruct players: []

  def add_player(game, nick) do
    if max_players?(game) do
      {:error, :max_players}
    else
      {:ok, Map.update!(game, :players, &([nick | &1]))}
    end
  end

  defp max_players?(%{players: ps}) when length(ps) < @max_players, do: false
  defp max_players?(_game), do: true
end

Серверы скинут немного кода, но, думаю, это даже к лучшему. Можно почистить их ещё больше, например, с помощью exactor.

Изображение взято из игры Cosmic Race.

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