Главная » Игры » Под капотом Screeps — виртуализация в MMO-песочнице для программистов

Под капотом Screeps — виртуализация в MMO-песочнице для программистов

Чтобы долго не тянуть резину, сразу спойлер: кажется, что такого шаманства в нативном коде Node.js, к которому мы пришли после нескольких лет разработки, до нас еще никто не делал. В этой статье я расскажу про одну малоизвестную технологию, которая нашла ключевое применение в нашей онлайн-игре для программистов. А возможности изоляции, которые он дает, не имеют аналогов и заслуживают, чтобы о них рассказали. Движок изолированных виртуальных машин (опенсорсный), который работает под капотом проекта, был написан специально для его нужд, и на данный момент используется в продакшене нами и еще одним стартапом.

Но давайте обо всем по порядку.

Предыстория

Не тот рутинный энтерпрайз-кодинг, которым многие из нас вынуждены заниматься 40 часов в неделю, борясь с прокрастинацией, вливая в себя литры кофе и профессионально выгорая; а программирование — ни с чем не сравнимый волшебный процесс трансформирования мыслей в работающую программу, получение наслаждения от того, что только что написанный тобой код воплощается на экране и начинает жить той жизнью, которой велит ему создатель. Вы любите программирование? В такие моменты слово "Создатель" хочется написать с большой буквы — настолько чувство, возникающее в процессе, иногда бывает близко к благоговению.

Чаще всего для того, чтобы не терять страсть к программированию, энтузиастам приходится затевать интрижку на стороне: программистское хобби, пет-проект, модный open-source, просто скрипт на питоне по автоматизации своего умного дома… или поведения персонажа в какой-нибудь популярной онлайн-игре. Жаль лишь, что совсем немного реальных проектов, связанных с каждодневным заработком, могут предложить своим разработчикам такие чувства.

Уже самые первые игры в этом жанре (Ultima Online, Everquest, не говоря уже о всяческих MUD-ах) привлекли немало умельцев, заинтересованных не столько в том, чтобы отыгрывать роль и наслаждаться фентезийностью мира, сколько применением своих талантов для автоматизации всего и вся в виртуальном игровом пространстве. Да, именно онлайн-игры часто дают неисчерпаемый источник вдохновения для программистов. Или другими ботами — как, например, в EVE Online, где торговля на густонаселенных рынках чуть менее, чем полностью контролируется торговыми скриптами, прямо как на настоящих биржах. И по сей день это остается особой дисциплиной онлайн-олимпиады ММО-игр: изощриться так написать своего бота, чтобы остаться незамеченным администрацией и получить максимальный профит по сравнению с другими игроками.

Такой игры, в которой написание бота — это не наказуемое деяние, а суть геймплея. В воздухе витала идея онлайн-игры, изначально и полностью ориентированной на программистов. И так как подразумевается онлайн-игра в жанре ММО — то соперничество происходит со скриптами других игроков в реальном времени в едином общем игровом мире. Где задачей было бы не выполнение из раза в раз одинаковых действий "Убей Х монстров и найди Y предметов", а написание скрипта, способного грамотно выполнять эти действия от вашего имени.

Все механики обыкновенной стратегической игры — добыча ресурсов, создание юнитов, строительство базы, захват территорий, производство и торговля — требуется программировать самому игроку через JavaScript API, который предоставляется миром игры. Так в 2014 году появилась игра Screeps (от слов "Scripts" и "creeps") — стратегическая ММО-песочница реального времени с единым большим persistent world, в котором игроки не имеют никакого влияния на происходящее кроме как через написание скриптов AI для своих игровых юнитов. Отличие от разных соревнований по написанию AI в том, что мир игры, как и полагается онлайновому игровому миру, постоянно работает и живет своей жизнью в реальном времени 24/7 на протяжении последних 4 лет, запуская AI каждого игрока каждый игровой такт.

