Перейти к содержимому

Zero-Tick Engine

Каждое действие игрока, каждый удар и каждый бафф обрабатываются в момент, когда они происходят: без тиков, без гонок, без холостой работы.

Активность во времени: классическая сборка работает каждые 100 мс, даже когда ничего не происходит, а Zero-Tick молчит и работает только тогда, когда проходит удар, кончается бафф или входит игрок

Состояние: ядро движка уже в nx-l2, и мир переезжает на него шаг за шагом. См. путь миграции.

Таймеры, баффы и откаты обходятся в разы дешевле, чем в классических сборках.

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

Продажа предмета NPC и передача его в трейде в один и тот же момент: в классической сборке одна пропущенная блокировка пропускает обе операции и дюпает предмет, в потоке партиции Zero-Tick продажа идёт первой, а трейд уже не находит предмета

Удар и Sleep, прилетевшие в один момент, всегда разрешаются одинаково — в порядке прихода. Атака будит спящую цель, поэтому порядок решает исход боя. Забыть блокировку больше негде: сущности мира избавились от 119 блоков synchronized, 155 concurrent-коллекций и 43 atomics ради одного потока-владельца и проверки.

Sleep мага и удар воина по одному мобу: в классической сборке два потока оставляют результат на волю случая, в потоке партиции Zero-Tick они выполняются в порядке прихода, и результат каждый раз один и тот же

Ничто не ждёт «следующего тика»: действие обрабатывается сразу.

Каждое пробуждение — настоящее событие: ни пустых тиков, как в тиковых сборках, ни задач, крутящихся на пустых пулах, как в сборках на пулах потоков.

Где тормозит, в какой фазе, в каком pass: метрики Prometheus из коробки, готовые для вашей Grafana.

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

Возможность Zero-Tick L2J Mobius aCis L2J Server Линия L2-Scripts L2E (Elixir)
Просыпается только на события, без опроса ✓ ✗ ✗ ✗ ✗ ✗
Без блокировок на игровом состоянии, single writer ✓ ✗ ✗ ✗ ✗ ✓
Дюпы исключены самим устройством ✓ ✗ ✗ ✗ ✗ —
Владение потоком проверяется в рантайме ✓ ✗ ✗ ✗ ✗ ✓
Детерминированные pass (RNG с seed) ✓ ✗ ✗ ✗ ✗ ✗
Встроенные метрики Prometheus ✓ ✗ ✗ ✗ ✗ ✗
Бюджет pass с разбивкой по фазам ✓ ✗ ✗ ✗ ✗ ✗

По открытому исходному коду каждого семейства; ✓ — есть, ✗ — нет, — — не установлено. Факты за каждой клеткой — в подробном сравнении ниже, в разделе «Под капотом».

Тик в 100 мс просыпается даже вхолостую и заставляет действие ждать до 100 мс; Zero-Tick просыпается только на настоящие события и обрабатывает действие, как только оно пришло

Классический сервер просыпается по фиксированному тику, например раз в 100 мс, и обрабатывает всё, что накопилось с прошлого раза. Выглядит как «всё одновременно», но это не так. Действие, пришедшее сразу после тика, ждёт следующего до 100 мс. Внутри тика события всё равно выполняются одно за другим, в том порядке, в каком лежат в списке. Тик чаще, хоть 100 раз в секунду, лишь сокращает ожидание; два события от этого одновременными не становятся. Zero-Tick убирает ожидание и закрепляет порядок: каждое событие обрабатывается, когда пришло, и порядок одинаков при каждом запуске.

Тик заставляет мир ждать. Zero-Tick — нет.


Пространство мира (партиция) — это event loop в собственном потоке. Его никто не опрашивает:

  1. I/O-поток Netty (epoll в Linux) декодирует пакет клиента и кладёт его в очередь сессии.
  2. Поднимается флаг готовности сессии, а в inbox партиции приходит «у этой сессии есть ввод»; если поток цикла запаркован, его будит LockSupport.unpark.
  3. Когда ввода нет, цикл паркуется в LockSupport.parkNanos до ближайшего дедлайна: следующего таймера или следующего запуска периодической системы. Если ничего не запланировано, он так и остаётся запаркованным.

