Часть 1. Идея
Несколько месяцев назад мне захотелось сдуть пыль со своего аккаунта в Steam и поиграть в старые игры про программирование. While True Learn в очередной раз показалась слишком скучной, я пару дней позалипал в TIS-100, реализуя свой многопоточный процессор, но в конце концов осознал, что интереснее не играть в игры про программирование, а самому писать такие игры.
Идея пришла в голову сразу. Моё хобби (если можно так сказать) — распределённые системы и проблемы консенсуса. Я с разной степенью регулярности почитываю книги и статьи на эту тему и никогда не пропускаю конференцию HydraConf. Вообще про проблематику распредёленных систем очень хорошо написал в своей статье мой бывший коллега Миша Кумачёв, и я очень рекомендую её почитать, если тема вас вдруг заинтересуют. А ещё в ней прекрасные картинки.
Назвал я свой проект Distributed game. Мне хотелось, чтобы, с одной стороны, он был про распределённые системы, а с другой — чтобы в нём была игровая составляющая. Например, написать систему, которая работает с одним узлом и одним клиентом, потом систему с несколькими узлами и клиентами, потом систему, устойчивую к отказу узлов и т.д.
На ум пришло одно из технических определений видеоигры: это набор состояний, которые переходят одно в другое с течением времени. Так уж оказалось, что это определение вполне подходит не только для описания видеоигры, но и для описания распределённой системы. Более того, этот подход активно используется как раз для проверки корректности алгоритмов распределённых систем. Один из инструментов, основанных на этом, называется TLA+, и он однозначно заслуживает, чтобы его изучить, если вы интересуетесь распределёнными системами.
Если объяснить его действие на пальцах, то TLA+ строит по заданным правилам все возможные состояния системы и переходы между состояниями в виде графа, находит в них нарушение инвариантов, дедлоки или бесконечные циклы, таким образом проверяя корректность заданных правил.
TLA+ очень мощный инструмент, но у него есть два минуса: для большого количества узлов он может очень долго строить граф, а язык спецификации не похож на остальные ЯП и имеет очень крутую кривую обучаемости (я, бывало, по полчаса сидел над одной страницей этой замечательной книжки).
У меня не было цели написать «свой TLA+», более того, я прекрасно понимаю, что до более-менее промышленного инструмента, который проверяет системы вероятностным способом, типа Maelstrom, мне тоже сильно далеко. Что я хотел: иметь возможность хотя бы примерно описывать и запускать некоторые алгоритмы (двухфазный коммит, read/write quorum, консенсус как программа максимум) на Python, визуализировать их, добавить чуть-чуть интерактива, чтобы можно было хотя бы примерно прикинуть проблемные места реализации алгоритмов распределённых систем.
Я решил сделать игрушечную систему, у которой все объекты находились бы в памяти и взаимодействовали друг с другом по мере продвижения прогресса, то есть в ней не было «реального» времени.
Часть 2. Реализация
Сперва я думал описать работу симулятора диаграммой, но решил, что моей статье не хватает немного кода. Да и основной код симулятора влез в пару десятков строчек:
class SimulatorLoop:
def add_object(self, obj):
self._objects.append(obj)
def loop(self):
while True:
self.process()
if self._sleep_interval > 0:
time.sleep(self._sleep_interval)
def process(self):
if self._timer:
self._timer.process()
for o in self._objects.copy():
o.process()
if _has_method(o, "destroyed") and o.destroyed():
self._objects.remove(o)
В этом коде не должно быть ничего нового для тех, кто хоть раз пробовал написать свою игру. Все объекты внутри симулятора имеют метод process()
, который вызывается в каждом шаге цикла loop()
. Также объекты можно добавлять в пул с помощью add_object()
, а некоторые из них удаляются, если имеют метод destroyed()
.
Все объекты реализуют два класса: WebServer
, который представляет собой узел нашей распределенной системы, и Signal
, который является средством взаимодействия узлов.
WebServer
— это класс, содержащий три основных метода:
send_message(receiver_id, message)
add_message(message)
process()
Метод send_message(receiver_id, message)
создаёт объект Signal
(1), в котором содержится сам «пакет сообщения» и ID сервера-получателя. Фактически Signal
через определённое количество шагов (process) симулятора вызывает коллбэк add_message(message)
(2) уже на стороне получателя.
Метод add_message(message)
с помощью типизации, декораторов и немного «питонячьей» магии отправляет сообщение в один из эндпоинтов
сервера (3). В общем, получился такой игрушечный веб-сервер, который обрабатывает приходящие сообщения в эндпоинтах.
Веб-серверы используют именно сигналы, а не вызывают методы друг друга напрямую, чтобы сымитировать асинхронную суть сетевого взаимодействия. У сигнала может быть произвольное время выполнения, более того, можно добавить возможность, чтобы сигнал «потерялся», имитируя сетевые разрывы.
Клиент, гейтвей, ноды — это всё наследники класса WebServer. Все они отличаются только эндпоинтами.
На картинке выше изображена диаграмма последовательностей той системы, которую я реализовывал. Клиент знает только о гейтвее. Гейтвей выступает в роли прокси между клиентом и узлами системы, соответственно, он знает обо всех веб-серверах. Гейтвей пересылает запросы клиента узлам по round-robin (первый запрос пришёл на первый узел, второй — на второй). Дальше узлы обмениваются между собой сообщениями и отправляют ответ обратно на гейтвей, а тот возвращает клиенту. Все стрелки на диаграмме — это на самом деле объекты сигналов.
Базовый сценарий, который я в итоге воплотил, это key-value хранилище с одним-единственным key, или, иначе говоря, регистр. У клиента есть два метода: записать в регистр и прочитать из регистра. Алгоритм взаимодействия узлов должен обеспечивать консистентность результатов для клиента.
А где же игровая составляющая? Суть в том, что алгоритм взаимодействия узлов должен реализовать игрок, наследуя класс Node
. Минимум нужно реализовать два эндпоинта:
@WebServer.endpoint(message_type=ClientReadRequest)
@abc.abstractmethod
def process_read_request(self, request):
pass
@WebServer.endpoint(message_type=ClientWriteRequest)
@abc.abstractmethod
def process_write_request(self, request):
pass
Эти эндпоинты обрабатывают входящие read- и write-запросы от клиента. Естественно, распределённая система не была бы распределённой, если бы узлам не пришлось общаться друг с другом, а для этого придётся создавать свои новые типы сообщений и эндпоинты.
Часть 3. Визуализация
Сперва я думал сделать интерфейс в стиле TIS-100, т.е. чисто консольный вывод, и не заморачиваться с какой бы то ни было визуализацией. Каждый шаг симулятора был отдельной строчкой в выводе консоли, а отображал я отправку клиентами запросов и получение ими ответов. Запустил симулятор и получил что-то похожее на картинку ниже (повернутую на 90°).
Строчки двигались, символы в консоли рисовались, и я был очень рад, что ничего не зависло и не задедлочилось. Но очень скоро стало понятно, что такого вывода недостаточно. Начал думать о том, как визуализировать отправку и получение сигналов, но ничего годного для консоли не придумал. Пришлось отложить проект на время.
Снова я вернулся к теме визуализации, когда наткнулся на описание инструмента, строящего диаграммы для таблиц в хранилище данных. Я зашел в GitHub проекта, увидел там ipynb-файлы и тут меня осенило! Есть же визуализация данных в Jupyter! А раз есть визуализация данных, возможно, есть и анимация.
Погуглив, я наткнулся на статью, автор которой заморочился ровно тем, что мне было нужно — созданием и запуском игр в Jupyter. Вооружившись опытом из статьи, вспомнив опыт написания игр на, прости господи, Delphi и используя исходную библиотеку ipycanvas, я собрал, наконец, вариант, из которого стало видно, что внутри симулятора что-то происходит.
Выглядело это примерно вот так:
Здесь всё, как на диаграмме последовательностей: есть клиенты, гейтвей, ноды (кружки красного цвета), между ними летают сигналы (кружки синего цвета)... Я даже нашёл баг в своей реализации, когда внутренний метод when_any(n) работал, как when_all().
Когда я показал анимацию с работой симулятора паре своих знакомых, они сказали, что это очень похоже на страницу с описанием работы алгоритма Raft. Я был весьма доволен, потому что, похоже, двигался в верном направлении. Я даже позаимствовал из схемы Raft'а центральный круг, который объединяет все узлы.
Супер-бонусом было то, что можно было в соседней ячейке написать код своего алгоритма для узлов, подсунуть его тут же в симулятор и наблюдать, как меняется поведение. Была всего пара минусов: не так-то просто прикрутить именно интерактивность, а ещё для запуска нужно было скачать код себе и запустить локально JupyterHub.
Однако из той же статьи я узнал про существование Binder, парой кликов добавил туда свой ноутбук и — вуаля! — прямо сейчас вы можете посмотреть, как это всё выглядит, перейдя по ссылке.
С Jupyter и Binder было всё классно. Они дали мне то, что нужно: основной код симулятора спрятан в модулях бэкенда; игрок может писать свой код в браузере, используя классы моей библиотеки; запустить и посмотреть, как всё работает. Очередной минус — на этот раз единственный, но существенный, — был в том, что в Binder'е предоставляются не очень сильные виртуалки и порой анимация серьёзно тормозит. Кстати, в случае статических вычислений это вообще не недостаток, поэтому рекомендую всем Binder для шаринга своих ноутбуков.
Прошла ещё пара недель. Мы отмечали окончание рабочей недели с коллегой, фронтенд-разработчиком. Я рассказал про проект и попросил поделиться идеями, как бы запустить его на клиентской машине без сервера, потому что я точно видел похожие симуляторы, работающее только в браузере. Коллега упомянул WebAssembly...
Я с фронтендом всегда был на «вы» и старался особо его не касаться, но тут понял, что иного выхода нет. А помните, как всё хорошо начиналось в консольке? Моя цель была найти что-то очень похожее на вариант с запуском игр в Jupyter, только на этот раз уже через WebAssembly в браузере. Сначала попалась эта статья и я понял, что смогу повторить супер-бонус из варианта с Jupyter: можно будет написать код узла прямо в браузере и тут же его использовать без компиляции в WebAssembly. Потом я поискал ещё и наткнулся на пост на Reddit, где пользователь сделал ровно то, что мне было нужно — игру Pong на Python в WebAssembly с помощью PyScript.
Эта библиотечка, скорее всего, была собрана чисто для демонстрационных целей, потому что умела рисовать только прямоугольники. Мне пришлось прикрутить свои круги (простите за каламбур), потом долго и с костылями скрещивать PyScript с CodeMirror...
В результате получилась страница с честным WebAssembly без сервера, которая не требует никаких предварительных установок, но где можно писать код и выполнять на Python. Запустить можно по ссылке.
В коде узла, который сейчас появляется по умолчанию при открытии страницы, кстати, есть баг, который стопорит систему, если количество узлов = 2. Попробуйте его найти. :)
И тут я сломался.
Часть 4. Призыв о помощи
Зачем я написал эту статью? Честно говоря, я немного задолбался. Работа над стройным, понятным, хоть и слегка наивным симулятором распределённой системы практически прекратилась на фоне моих приключений с Jupyter, ipycanvas, WebAssembly и запуском в браузере.
В той версии, что открывается сейчас, много чего нет:
нельзя добавлять и удалять узлы;
нет никакой проверки консистентности ответов, получаемых клиентами;
я ещё даже не приступал к системе, которая представляла бы собой распределённый упорядоченный лог (для реализации собственного Raft, например);
нельзя управлять средой, например, процентом отказов узлов, потерянных сигналов, средним временем сигнала, чтобы можно было сравнивать два алгоритма друг с другом;
WebAssembly реализация выглядит «вырвиглазно», как, собственно, и вариант с Jupyter.
Вот я и подумал, что если своих сил не хватает, а продолжать делать хочется, то стоит обратиться к коммьюнити. С одной стороны, понимаю, что гораздо проще заинтересовать кого-то помогать проекту, когда проект уже что-то из себя представляет. С другой стороны, больше всего помощь нужна именно тогда, когда проект находится в стадии, когда ещё не начал что-то из себя представлять.
Эта статья — попытка заинтересовать. Вы, может быть, не захотите принимать участие в разработке, но можете покритиковать. Или похвалить. Или указать мне на косяки и костыли, которые я допустил, чтобы я смог их поправить. Мне кажется, почти любая обратная связь сможет дать мне дополнительный стимул, чтобы продолжить развивать проект.
Спасибо, что дочитали до конца, жду ваших комментариев! А вот и сам репозиторий с симулятором: https://github.com/xneg/distributed-game.
В статье тут и там разбросано много ссылок. Мне кажется, они сами по себе очень полезны, поэтому вынесу их отдельным списком:
Статья-введение про проблемы распределённых систем от @Ceridan
Отличная (но сложная) книжка по TLA+
Статья по созданию игр внутри Jupyter с помощью ipycanvas
Статья, в которой описывается, как написать Python REPL в браузере
Пост на Reddit, который стал стартом для использования PyScript
А вот и сам проект pywebcanvas (почему-то в Gitlab)