Раздел 23 · Rust

Сокеты с нуля: TCP, UDP и байты на проводе

senior~35 мин

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

Сокеты с нуля: TCP, UDP и байты на проводе

Прошлый блок закрылся физикой кэшей: мы дошли до строк кэша и барьеров процессора и поняли, откуда у атомиков цена. Теперь начинается третий проект курса, сетевой игровой движок со своим бинарным протоколом, предсказанием и лаг-компенсацией. И, как всегда, начинаем снизу: прежде чем проектировать протокол, надо потрогать сами сокеты руками. Сегодня разберём два транспорта и их фундаментальную разницу: TCP как поток байт без границ и UDP как датаграммы без гарантий. Поймём, почему именно игра берёт UDP, а не удобный TCP, и научимся читать сокет, не вешая на нём поток управления, через неблокирующий режим. Это прямое продолжение сетевого программирования из CS:APP, только на Rust и без отдельного C-курса.

Зачем трогать сокеты руками

Можно взять готовый веб-фреймворк и не думать, что под ним. Но сетевой движок мы строим именно затем, чтобы увидеть слой, который обычно спрятан. Между двумя машинами лежит не «отправил объект, получил объект», а поток или поток датаграмм из байтов. Все ловушки сети (нет границ сообщений, пакет потерялся, пакет пришёл дважды, пакет пришёл позже соседа) живут ровно тут, на уровне сокета, и netcode это набор приёмов, как с ними жить. Поэтому начинаем с двух базовых абстракций std::net и щупаем их поведение, а не описание из документации.

TCP это поток без границ

TCP даёт соединение, надёжную доставку и порядок. Звучит как мечта, но у потоковой природы есть прямое следствие, на котором спотыкаются все новички: у потока нет границ сообщений. Один write на одной стороне и один read на другой не обязаны совпадать по размеру. Ты отправил два сообщения по 100 байт, а на приёме может прийти один кусок в 200 байт, или три куска, или 199 и потом 1. Операционная система видит просто реку байтов и режет её как ей удобно.

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

use std::io::{self, Read, Write};

/// Потолок размера кадра при чтении из TCP. Граница доверия: длина в префиксе
/// пришла из сети, и без потолка злонамеренный пир заставил бы нас выделить сколько
/// угодно памяти, просто прислав большое число.
pub const MAX_FRAME: u32 = 1 << 20; // 1 MiB

/// Кадрирует сообщение длиной: u32 big-endian с размером, затем сами байты.
pub fn write_frame<W: Write>(w: &mut W, payload: &[u8]) -> io::Result<()> {
    let len = payload.len();
    if len as u64 > MAX_FRAME as u64 {
        return Err(io::Error::new(io::ErrorKind::InvalidInput, "кадр больше предела"));
    }
    w.write_all(&(len as u32).to_be_bytes())?;
    w.write_all(payload)?;
    w.flush()
}

/// Читает один кадр, записанный write_frame: сперва длину, потом тело целиком.
pub fn read_frame<R: Read>(r: &mut R) -> io::Result<Vec<u8>> {
    let mut len_buf = [0u8; 4];
    r.read_exact(&mut len_buf)?;
    let len = u32::from_be_bytes(len_buf);
    if len > MAX_FRAME {
        return Err(io::Error::new(io::ErrorKind::InvalidData, "кадр больше предела"));
    }
    let mut payload = vec![0u8; len as usize];
    r.read_exact(&mut payload)?;
    Ok(payload)
}

Тут видно, почему TCP это поток: read_exact собирает ровно нужное число байт, сколько бы вызовов read под капотом ни понадобилось. И обрати внимание на MAX_FRAME: длина пришла из сети, то есть это недоверенный ввод. Без потолка пир прислал бы в префиксе 0xFFFFFFFF, и мы бы покорно попытались выделить четыре гигабайта. Любое число с провода это потенциальная атака, и граница доверия начинается прямо тут. К этой мысли мы вернёмся в уроке про сериализацию.

Big-endian в префиксе выбран не случайно: это сетевой порядок байт, и машина с любым нативным порядком прочитает длину одинаково. Мы проходили endianness в RU3, и весь наш протокол будет писать числа на провод явно через to_be_bytes, не полагаясь на порядок процессора.

UDP это датаграммы без гарантий

UDP устроен противоположно. Соединения нет, доставки никто не обещает, порядок не гарантирован, пакет может прийти дважды или не прийти. Зато у датаграммы есть граница: одна send_to это ровно одна recv_from. Кадрировать ничего не надо, сообщение приходит целиком или не приходит совсем.

use std::net::UdpSocket;

/// Открывает UDP-сокет на адресе и переводит его в неблокирующий режим.
pub fn bind_udp_nonblocking(addr: &str) -> io::Result<UdpSocket> {
    let socket = UdpSocket::bind(addr)?;
    socket.set_nonblocking(true)?;
    Ok(socket)
}