Проснувшись, цикл один раз читает часы и выполняет один pass из шести фаз в фиксированном порядке:

flowchart TB
  I[Inbox<br/>ввод и сообщения] --> D[Дедлайны<br/>наступившие таймеры]
  D --> S[Системы<br/>периодические системы]
  S --> E[Побочные эффекты<br/>смерти, дроп, статы]
  E --> R[Репликация<br/>что видит каждый клиент]
  R --> F[Flush<br/>одна запись на соединение]
  • Одно чтение часов на pass. Каждая механика в pass видит одно и то же «сейчас», и именно поэтому pass воспроизводим.
  • Детерминированный порядок. Наступившие таймеры выполняются в порядке (deadline, sequence); при равных дедлайнах sequence сохраняет порядок регистрации.
  • Всплески делят один pass. Между pass есть минимальный интервал (pass-min, см. эксплуатацию), поэтому пачка ввода, пришедшая внутри него, обрабатывается одним pass, а не многими.
  • Последствия в том же pass. Работа, порождённая внутри pass (сработавший триггер-скилл, смерть от тика урона), выполняется в том же pass с ограничением глубины, поэтому цепочка триггеров не может зациклиться.
  • Побочные эффекты в фиксированном порядке. Смерти, дроп и награды откладываются в фазу побочных эффектов и выполняются там первым пришёл, первым вышел; работа, которую они откладывают, идёт после них в той же фазе. Смерть монстра делает снимок того, кто его бил и насколько сильно, поэтому следующие за ней дроп, event drop и опыт определяются этим боем, а не тем, что изменили последствия смерти.

Тик или Zero-Tick — это модель планирования: она решает, когда выполняется работа. ECS (entity-component-system) — модель данных и кода: она решает, как разложены состояние и логика. Одно от другого не зависит. На практике ECS-движки — Bevy, Unity DOTS, Flecs — почти всегда работают на тике: каждый кадр расписание обходит все сущности, у которых есть компоненты, нужные системе.

Классический L2J (тики + пулы) ECS (в духе Bevy) Zero-Tick
Когда идёт работа задачи с фиксированной частотой и тики каждый кадр на ввод, наступивший дедлайн или запуск периодической системы
Работа на пробуждение все наступившие задачи, даже пустые все сущности с компонентом, O(сущностей) на тик только то, что затронуто, O(событий)
Потоки общий пул плюс synchronized: гонки и дюпы планировщик распараллеливает системы по доступу к компонентам, без блокировок один writer-поток на партицию, партиции параллельно
Порядок и детерминизм на усмотрение планировщика ОС детерминирован при фиксированном шаге одно «сейчас» на pass, порядок (deadline, sequence)
Сильная сторона просто и привычно плотная работа каждый кадр, cache-friendly обход разреженная работа: большинство объектов простаивает, редкие точные события — баффы, откаты, респауны, удары
Слабая сторона гонки, холостые задачи, ничего не видно пустые тики, задержка до шага, переписывание всех скриптов плотную покадровую работу приходится выносить в периодические системы

Почему Lineage 2 подходит Zero-Tick. Мир MMO разрежен: тысячи NPC стоят без дела, а у каждого игрока десятки баффов и откатов, и каждый кончается в свою миллисекунду. Тик ECS проверяет их все каждый кадр; Zero-Tick просыпается ровно тогда, когда наступает один из них.