Больше представления можно получить из этого видео, но это уже по желанию: Итак, хватит о самой игре — этого должно быть вполне достаточно, чтобы дальше можно было понять суть технических проблем, с которыми мы столкнулись при разработке.

Видео трейлер

Технические проблемы

Одна комната является атомарной единицей процесса обработки состояния игрового мира. Суть механики игрового мира заключается в следующем: весь мир разбит на комнаты, которые связаны между собой выходами по четырем сторонам света. Серверный обработчик берет по одной комнате за раз, выполняет эти команды, меняя состояние объектов, и коммитит новое состояние комнаты в базу. В комнате могут быть некие объекты (например, юниты), у которых есть свое состояние, и на каждом игровом такте они получают команды от игроков. Эта система хорошо горизонтально масштабируется: можно добавлять больше обработчиков в кластер, и так как комнаты архитектурно изолированы друг от друга, то параллельно может обрабатываться столько комнат, сколько запущено обработчиков.

Серверный кластер из 36 четырехъядерных физических машин содержит 144 обработчика. На данный момент у нас в игре 42 060 комнат. Для формирования очередей мы используем Redis, весь бекенд написан на Node.js.

Но откуда берутся команды игроков? Это был один этап работы игрового такта. Максимум, что можно сделать в интерфейсе — поставить нематериальный флаг в нужном месте комнаты. Специфика игры в том, что нет никакого интерфейса, где можно было бы кликнуть на юнита и сказать ему отправиться в определенную точку или построить определенное сооружение. Чтобы юнит пришел в это место и сделал необходимое действие, нужно, чтобы ваш скрипт на протяжении нескольких игровых тактов выполнял примерно следующее:

module.exports.loop = function()
}

Кажется, все довольно просто. Получается, на каждом игровом такте нужно взять функцию loop игрока, выполнить её в полноценном JavaScript-окружении этого конкретного игрока (в котором существует сформированный для него объект Game), получить набор приказов для юнитов, и отдать их на следующий этап процессинга.

На данный момент у нас 1600 активных игроков в мире. Проблемы начинаются, когда дело доходит до нюансов реализации. Так как на каждом такте ресурсы CPU и памяти игрока ограничены, то такая модель хорошо работает. Скрипты отдельных игроков уже язык не поворачивается назвать "скриптами" — некоторые из них содержат до 25к строк кода, компилируются из TypeScript или даже из C/C++/Rust через WebAssembly (да, мы поддерживаем wasm!), и реализуют концепцию настоящих миниатюрных ОС, в которых игроки разработали собственный пул игровых задач-процессов и их менеджмент через ядро, которое берет столько задач, сколько получается выполнить на данном игровом такте, выполняет их, а невыполненные откладывает в очередь до следующего такта. Хотя и не является обязательной — для начала игры новичку достаточно взять скрипт из 15 строк, который к тому же уже написан в рамках туториала.

И что игра работает в реальном времени — то есть JavaScript-машина каждого игрока должна постоянно существовать, работая с неким заданным темпом, чтобы не замедлять игру в целом. Но теперь давайте вспомним, что скрипт игрока должен работать в настоящей машине JavaScript. Но в отличие от этапа процессинга комнат, здесь уже кроется немало трудностей. Этап выполнения игровых скриптов и формирования приказов юнитам работает примерно по такому же принципу, как обработка комнат — каждый скрипт игрока является задачей, которую берет на себя один обработчик из пула, в кластере работает множество параллельных обработчиков.

JavaScript-машина игрока должна работать без останова, каждый следующий такт — это просто новый вызов функции loop, но глобальный контекст должен продолжать существовать тот же. Во-первых, нельзя просто распределять задачи по обработчикам случайным образом на каждом такте так, как это возможно делать в случае комнат. Грубо говоря, в игре разрешается делать примерно так:

let counter = 0;
let song = ['EX-', 'TER-', 'MI-', 'NATE!']; module.exports.loop = function () { Game.creeps['DalekSinger'].say(song[counter]); counter++; if(counter == song.length) { counter = 0; }
}

