Раздел 23 · Rust

Сервер на tokio: авторитетный тик, рассылка, backpressure

senior~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Сервер на tokio: авторитетный тик, рассылка, backpressure

У нас есть сокеты, протокол и кодек. Пора свести их с настоящим рантаймом и собрать игровой сервер. Сегодня соберём его на tokio: главный цикл, где приём пакетов и серверный тик живут в одном select!, авторитетная симуляция как единственный источник правды, рассылка снапшотов с дельта-сжатием, интерес-менеджмент (шлём игроку только то, что он видит) и две защиты от перегрузки: антифлуд на входе и контроль темпа на выходе. Это соединение трёх блоков сразу: async из RU8, конкурентность под нагрузкой и работа с байтами под нагрузкой. Бой и лаг-компенсацию оставим следующему уроку, сегодня про каркас сервера.

Почему сервер авторитетный

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

Главный цикл: select! между тиком и пакетом

Серверу надо делать два дела одновременно: принимать датаграммы, когда они приходят, и каждые 16 миллисекунд (60 раз в секунду) считать такт мира. Висеть на recv он не может, мы это разобрали на сокетах. Решение это tokio::select!: одна ветка ждёт таймер тика, другая ждёт пакет, и цикл реагирует на то, что произошло раньше.

let socket = UdpSocket::bind(bind).await?;
let mut sim = ServerSim::new();
let mut peers: HashMap<SocketAddr, Peer> = HashMap::new();
let mut tick = interval(Duration::from_secs_f64(TICK_DT as f64)); // 60 Гц

loop {
    tokio::select! {
        _ = tick.tick() => {
            let events = sim.tick();        // авторитетный такт мира
            // ... разослать снапшоты всем пирам (см. ниже)
        }
        res = socket.recv_from(&mut buf) => {
            let (n, addr) = res?;
            // новый адрес это новый игрок: спавним и заводим соединение
            let peer = peers.entry(addr).or_insert_with(|| {
                let id = sim.add_player();
                Peer { id, conn: Connection::new(PROTOCOL_ID) }
            });
            // ... разобрать пакет и скормить ввод симуляции (см. ниже)
        }
    }
}

Это тот самый select! из урока про async, только в боевом применении. Никаких потоков на каждого игрока: один цикл, кооперативно переключающийся между тиком и приёмом. Адрес пира это его ключ: новый SocketAddr это новый игрок, и HashMap::entry заводит ему слот и соединение лениво, на первом пакете.

Авторитетный тик

Внутри sim.tick() живёт детерминированный шаг мира. Порядок строгий: собрать ввод, подвинуть всех, и только потом считать бой. Ключевая деталь это что делать, когда ввод игрока не пришёл (пакет потерялся или опоздал):

for slot in self.slots.values_mut() {
    let input = if let Some(next) = slot.pending.pop_front() {
        slot.last_processed_seq = next.seq;
        slot.last_buttons = next.buttons;
        slot.last_aim = next.aim;
        next
    } else {
        // Ввод запоздал: повторяем последние кнопки и прицел, seq не двигаем.
        Input::new(slot.last_processed_seq, slot.last_buttons).with_aim(slot.last_aim)
    };
    inputs.insert(slot.id, input);
}
self.world.step(&inputs); // общий с клиентом детерминированный шаг

Сервер не замирает в ожидании опоздавшего ввода: он повторяет последнюю команду игрока. Бежал вперёд и пакет потерялся, значит на этот такт считаем, что бежишь дальше. Это сглаживает потери и держит мир в движении. И world.step это ровно тот же код, что крутит клиент у себя, на этом совпадении держится всё предсказание.

Интерес-менеджмент: не шли того, кого не видно

Наивный сервер слал бы каждому полный список всех игроков. Это и трафик впустую, и дыра в безопасности: имея на руках чужие координаты, читер нарисует врагов сквозь стены (wallhack). Лечит оба интерес-менеджмент: в снапшот игроку попадают только он сам и те, кто в радиусе видимости.