Гибрид, а не противник ECS.

  • Разреженное — на событиях: эффекты, откаты, респауны и ввод работают через timer queue и inbox.
  • Плотное — периодические системы в стиле ECS: AI think, движение и (в планах) видимость обходят свои компоненты со своим интервалом, внутри того же pass и в том же writer-потоке.
  • Данные — в стиле ECS: состояние лежит в компонентах, которыми владеют системы, а data-oriented хранение появляется там, куда укажет профилировщик.
  • Почему не чистый ECS: нет открытого примера классического ядра MMO-сервера на чистом ECS, около 310 тысяч строк скриптов написаны против объектных фасадов, а измеренный выигрыш требует data-oriented хранения лишь на нескольких hot paths. См. архитектуру.

В ECS и тиковых движках работа — это норма. В Zero-Tick это исключение, которое запускает событие.

Открытый мир как одна партиция, каждый инстанс и каждый матч олимпиады как своя партиция, а глобальные сервисы — кланы, аукцион, замки — как single writers, обменивающиеся с ними сообщениями

  • Открытый мир — одна партиция. Он не режется на регионы: осады нагружают ровно те границы, которые создало бы деление, а разделение бесшовного мира между несколькими writers — подход, который на практике провалился.
  • Каждый инстанс и каждый матч олимпиады — своя партиция. В игре это и так отдельные пространства.
  • Глобальные сервисы (кланы, аукцион, замки и расписание осад) — тоже single writers, у каждого свой inbox. Партиции шлют им команды и читают их состояние из публикуемых снимков.

Массовые битвы в мире не тормозят олимпиаду. У каждой партиции свой поток цикла, а глобального барьера между партициями нет, поэтому осада, заполнившая pass мира, не задерживает матч. Условие одно — достаточно ядер процессора.

Таймеры живут в двух очередях на партицию (игровое и wall-clock время): двоичная куча JDK, упорядоченная по deadline, затем по порядку регистрации. Голова кучи — ближайший deadline, поэтому простаивающая партиция с тысячами таймеров на минуты вперёд (респауны, деспауны, долгие баффы) не выполняет ни одного pass, пока не наступит первый из них.

Отмена таймера ставит на нём флаг; помеченная запись пропускается, когда доходит до головы, а когда помеченных становится половина кучи, куча перестраивается без них. Таймеры сущностей ещё и устаревают по generation, поэтому cleanse или смерть ничего не стоят на каждый таймер. Ни то ни другое не расходует лимит overdue (см. ниже).

Повторяющийся таймер сохраняет фазу и никогда не нагоняет: после опоздавшего pass он срабатывает один раз, а следующий deadline — ближайшая точка его сетки периода. Overdue-таймеры (их deadline старше pass-min до pass) выполняются в количестве до overdue-per-pass на очередь и pass; остальные сохраняют место до следующего pass. Таймер, поставленный во время срабатывания таймеров, никогда не срабатывает в том же pass: его deadline сдвигается минимум на 1 мс после pass, за всё уже наступившее, поэтому обработчик, перепланирующий себя, не может ни зациклить один pass, ни заморозить таймеры позади себя. Если wall clock переведены назад, ни один deadline не срабатывает раньше времени.

Очередь заменила hashed timer wheel (DeadlineTimerWheel из Agrona с bucket на каждый оборот). Wheel не упорядочивал таймеры внутри тика и не знал своего ближайшего deadline, поэтому тащил bucket, bitmap и сортировку на каждый тик; теперь рандомизированный дифференциальный тест при каждой сборке гоняет очередь против эталонной модели.

  • Один сигнал готовности на сессию. Producer кладёт элемент в очередь, ставит full fence (VarHandle.fullFence()) и шлёт «есть ввод», только если флаг сессии был снят. Партиция снимает флаг, когда очередь пуста, ставит full fence и проверяет очередь ещё раз. С fence с обеих сторон элемент, добавленный в момент снятия флага, никогда не остаётся в очереди без сигнала.
  • Строгий порядок прихода для каждого клиента, с обходом сессий round-robin и лимитом на pass, поэтому флудящий клиент задерживает только себя.
  • Сбойный элемент стоит только себя. Элемент снимается с очереди и только потом выполняется; обработчик, бросивший исключение, попадает в отчёт, а остальной pass продолжается.