Номер строчки песни counter хранится в глобальном контексте, который сохраняется между тактами. Такой крип будет петь по одной строчке песни каждый игровой такт. Значит, все игроки должны быть распределены по конкретным обработчикам, и менять их должны как можно реже. Если каждый раз выполнять скрипт этого игрока в новом процессе обработчика, то контекст будет теряться. Один игрок может затратить 500мс выполнения на этой ноде, а другой игрок — 10мс, и очень трудно спрогнозировать это заранее. Но как тогда быть с балансировкой нагрузки? А чтобы перебалансировать этих игроков и перекинуть на другие ноды, приходится терять их контекст. Если на одну ноду вдруг попадут 20 игроков по 500мс, то работа такой нода займет 10 секунд, в течение которых все остальные будут ждать её завершения и простаивать.

И это касается не только безопасности, но и комфорта для самих пользователей. Во-вторых, окружение игрока должно быть хорошо изолировано от других игроков и от серверного окружения. Так как ресурсом CPU в игре является время выполнения скрипта (он подсчитывается с момента старта и до конца метода loop), то трата ресурсов на посторонние задачи во время выполнения моего скрипта могут быть очень чувствительными, ведь расходуются из моего бюджета ресурсов CPU. Если соседний игрок, выполняющийся на той же ноде в кластере, что и я, творит что попало, генерирует много мусора, и вообще ведет себя неподобающе, то я не должен это чувствовать.

В попытках справиться с этими проблемами мы пришли к нескольким решениям.

Первая версия

Первая версия движка игры была основана на двух базовых вещах:

  • штатный модуль vm в поставке Node.js,
  • форки рантайм-процессов.

На каждой машине в кластере существовало 4 (по числу ядер) процесса обработчиков игровых скриптов. Выглядело это примерно следующим образом. Дочерний процесс, будучи изолированным от родительского (в котором содержалась бизнес-логика кластера), умел только одно: создать объект Game из полученных данных и запустить виртуальную машину игрока. При получении новой задачи из очереди игровых скриптов, обработчик запрашивал нужные данные из базы и передавал их в дочерний процесс, который поддерживался в постоянно запущенном состоянии, перезапускался в случае сбоя и переиспользовался разными игроками. Для запуска использовался модуль vm в Node.js.

Строго говоря, здесь не решались вышеописанные две проблемы. Почему это решение было неидеальным?

Поэтому чтобы иметь на 4-ядерной машине четыре параллельных обработчика на каждом ядре, нужно иметь 4 процесса. vm работает в таком же однопоточном режиме, что и сам Node.js. Перемещение "живущего" в одном процессе игрока на другой процесс приводит к полному пересозданию глобального контекста, даже если это происходит в рамках одной и той же машины.

Что оно делает, так это лишь создает изолированный контекст, или область видимости, но выполняет код в том же экземпляре виртуальной машине JavaScript, откуда происходит вызов vm.runInContext. Кроме того, vm на самом деле не создает полностью изолированную виртуальную машину. Хоть игроки и разделены по изолированным глобальным контекстам, но, будучи частью одной и той же виртуальной машины, имеют общую heap-память, общий garbage collector и генерируют мусор совместно. А значит — в том же экземпляре, в каком запускаются и другие игроки. Не говоря уже о том, что все контексты работают в одном и том же event loop, и теоретически возможно выполнение чужого промиса в любой момент, хотя мы и пытались это предотвращать. Если игрок "А" сгенерировал много мусора за время выполнения своего игрового скрипта, закончил работу, и управление перешло к игроку "Б", то в этот момент вполне может вызваться сбор всего мусора процесса, и игрок "Б" заплатит своим временем CPU за сбор чужого мусора. Также vm не позволяет контролировать, сколько heap-памяти выделяется под выполнение скрипта, доступна вся память процесса.

isolated-vm

