Zero-Tick Engine
Мир, который не тикает. Он реагирует.
Заголовок раздела «Мир, который не тикает. Он реагирует.»Каждое действие игрока, каждый удар и каждый бафф обрабатываются в момент, когда они происходят: без тиков, без гонок, без холостой работы.
Состояние: ядро движка уже в nx-l2, и мир переезжает на него шаг за шагом. См. путь миграции.
Почему Zero-Tick
Заголовок раздела «Почему Zero-Tick»Быстрее
Заголовок раздела «Быстрее»Таймеры, баффы и откаты обходятся в разы дешевле, чем в классических сборках.
Никаких дюпов
Заголовок раздела «Никаких дюпов»Предмет не может оказаться в двух местах: изменения инвентаря идут строго по одному, в собственном потоке пространства. И сервер это проверяет: каждый тип с состоянием мира отказывает вызову из чужого потока, поэтому случайный поток падает громко, а не тихо дюпает предмет.
Никаких гонок
Заголовок раздела «Никаких гонок»Удар и Sleep, прилетевшие в один момент, всегда разрешаются одинаково — в порядке прихода. Атака будит спящую цель,
поэтому порядок решает исход боя. Забыть блокировку больше негде: сущности мира избавились от 119 блоков
synchronized, 155 concurrent-коллекций и 43 atomics ради одного потока-владельца и проверки.
Мгновенная реакция
Заголовок раздела «Мгновенная реакция»Ничто не ждёт «следующего тика»: действие обрабатывается сразу.
Без холостой работы
Заголовок раздела «Без холостой работы»Каждое пробуждение — настоящее событие: ни пустых тиков, как в тиковых сборках, ни задач, крутящихся на пустых пулах, как в сборках на пулах потоков.
Видно всё
Заголовок раздела «Видно всё»Где тормозит, в какой фазе, в каком pass: метрики Prometheus из коробки, готовые для вашей Grafana.
Детерминирован по устройству
Заголовок раздела «Детерминирован по устройству»Pass детерминирован: время и случайность берутся только из pass, а seed пишется в лог, поэтому одни и те же входные данные дают тот же бой. Запись живых входных данных для replay — в планах.
Zero-Tick против классических сборок
Заголовок раздела «Zero-Tick против классических сборок»| Возможность | Zero-Tick | L2J Mobius | aCis | L2J Server | Линия L2-Scripts | L2E (Elixir) |
|---|---|---|---|---|---|---|
| Просыпается только на события, без опроса | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Без блокировок на игровом состоянии, single writer | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Дюпы исключены самим устройством | ✓ | ✗ | ✗ | ✗ | ✗ | — |
| Владение потоком проверяется в рантайме | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ |
| Детерминированные pass (RNG с seed) | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Встроенные метрики Prometheus | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Бюджет pass с разбивкой по фазам | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
По открытому исходному коду каждого семейства; ✓ — есть, ✗ — нет, — — не установлено. Факты за каждой клеткой — в подробном сравнении ниже, в разделе «Под капотом».
Тик против Zero-Tick
Заголовок раздела «Тик против Zero-Tick»Классический сервер просыпается по фиксированному тику, например раз в 100 мс, и обрабатывает всё, что накопилось с прошлого раза. Выглядит как «всё одновременно», но это не так. Действие, пришедшее сразу после тика, ждёт следующего до 100 мс. Внутри тика события всё равно выполняются одно за другим, в том порядке, в каком лежат в списке. Тик чаще, хоть 100 раз в секунду, лишь сокращает ожидание; два события от этого одновременными не становятся. Zero-Tick убирает ожидание и закрепляет порядок: каждое событие обрабатывается, когда пришло, и порядок одинаков при каждом запуске.
Тик заставляет мир ждать. Zero-Tick — нет.
Под капотом
Заголовок раздела «Под капотом»Как устроен pass
Заголовок раздела «Как устроен pass»Пространство мира (партиция) — это event loop в собственном потоке. Его никто не опрашивает:
- I/O-поток Netty (epoll в Linux) декодирует пакет клиента и кладёт его в очередь сессии.
- Поднимается флаг готовности сессии, а в inbox партиции приходит «у этой сессии есть ввод»; если поток
цикла запаркован, его будит
LockSupport.unpark. - Когда ввода нет, цикл паркуется в
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
Заголовок раздела «Zero-Tick, тики и ECS»Тик или 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 это исключение, которое запускает событие.
Пространства
Заголовок раздела «Пространства»- Открытый мир — одна партиция. Он не режется на регионы: осады нагружают ровно те границы, которые создало бы деление, а разделение бесшовного мира между несколькими writers — подход, который на практике провалился.
- Каждый инстанс и каждый матч олимпиады — своя партиция. В игре это и так отдельные пространства.
- Глобальные сервисы (кланы, аукцион, замки и расписание осад) — тоже single writers, у каждого свой inbox. Партиции шлют им команды и читают их состояние из публикуемых снимков.
Массовые битвы в мире не тормозят олимпиаду. У каждой партиции свой поток цикла, а глобального барьера между партициями нет, поэтому осада, заполнившая pass мира, не задерживает матч. Условие одно — достаточно ядер процессора.
Timer queue
Заголовок раздела «Timer queue»Таймеры живут в двух очередях на партицию (игровое и 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 продолжается.
Single writer
Заголовок раздела «Single writer»Каждый тип, который держит состояние мира, принадлежит одному потоку — 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, 28volatile-полей и 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:jmhJarjava -jar engine/core/build/libs/engine-core-jmh.jar TimerComparisonBenchmarkjava -jar engine/core/build/libs/engine-core-jmh.jar IdleWakeupBenchmarkjava -cp engine/core/build/libs/engine-core-jmh.jar app.l2nx.engine.core.loop.LatenessHarnessLatenessHarness — обычный 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 держит один поток на звёздную систему. Если осаде понадобится больше, шаги идут по порядку и только по замерам:
- Убрать из потока мира всё, что не меняет состояние: persistence executor, network flush на потоках Netty, репликацию и клиентский протокол — в отдельный процесс edge gateway (#4).
- Параллельные чистые вычисления внутри фазы: pathfinding, line of sight и диффы видимости на рабочих потоках, а запись остаётся в одном потоке (#21).
- Замедление времени, как TiDi в EVE: партиция сверх бюджета замедляет своё игровое время вместо рассинхрона (#20).
- Крайняя мера — регионы открытого мира, у каждого свой writer, как в Folia (#21). Именно на границах рождались дюпы в других проектах, и именно туда Lineage 2 стягивает нагрузку: осады.
Сигнал — время pass по фазам и счётчик превышений бюджета (
pass-budget); приёмка — осады ботами на полном стеке. Реальных цифр осад пока нет: мир ещё переезжает. -
Проверка владельца стоит от 1 до 5 нс на вызов. Большинство горячих путей всё равно стали быстрее, но путь, который делает тысячи проверок и почти ничего больше, за них платит: смена региона при 500 объектах на регион идёт примерно вдвое дольше, 23 мкс против 54 мкс (#146, в эпике производительности #151).
-
Каждая сущность мира — single writer; глобальные сервисы пока нет. Менеджеры, таблицы данных и task managers вне модели сущностей всё ещё держат свои concurrent-коллекции и идут следующим шагом (#161).
-
Открытая работа:
-
Открытый мир — single writer в цикле мира, и все его игровые таймеры идут через API цикла. AI think и движение уже работают как периодические системы, но остальные импортированные подсистемы Mobius по-прежнему планируют свою работу задачами с фиксированной частотой. Путь миграции показывает остальной порядок: подсистемы по одной переходят на дедлайны партиции, а инстансы и матчи олимпиады получают собственные партиции.
Подробнее
Заголовок раздела «Подробнее»- Архитектура: партиции, модель сущностей, репликация, persistence и путь миграции.
- Исследование движка: модели тиков, timer wheels и очереди, акторы и устройство зрелых MMO-серверов.