pub fn from_world_interest(world: &World, acked_input: u32, viewer: PlayerId, radius: f32) -> Self {
    let center = world.player(viewer).map(|p| (p.x, p.y));
    let r2 = radius * radius;
    let players = world.players.values()
        .filter(|p| {
            p.id == viewer || center.map(|(cx, cy)| {
                let (dx, dy) = (p.x - cx, p.y - cy);
                dx * dx + dy * dy <= r2          // сравниваем квадраты, без sqrt
            }).unwrap_or(true)
        })
        .copied().collect();
    Snapshot { tick: world.tick, acked_input, players }
}

Это редкий приём, который одновременно ускоряет и защищает: то, чего нет в пакете, нельзя ни нарисовать, ни сжульничать. Защита, встроенная в протокол, а не приклеенная сверху.

Рассылка: дельта по подтверждённой базе

На каждом тике сервер шлёт каждому пиру снапшот. Полный он или дельта, решает наличие подтверждённой клиентом базы:

pub fn snapshot_for(&mut self, id: PlayerId) -> Option<ServerMessage> {
    let acked = self.slots.get(&id)?.last_processed_seq;
    // Интерес-менеджмент: только viewer и ближние.
    let current = Snapshot::from_world_interest(&self.world, acked, id, AOI_RADIUS);
    let slot = self.slots.get_mut(&id)?;
    slot.sent_history.insert(current.tick, current.clone());

    let msg = match &slot.baseline {
        Some(base) => ServerMessage::Delta(DeltaSnapshot::diff(base, &current)),
        None => ServerMessage::Snapshot(current),
    };
    Some(msg)
}

Пока клиент не подтвердил ни одного снапшота, базы нет, и сервер шлёт полный (опору). Как только клиент прислал AckSnapshot, сервер запоминает эту базу и переходит на дельты из прошлого урока. Снимок снапшота кладётся в sent_history, чтобы было от чего строить diff.

Две защиты от перегрузки

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

const MAX_INPUT_QUEUE: usize = 16; // джиттер-буфер, не больше

if slot.pending.len() >= MAX_INPUT_QUEUE {
    slot.pending.pop_front();
    slot.dropped_inputs += 1; // антифлуд: выкидываем самое старое
}
slot.pending.push_back(input);

На выходе это backpressure: сервер не шлёт на максимальной скорости, а сам выбирает темп рассылки, и при плохом канале сбавляет его:

/// Рекомендуемый интервал между отправками по режиму перегрузки.
/// В хорошем режиме 30 пакетов в секунду, в плохом 10.
pub fn send_interval(&self) -> f64 {
    match self.congestion {
        Congestion::Good => 1.0 / 30.0,
        Congestion::Bad  => 1.0 / 10.0,
    }
}

Откуда берётся режим Good/Bad и как считается перегрузка по RTT, разберём в уроке про надёжность. Здесь важна сама идея: захлёбывающийся канал нельзя лечить, заливая его ещё сильнее, и сервер обязан уметь придержать темп.

Что унести из урока

Сервер авторитетный: клиент шлёт намерение, правду считает и рассылает сервер, и это защита и от лага, и от читера. Главный цикл это tokio::select! между таймером тика и приёмом датаграммы, один кооперативный цикл вместо потока на игрока, а HashMap по SocketAddr лениво заводит игрока на первом пакете. Авторитетный тик детерминирован и повторяет последнюю команду при потере ввода, чтобы мир не замирал, и крутит ровно тот же step, что и клиент. Интерес-менеджмент шлёт игроку только видимое, экономя трафик и закрывая wallhack встроенно. Рассылка шлёт полный снапшот как опору и дельты от подтверждённой клиентом базы. Перегрузку держат с двух сторон: антифлуд ограничивает входную очередь ввода, backpressure через send_interval сбавляет темп рассылки на плохом канале.

Дальше перейдём на сторону клиента и разберём самое интересное в netcode: предсказание, реконсиляцию, интерполяцию и лаг-компенсацию, и увидим всё это живьём в браузерном виджете.

Домашка