Каждый тип, который держит состояние мира, принадлежит одному потоку — loop thread своей партиции. Такой тип помечен @SingleWriter, и каждый его public-метод начинается с проверки владельца: вызов из любого другого потока бросает исключение, считается в метрике и пишет место вызова в лог раз в минуту. Правила сборки держат это в силе: ArchUnit требует проверку первой строкой в каждом public-методе помеченного типа и запрещает в нём concurrent-коллекции, блокировки, atomics, volatile и public изменяемые поля; PMD запрещает synchronized, в движке и в скриптах. Подкласс помеченного типа (скрипт, расширяющий монстра) тоже помечен, а тест фиксирует короткий список исключений (статические данные вроде шаблонов), который только сокращается.

  • Что покрыто. Открытый мир и его регионы, каждый объект мира и creature вплоть до монстров, питомцев, дверей и служебных NPC, эффекты и reuse, предметы и их контейнеры, игрок с его holders, статами и переменными, party, command channel и поиск группы, кланы, зоны, спауны, инстансы, резиденции и их функции, аукционы clan hall и предметов: 252 типа. Статические данные (шаблоны, геометрия зон) строятся один раз и только читаются, а простые координаты — value types, которые отдаются копиями.
  • Что убрано. В этих классах: 119 synchronized-методов и блоков, 155 concurrent-коллекций, 43 atomics, 28 volatile-полей и 6 явных блокировок — ни одной не осталось. На их месте обычные коллекции fastutil и JDK с фиксированным порядком обхода, поэтому одни и те же входные данные выбирают одни и те же цели, владельцев и дроп.
  • Обход. Тип отдаёт read-only snapshots, а не живую коллекцию. Обход, который вызывает что-то снаружи (пакеты, AI, эффекты, callback вызывающего), идёт по копии в переиспользуемых scratch-буферах на каждый уровень вложенности, поэтому в установившемся режиме ничего не аллоцирует; горячие вызывающие получают lookups и счётчики вместо копий.
  • Что нашлось. Перевод каждого класса на один поток означал прочитать каждый путь, который его трогал. Миграция исправила, среди прочего: любой игрок мог править или закрыть чужую комнату поиска группы; command channel оставался под лидером партии, который уже ушёл; пассивный скилл, заменённый новым уровнем, оставлял старые тики; эффект слотов бафов увеличивал лимит бафов при каждом применении; функции clan hall, замков и фортов продолжали снимать плату после удаления; настройки зон не восстанавливались после reload; пакет списка войн клана мог объявить больше записей, чем отправлял.
Путь (JMH, нс на операцию) До Single writer
Расчёт стата creature 19 15
Поиск предмета по object id в контейнере 224 2.8
Перепроверка зон при движении (вход и выход) 340 186
Обход 100 участников клана 315 258
Число онлайн в клане из 100 участников 317 190
Смена региона, 500 объектов на регион 23 000 54 000