Список того, чего у UDP нет, выглядит как список недостатков. Почему же игра выбирает именно его?

Сначала потрогай саму разницу. В режиме TCP два сообщения склеиваются в один поток, и ползунок режет его как попало: границы чтения не совпадают с границами сообщений, потому их и приходится размечать префиксом длины. Переключи на UDP, и каждое сообщение станет отдельной датаграммой с готовой границей.

Почему игра берёт UDP

Кажется логичным взять TCP: он же надёжный, зачем городить велосипед. Но у надёжности TCP есть скрытая цена, смертельная для реального времени, это head-of-line blocking. TCP обязан отдавать байты строго по порядку. Если один пакет потерялся, поток встаёт и ждёт его переотправки, придерживая весь свежий трафик, который уже пришёл и лежит в буфере. Для игры это худшее, что может случиться: вместо плавной картинки ты получаешь заморозку, пока сеть переотправляет позицию, которая давно устарела.

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

Дальше весь третий проект это достройка над голым UDP ровно тех гарантий, которые нужны, и только их: для эфемерного (ввод, снапшоты позиций) надёжность не нужна вовсе, а для важного (рукопожатие, выстрелы, убийства) мы сделаем свою надёжность с переотправкой именно того, что нельзя терять. TCP даёт всё или ничего, а нам нужна выборочная надёжность, и построить её можно только поверх UDP.

Блокирующий против неблокирующего

Есть ещё одна ось, ортогональная выбору транспорта, и она решает, как вообще устроен главный цикл сервера. По умолчанию сокет блокирующий: вызов recv_from висит, пока не придут данные. Для игрового сервера это неприемлемо: ему надо и принимать пакеты, и каждые 16 миллисекунд считать такт мира, и рассылать снапшоты. Висеть на одном recv он не может.

Решение это неблокирующий режим. С set_nonblocking(true) сокет, если данных нет, не висит, а сразу возвращает ошибку WouldBlock. Это не настоящая ошибка, а штатное «сейчас данных нет». Превратим её в понятный результат:

use std::net::UdpSocket;

/// Неблокирующее чтение UDP-датаграммы.
///
/// На неблокирующем сокете отсутствие данных это не ошибка приложения, а штатный
/// WouldBlock. Возвращаем Ok(None) в этом случае и Ok(Some(...)), когда датаграмма
/// пришла. Так строится цикл опроса (и так его потом прячет рантайм).
pub fn try_recv_udp(
    socket: &UdpSocket,
    buf: &mut [u8],
) -> io::Result<Option<(usize, std::net::SocketAddr)>> {
    match socket.recv_from(buf) {
        Ok((n, addr)) => Ok(Some((n, addr))),
        Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None),
        Err(e) => Err(e),
    }
}

Три ветви match это вся суть неблокирующего ввода: данные пришли это Some, данных нет это None, всё остальное это настоящая ошибка. Имея такой try_recv_udp, сервер крутит цикл опроса: дёрнул сокет (вернулось None это и ладно), посчитал такт, разослал снапшоты, снова дёрнул. Поток управления всегда у нас.

Но опрос в тугую петлю жёг бы процессор впустую, поэтому в реальности отсутствие данных надо как-то ждать без busy-loop. Этим занимается слой ниже: epoll на Linux, kqueue на BSD, а кроссплатформенно крейт mio, который мы видели в уроке про свой executor. Сам же асинхронный рантайм tokio прячет всю эту механику за async/await, и в уроке про сервер мы соберём главный цикл уже на нём. Но под tokio::select! лежит ровно то, что ты видишь тут: неблокирующий сокет и WouldBlock.

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

TCP это поток без границ сообщений: что отправитель записал одним write, получатель прочитает как попало нарезанным, поэтому сообщения надо кадрировать самому, и простейший кадр это префикс длины плюс read_exact. UDP это датаграммы: граница есть (одна send_to это одна recv_from), а гарантий доставки и порядка нет. Игра выбирает UDP именно из-за этого: head-of-line blocking в TCP заморозил бы картинку ради переотправки устаревших данных, а игре свежесть важнее доставки, и нужную надёжность мы достроим выборочно сами. Любое число с провода (длина кадра) это недоверенный ввод, и потолок вроде MAX_FRAME это не перестраховка, а защита от бомбы аллокаций. Неблокирующий режим (set_nonblocking, WouldBlock, превращённый в Ok(None)) оставляет поток управления у программы и лежит в основе и mio, и tokio.

Дальше построим то, ради чего всё это: свой бинарный игровой протокол с компактной упаковкой чисел и битов, версионированием и дельтами. Голый UDP теперь у нас есть, пора решить, что именно по нему гонять.

Домашка