Как избежать состояния гонки при использовании 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
У нас будет две функции:
-
Функция
get_state
, которая возвращает текущее состояние игры; -
Функция
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.