Многопоточность в Photon Plugin
Плагины в фотоне предназначены для того, чтобы запускать какую-то свою логику на своём Photon облаке. Так же даже если используется Photon SDK плагин является более быстрым способом, получить что-то работающее без погружения в дебри SDK.
В процессе создания и доработки плагина случается, что разработчики реализуют свои решения, упуская факт, что мы работаем в конкурентной среде и необходимо предпринимать дополнительные усилия, чтобы всё заработало правильно. Вот мы и поговорим о том, как сделать всё правильно.
И так мы начали говорить, о том, что сервер может выполнять часть логики. Какая это может быть логика? Если коротко, то любая. Но я бы всё равно выделил основные направления:
симуляция, когда сервер обсчитывает мир и клиенты с ним синхронизируются
всевозможного вида проверки, чтобы обеспечить защиту игроков как от читеров так и от хакеров.
взаимодействие с бэкендами разработчиков, чтобы поддержать социализацию игроков, собирать статистику и т.д. и т.п.
В некоторых случаях, что в общем-то очевидно, требуется делать вещи, которые вовлекают многопоточное взаимодействие. И вот тут, даже у опытных команд, вылезают упущения, которые ведут к тому, что весь сервер падает (но быстро поднимается). Простейший пример — это получение ответа от внешнего бэкэнда.
Прежде чем объяснить природу упущения, коснёмся, сначала, вопроса — как устроена многопоточность в фотоне и в плагине в частности.
Я предполагаю, что статью про файберы уже читали и знаете, что это такое. Если коротко, то файбер это очередь задач построенная либо надо пулом потоков, либо над одним потоком. Мы будем говорить про версию, работающую с пулом потоков. Остальные варианты не используются, поэтому не интересны. Вариант с пулом потоков исполняет накопившиеся задачи в системном пуле потоков, так что все они исполняются строго по порядку. Но приэтом каждый раз это может быть новый поток. [[Photon Fibers|Подробности тут]].
И так, в photon каждый пир и каждая комната имеет свой файбер. Сообщение от клиента попадает в файбер пира и там десеарилизуется и проверяется. Если всё хорошо, пир передаёт это сообщение в файбер комнаты, где оно ещё раз может быть подвергнуто проверкам и только после этого оно попадает в плагин. Такое разделение ответственности позволяет нам эффективно использовать возможности многопроцессорных/многоядерных систем.
Так же, из вышесказанного, следует, что плагин работает в файбере комнаты. Это в свою очередь означает, что из всех методов IPhotonPlugin можно безопасно работать с данными комнаты. Коллбэки для таймеров и http запросов созданных с помощью методов IPluginHost так же вызываются в файбере комнаты и доступ к данным комнаты из них безопасен.
Теперь про упущение — упущение всегда одно и тоже: разработчики начинают обращаться к данным комнаты не из файбера комнаты, что, обычно, завершается тремя вариантами:
повезло и ничего не упало
не обработанное исключение в управляемом коде → рестарт всего процесса
не обработанное исключение в нэйтив коде → рестарт всего процесса
Теперь как же всё это победить? Победить очень просто. Метод IPluginHost.Enqueue предназначен специально для таких случаев. Он наша палочка-выручалочка во всех возможных сценариях.
Для целей статьи выделю два сценария:
мы что-то делаем независимо от файбера комнаты и по результатам работы нам надо, например, разослать сообщения клиентам без использования async/await
то же что и 1, но с использованием async/await
Первый сценарий решается легко: мы просто вызываем IPluginHost.Enqueue и передаём ему экшен. Ну что-то вроде этого:
// что-то считали, вычисляли запрашивали и результируем :)
this.PluginHost.Enqueue(()=>
{
this.BroadcastEvent(....)
}
);
Второй сценарий более интересен, потому что до сих пор использование async/await было нерекомендовано и предлагаемые решения были угловаты. Но как мне кажется есть одно решение получше — это synchronization context.
Сразу оговорюсь, что в версии ExitGamesLibs.dll 1.3.34 PoolFiber and ThreadFiber имеют свой synchronization context и async/await будет работать правильно из коробки, но пока она недоступна широкому кругу разработчиков, поэтому обсудим решение проблемы, если, как это часто бывает, нужно вчера.
Решение простое, но требует от разработчика внимательности.
создаётся класс-наследник класса SynchronizationContext
каждый плагин создаёт его и держит на готове
перед использованием await текущим контекстом выставляется контекст плагина.
сбрасываем в правильный момент на предыдущий
Класс-наследник
public class PluginSychContext : SynchronizationContext
{
private readonly IPluginHost host;
public PluginSychContext(IPluginHost host)
{
this.host = host;
}
public override void Post(SendOrPostCallback d, object state)
{
this.host.Enqueue(() => d(state));
}
}
Всё. Вот так просто.
Пункт два пропустим и перейдём к 3 и 4.
public void CallerForAsyncMethod()
{
var old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(this.pluginContext);
try
{
this.AsyncMethod();
}
finally
{
SynchronizationContext.SetSynchronizationContext(old);
}
}
public async void AsyncMethod()
{
await Task.Delay(500);
}
Для того, чтобы установить и сбросить контекст пришлось добавить специальный метод. Строго говоря, он не нужен, чтобы выставить новый контекст, но он нужен, чтобы корректно его сбросить. Контекст устанавливается для потока, код после await в общем случае будет выполнятся в другом потоке, не в том в котором контекст выставлялся.
Очень надеюсь, что этот приём поможет в разработке плагина. Частенько разработчикам приходится использовать сторонние SDK для работы с бэкэндами. Например, AWS SDK. вот в таких случаях этот приём будет работать. Он так же будет работать, если после обновления PoolFiber будет использовать собственный SynchronizationContext.
В заключении, хочу привести ссылку на шикарную статью Стефана Тоуба про контексты. Именно она и натолкнула меня на решение.