Надёжность поверх UDP: acks, переотправка, перегрузка
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Надёжность поверх UDP: acks, переотправка, перегрузка
Весь netcode из прошлого урока считал, что пакеты как-то доходят. Но мы с самого начала выбрали UDP, а он не гарантирует ни доставку, ни порядок, ни отсутствие дубликатов. Снапшоты и ввод эфемерны: потерялся пакет, придёт следующий, свежее. А вот рукопожатие, выстрелы и убийства терять нельзя и показывать дважды нельзя. Значит, нужна выборочная надёжность: для важного с переотправкой, для остального без. Сегодня построим её руками: нумерацию с закольцовыванием, систему подтверждений с битовым полем, очередь переотправки, оценку RTT и контроль перегрузки. А в конце замкнём весь третий проект: симулятор плохого канала и golden-трейсы как страховка детерминизма. Это финал блока.
Почему не взять TCP ради надёжности
Соблазн вернуться к TCP велик: он же всё это умеет. Но TCP даёт надёжность на весь поток сразу, и мы видели цену: head-of-line blocking заморозит свежие снапшоты ради переотправки устаревших. Нам же нужна надёжность точечная: переотправлять только то, что нельзя терять, а позиции игроков пусть теряются на здоровье. TCP такого не умеет, поэтому строим своё поверх UDP, ровно ту надёжность, которая нужна, и ни байтом больше.
Нумерация с закольцовыванием
Каждый пакет несёт свой порядковый номер. Чтобы заголовок был маленьким, номер это u16, а он рано или поздно переполнится: после 65535 идёт 0. Наивное сравнение a > b на стыке сломается: пакет 0 «меньше» пакета 65535, хотя пришёл позже. Поэтому сравниваем с поправкой на закольцовывание:
/// Истинно, если a новее b с учётом закольцовывания u16.
///
/// a считается больше b, если разница меньше половины диапазона вперёд.
pub fn sequence_greater_than(a: u16, b: u16) -> bool {
((a > b) && (a - b <= 32768)) || ((a < b) && (b - a > 32768))
}
Идея простая: считаем a новее b, если по кольцу до него меньше половины диапазона вперёд. Тогда 0 корректно оказывается новее 65535. Без этой аккуратности соединение сходило бы с ума раз в 65536 пакетов, и поди потом отладь такое.
Подтверждения: один ack за 33 пакета
Теперь главный механизм. Каждый пакет несёт не только свой номер, но и подтверждение того, что мы получили от собеседника:
/// Заголовок надёжного пакета. Едет в начале каждой датаграммы игрового канала.
pub struct Header {
pub protocol_id: u32,
pub sequence: u16, // мой номер
pub ack: u16, // последний номер, что я получил от тебя
pub ack_bits: u32, // битовое поле: какие 32 номера до ack я тоже получил
}
Хитрость в ack_bits. Один ack подтверждает один пакет, а ack-bitfield подтверждает ещё 32: бит n стоит, если получен пакет ack - (n+1). Так один заголовок подтверждает до 33 пакетов разом.
/// Битовое поле подтверждений: бит n стоит, если получен номер
/// remote_sequence - (n + 1).
fn ack_bits(&self) -> u32 {
let mut bits = 0u32;
for n in 0..32u16 {
let seq = self.remote_sequence.wrapping_sub(n + 1);
if self.received.exists(seq) {
bits |= 1 << n;
}
}
bits
}
Зачем такая избыточность? Потому что подтверждения сами едут по UDP и сами теряются. Будь у нас один ack на пакет, потеря подтверждения выглядела бы как потеря данных, и мы зря переотправили бы доставленное. С битовым полем потерянное подтверждение повторится в следующих 32 пакетах, и отдельная надёжность для самих acks не нужна. Красивое самоисцеление.
Помечай пакеты потерянными и смотри, как меняется ack_bits и сколько пакетов подтверждает один заголовок. Снизу калькулятор сравнения номеров покажет, почему на стыке 65535 и 0 наивное сравнение врёт.
Переотправка как следствие, а не таймер
Тут механизм складывается особенно ладно. Надёжное сообщение не имеет своего таймера переотправки. Оно просто лежит в очереди, пока не подтверждено, и едет в каждом подходящем пакете:
/// Ставит надёжное сообщение в очередь. Оно поедет в ближайших пакетах и будет
/// повторяться, пока не подтвердится.
pub fn send_reliable(&mut self, payload: Vec<u8>) {
let id = self.next_reliable_id;
self.next_reliable_id = self.next_reliable_id.wrapping_add(1);
self.outgoing_reliable.push_back(OutgoingReliable { id, payload, last_sent: None });
}
Когда приходит подтверждение пакета, мы снимаем из очереди все надёжные сообщения, которые в нём ехали:
// Снимаем надёжные сообщения, которые ехали в этом подтверждённом пакете.
if let Some(ids) = self.sent_reliable_ids.get(acked_seq).cloned() {
for id in ids {
self.outgoing_reliable.retain(|m| m.id != id);
}
}
Переотправка тут не отдельная машина, а следствие: не подтверждено, значит всё ещё в очереди, значит поедет снова. На приёме сообщения дедуплицируются по id (переотправленное важное могло дойти и в первый раз, и в повторе), поэтому выстрел не засчитается дважды. Эфемерное (снапшоты, ввод) едет отдельным ненадёжным блоком и не переотправляется вовсе. Это и есть та самая выборочность.
RTT и контроль перегрузки
Раз мы знаем, когда пакет ушёл и когда подтвердился, мы знаем и RTT. Сглаживаем его скользящим средним, чтобы один выброс не дёргал оценку:
fn update_rtt(&mut self, sample: f64) {
if self.rtt == 0.0 {
self.rtt = sample;
} else {
self.rtt += (sample - self.rtt) * RTT_SMOOTHING; // EWMA
}
self.rto = (self.rtt * 2.0).clamp(0.05, 1.0); // тайм-аут с запасом
}
А по RTT строим контроль перегрузки, тот самый Good/Bad, что задавал темп рассылки сервера. В плохой режим уходим сразу при высоком RTT, а обратно только после устойчиво хороших условий, причём штрафное время растёт, если перегрузка возвращается слишком быстро:
/// Контроль перегрузки с гистерезисом: в плохой режим уходим сразу, обратно
/// только после устойчиво хороших условий, штраф растёт при быстрых возвратах.
fn update_congestion(&mut self, dt: f64) {
let conditions_good = self.rtt > 0.0 && self.rtt <= RTT_BAD_THRESHOLD;
// ... Good -> Bad сразу при плохом RTT, удваивая штраф при частых срывах;
// Bad -> Good только когда хорошо держится дольше penalty_time
}
Этот гистерезис не даёт системе дёргаться туда-сюда на колебаниях RTT у самой границы. Реагируй на беду мгновенно, на улучшение осторожно, простое и универсальное правило для любых порогов.
Как это вообще тестировать
Чтобы проверить netcode, не нужен плохой Wi-Fi, нужен управляемый плохой канал. Симулятор канала моделирует всё, чем сеть портит жизнь, и делает это детерминированно:
/// Параметры качества канала.
pub struct LinkConfig {
pub latency: f64, // базовая задержка
pub jitter: f64, // разброс задержки (переупорядочивание)
pub loss: f32, // вероятность потери
pub duplicate: f32, // вероятность дубликата
}
Время виртуальное, генератор случайных чисел детерминированный, поэтому тест с потерями и джиттером воспроизводим до байта. На этом же симуляторе крутится и интерактивное демо, и тот самый виджет из прошлого урока: ползунки канала это и есть LinkConfig.
Golden-трейс: страховка детерминизма
И последний штрих, замыкающий весь блок. Симуляция детерминирована, значит её можно записать и проиграть заново байт в байт. Запись это начальное число игроков плюс ввод каждого на каждом такте, а отпечаток мира на каждом такте сворачивается в одно число:
/// Прогоняет запись через свежий сервер и возвращает отпечаток мира на каждом
/// такте. Дважды вызванный на одной записи даёт идентичный результат.
pub fn replay(&self) -> Vec<u64> {
let mut sim = ServerSim::new();
for _ in 0..self.players { sim.add_player(); }
let mut digests = Vec::new();
for frame in &self.frames {
for (id, input) in frame {
sim.ingest(*id, ClientMessage::Inputs { inputs: vec![*input] });
}
sim.tick();
digests.push(sim.world().digest());
}
digests
}
Это число коммитится как golden-значение. Если кто-нибудь случайно сломает детерминизм (поменяет порядок обхода игроков, протащит скрытую зависимость от часов или случайности), трасса разойдётся и тест это поймает. Это прямой аналог nestest из эмулятора NES: эталонная трасса как страховка от незаметной регрессии. Тем же приёмом проверяется и digest мира, построенный на детерминированном обходе BTreeMap.
Что унести из урока и из блока
Надёжность поверх UDP делается выборочной: важное (рукопожатие, выстрелы) едет с переотправкой, эфемерное (снапшоты, ввод) без, чего TCP с его надёжностью на весь поток не умеет. Номера пакетов это u16 с закольцовыванием, и сравнивать их надо через sequence_greater_than. Подтверждения это ack плюс 32-битное поле, один заголовок подтверждает до 33 пакетов и сам себя чинит при потере acks. Переотправка это не таймер, а следствие: неподтверждённое сообщение лежит в очереди и едет снова, а на приёме дедуплицируется по id. RTT считается из времени до подтверждения и через гистерезис управляет контролем перегрузки. А тестируется всё это на детерминированном симуляторе плохого канала, и golden-трейс ловит любую поломку детерминизма.
На этом третий проект курса и весь блок про сети закрыты. Ты прошёл путь от голого сокета через свой бинарный протокол, кодек с валидацией и авторитетный сервер до предсказания, лаг-компенсации и собственной надёжности поверх UDP, и весь этот netcode крутится живьём в браузере. Дальше следующий блок: Rust в браузере через WebAssembly, где мы наконец разберём, как один и тот же Rust-код становится и боевым сервером, и виджетом урока.