Медианы трёх чередующихся раундов JMH против сборки до каждого шага, на AMD Ryzen 5 5600X, JDK 25, Windows 11. Большинство путей стали быстрее, потому что обычная коллекция дешевле concurrent. Сама проверка стоит от 1 до 5 нс на вызов: около 1 нс отдельно, около 5 нс внутри смены региона; там, где путь делает тысячи проверок и почти ничего больше, как смена региона, это заметно (#146).

Замерено на AMD Ryzen 5 5600X, JDK 25, Linux в Docker на WSL2, то есть в виртуальной машине; абсолютные числа на железе без виртуализации будут другими, важны соотношения.

Цена одного таймера — постановка и срабатывание пачкой за один pass, нс на таймер, при 1 000 / 100 000 / 1 000 000 других ожидающих таймеров. Очередь и заменённый ею wheel замерены вперемешку, раунд за раундом, в одном сеансе на одной машине; здесь первый раунд, который шёл на спокойной машине:

Планировщик 1 000 100 000 1 000 000
Timer queue Zero-Tick 85 134 169
Timer queue Zero-Tick, каждый второй отменён 89 142 174
Timer wheel, который она заменила 581 387 361

В пачке размером с pass куча не медленнее wheel ни при каком размере. У значения wheel при 1 000 ожидающих погрешность около ±1 050 нс; второй раунд wheel дал 374 / 351 / 659 нс. Второй раунд очереди шёл под нагрузкой на CPU от других процессов и несопоставим. Слабое место кучи — постановка и срабатывание таймеров по одному: 73 / 262 / 3 143 нс против 78 / 224 / 1 143 нс у wheel, потому что обход миллиона записей промахивается мимо кэша. Отмена половины пачки заметной цены не добавляет.

Пустой мир: игроков нет, у каждого NPC таймер вроде респауна, который срабатывает примерно раз в минуту (30–90 с).

Планировщик Пробуждений/с, 1 000 NPC Пробуждений/с, 10 000 NPC CPU мс/с, 1 000 NPC CPU мс/с, 10 000 NPC
Zero-Tick 16 150 1,0 4,1
L2J Mobius 28 275 4,7 16,9
Тик 100 мс 10 10 0,38 0,55

Zero-Tick просыпается один раз на каждый наступивший таймер. L2J Mobius выполняет те же таймеры и каждую секунду добавляет 11 задач AI и движения с фиксированной частотой на 1 000 NPC, которые срабатывают на пустых пулах; он уже усыпляет AI моба, рядом с которым нет игроков, поэтому это число небольшое. Тик просыпается 10 раз в секунду в любом случае и в простое дешевле всех; платит он задержкой.

Опоздание срабатывания таймеров с разбросанными дедлайнами и CPU на один сработавший таймер:

Планировщик p50 p99 CPU на таймер
Zero-Tick 0,9 мс 1,5 мс 2,2 мкс
Пул L2J Mobius 0,09 мс 0,15 мс 23,7 мкс
Тик 100 мс 50 мс 99 мс 0,19 мкс

Zero-Tick опаздывает на 0,9 мс по p50 против 50 мс на тике в 100 мс. Пул Mobius срабатывает ближе, примерно через 0,1 мс, но тратит примерно в одиннадцать раз больше CPU на таймер; для игры миллисекунда ниже порога восприятия.

Таблицы пустого мира и опоздания замерены на прежней сборке с timer wheel. Повторный замер на timer queue дал то же число пробуждений в секунду (16 и 148) и опоздание около 1,1 мс на p50 и 1,7 мс на p99.

Как запустить:

Окно терминала
./gradlew :engine:core:jmhJar
java -jar engine/core/build/libs/engine-core-jmh.jar TimerComparisonBenchmark
java -jar engine/core/build/libs/engine-core-jmh.jar IdleWakeupBenchmark
java -cp engine/core/build/libs/engine-core-jmh.jar app.l2nx.engine.core.loop.LatenessHarness

LatenessHarness — обычный main-класс, не JMH: он выдаёт распределение величины, измеренной внутри запуска. Запускайте по одному планировщику на JVM с scheduler=ZERO_TICK, MOBIUS_STPE или TICK_100MS.

Семейство Модель выполнения Периоды опроса Блокировки Метрики Детерминизм
Zero-Tick один event loop на партицию, timer queue, single writer нет: просыпается на ввод, дедлайн или запуск системы нет на игровом состоянии Micrometer по pass и фазам, Prometheus seed RNG, часы на pass
L2J Mobius общий ScheduledThreadPoolExecutor (4 потока на CPU) плюс instant-пул, менеджеры задач с фиксированной частотой AI 1000 мс, движение 100 мс (существа) и 50 мс (игроки), игровое время 10 тиков/с, decay, респаун и автосохранение 1000 мс около 220 мест с synchronized, переключение активности регионов нет; наблюдатель за дедлоками ThreadLocalRandom, без seed, без replay
aCis один пул планировщика (поток на CPU) на виртуальных потоках, задача с фиксированной частотой на объект AI 1000 мс около 150 мест с synchronized нет нет
L2J Server четыре пула планировщика (эффекты, общий, AI, события), поток игрового времени 10 тиков/с для движения, AI-задача на каждого attackable 1000 мс synchronized примерно в 110 файлах нет нет
Форки Interlude в духе L2jFrozen дизайн L2J: пулы эффектов, общий и AI плюс пулы пакетов поток таймера 10 тиков/с, AI 1000 мс synchronized дампы пулов, детектор дедлоков нет
Линия L2-Scripts пул планировщика (4 потока на CPU) плюс рабочий пул, пошаговый менеджер AI-задач шаг AI 250 мс, задержка AI NPC 1000 мс ReentrantLock и synchronized статус по telnet и дампы пулов нет
L2E (Elixir) процесс на сущность, таймеры send_after, обмен сообщениями регенерация каждые 3 с нет: общего состояния нет нет :rand без seed

Ни одно открытое дерево этих семейств не отдаёт метрики Micrometer, Prometheus или OpenTelemetry, и ни одно не пишет seed и не делает replay сессии.

  • Сообщение между партициями занимает один pass. Команду из мира глобальный сервис обрабатывает в своём следующем pass, а её результат мир получает в своём следующем pass после этого. Порядок детерминирован, но это не одновременно.

  • Перегруженный pass тормозит свою партицию. Партиция — один поток, поэтому тяжёлый pass задерживает всё, что в ней. У одного потока на партицию большой запас: pass со 100 000 живых таймеров занимает около 170 мкс при бюджете 50 мс, а EVE Online держит один поток на звёздную систему. Если осаде понадобится больше, шаги идут по порядку и только по замерам:

    1. Убрать из потока мира всё, что не меняет состояние: persistence executor, network flush на потоках Netty, репликацию и клиентский протокол — в отдельный процесс edge gateway (#4).
    2. Параллельные чистые вычисления внутри фазы: pathfinding, line of sight и диффы видимости на рабочих потоках, а запись остаётся в одном потоке (#21).
    3. Замедление времени, как TiDi в EVE: партиция сверх бюджета замедляет своё игровое время вместо рассинхрона (#20).
    4. Крайняя мера — регионы открытого мира, у каждого свой writer, как в Folia (#21). Именно на границах рождались дюпы в других проектах, и именно туда Lineage 2 стягивает нагрузку: осады.

    Сигнал — время pass по фазам и счётчик превышений бюджета (pass-budget); приёмка — осады ботами на полном стеке. Реальных цифр осад пока нет: мир ещё переезжает.

  • Проверка владельца стоит от 1 до 5 нс на вызов. Большинство горячих путей всё равно стали быстрее, но путь, который делает тысячи проверок и почти ничего больше, за них платит: смена региона при 500 объектах на регион идёт примерно вдвое дольше, 23 мкс против 54 мкс (#146, в эпике производительности #151).

  • Каждая сущность мира — single writer; глобальные сервисы пока нет. Менеджеры, таблицы данных и task managers вне модели сущностей всё ещё держат свои concurrent-коллекции и идут следующим шагом (#161).

  • Открытая работа:

    • упорядочивание ввода относительно наступивших таймеров по времени прихода (#45);
    • запись для replay в продакшене (#44).
  • Открытый мир — single writer в цикле мира, и все его игровые таймеры идут через API цикла. AI think и движение уже работают как периодические системы, но остальные импортированные подсистемы Mobius по-прежнему планируют свою работу задачами с фиксированной частотой. Путь миграции показывает остальной порядок: подсистемы по одной переходят на дедлайны партиции, а инстансы и матчи олимпиады получают собственные партиции.

  • Архитектура: партиции, модель сущностей, репликация, persistence и путь миграции.
  • Исследование движка: модели тиков, timer wheels и очереди, акторы и устройство зрелых MMO-серверов.