Для одних он в свое время стал замечателен тем, что написал библиотеку node-fibers, для других — тем, что взломал Facebook и был нанят там работать. Живет на свете такой замечательный человек по имени Марсель Лаверде. А для нас он замечателен потому, что щедро участвовал в нашей самой первой краудфандинговой кампании и по сей день является большим фанатом Screeps.

Хотя официальный клиент и продается платно через Steam, но существуют альтернативные его версии, а сам сервер доступен для изучения и модификации в любых масштабах, что мы всячески поощряем. Наш проект уже несколько лет как вышел в опенсорс — сервер игры опубликован на GitHub.

И вот как-то раз Марсель пишет нам: «Ребята, у меня хороший опыт в нативной разработке C/C++ под Node.js, и мне нравится ваша игра, но не во всем нравится как она работает — давайте мы с вами напишем совершенно новую технологию запуска виртуальных машин под Node.js специально для Screeps?».

Через несколько месяцев нашего сотрудничества на свет появилась библиотека isolated-vm. Так как денег Марсель не просил, мы не смогли отказаться. И это поменяло абсолютно все.

Не вдаваясь в детали, это означает, что создается полноценный отдельный экземпляр JavaScript-машины, который обладает не только собственным глобальным контекстом, но и собственной heap-памятью, сборщиком мусора и работает в рамках отдельного event loop. isolated-vm отличается от vm тем, что изолирует не контекст, а isolate в терминах V8. На этом минусы заканчивается, в остальном — это просто панацея! Из минусов: на каждую запущенную машину требуется небольшой оверхед RAM (порядка 20 Мб), а также внутрь машины невозможно передавать объекты или вызывать функции напрямую, весь обмен надо сериализовать.

У игрока есть свои 500 Мб хипа, если он закончился — то это значит, что закончился именно твой собственный хип, а не хип общего процесса. Теперь стало действительно возможным запускать скрипт каждого игрока в своем собственном полностью изолированном пространстве. Повисшие промисы выполнятся только тогда, когда твоему изоляту перейдет управление в следующий раз, и не ранее. Если сгенерировал мусор — то это твой собственный мусор, тебе его и собирать. Ну и секьюрность — ни при каких обстоятельствах невозможно получить доступ куда-то вовне изолята, только если найти где-то уязвимость на уровне V8.

Еще один плюс isolated-vm в том, что он запускает машины из этого же процесса, но в отдельных тредах (здесь пригодился опыт работы Марселя над node-fibers). Но что насчет балансировки? При этом находясь в рамках одного и того же процесса, а значит, имея общую память, мы можем перекидывать любого игрока из одного треда в другой внутри этого пула. Если у нас 4-ядерная машина, мы можем создать пул из 4 тредов, и запускать в один момент времени 4 параллельных машины. Хоть каждый игрок и остается привязанным к одному конкретному процессу на одной конкретной машине (чтобы не терять глобальный контекст), но балансировки между 4 тредами оказывается достаточно, чтобы решить проблемы распределения "тяжелых" и "легких" игроков между нодами так, чтобы все обработчики заканчивали работу одновременно и вовремя.

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

Но об этом в другой раз. Конечно, есть еще пространство для оптимизации и дальше, а также есть и другие интересные области проекта, в которых мы решали разные технические проблемы.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

«Матрице» 20 лет: как Вачовски сделали киберпанк, определивший повестку для целого поколения

Офигеть, не правда ли? На днях фильму «Матрица» исполнилось 20 лет. По этому случаю WIRED сделал лонгрид на основе книги о сестрах (тогда еще братьях) Вачовски, из которого можно узнать много нового о карьере режиссеров-сценаристов до и во время создания ...

[Из песочницы] Коренные микробы

С 2007 года моя судьба связана с микробами: вот уже 13 лет они меня не отпускают. По моим прикидкам, это гораздо больше, чем 10 000 часов — что-то около 30 000. Как и с любой областью знаний, в определенный момент ...