24. Deep Dive в QUIC. Пакеты, Stream, Loss Recovery
Введение
В предыдущей статье 23. HTTP 3 и QUIC. Почему будущее уходит от TCP мы рассмотрели мотивы перехода от TCP к UDP + QUIC. Теперь мы спустимся на уровень реализации, чтобы понять, как QUIC заменяет ядро ОС, как Go управляет пакетами в userspace и какие инженерные компромиссы это несёт.
QUIC — это не просто обёртка над UDP. Это полноценный транспортный протокол, реализованный в пространстве пользователя. Для Go-разработчика это означает, что мы берём на себя ответственность за планирование, аллокации, криптографию и контроль перегрузок, которые раньше обеспечивал Linux TCP-стек.
Когда операционная система освобождается от забот о надежности передачи, все обязанности по переотправке пакетов, вычислению RTT, скользящим окнам и криптографии падают на плечи рантайма вашего приложения. Если в TCP вы просто читали байты из файлового дескриптора сокета, то в QUIC ваш Go-процесс становится собственной миниатюрной сетевой подсистемой ядра.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Userspace ["Пространство пользователя (Go Runtime)"]
App["Приложение (HTTP/3 Handlers)"]
StreamEngine["Движок Streams & Flow Control"]:::process
LossRecovery["Loss Recovery (PTO, Congestion Control, ACK Tracker)"]
CryptoEngine["TLS 1.3 Engine (Шифрование фреймов)"]
PacketMux["Демультиплексор пакетов (Connection ID)"]
BufferPool["sync.Pool буферов памяти"]:::accent
App --> StreamEngine
StreamEngine --> LossRecovery
LossRecovery --> CryptoEngine
CryptoEngine --> PacketMux
PacketMux <--> BufferPool
end
subgraph Kernel ["Ядро Linux (Kernel-Space)"]
Netpoller["netpoller (epoll / kqueue)"]
UDPSocket["UDP Socket (recvmmsg / sendmmsg)"]
NetCard["Сетевая карта (NIC Driver)"]
PacketMux <--> Netpoller
Netpoller <--> UDPSocket
UDPSocket <--> NetCard
end
1. Анатомия пакетов QUIC
QUIC использует два формата заголовка: Long Header (для рукопожатия) и Short Header (для данных 1-RTT). Ключевое отличие от TCP: заголовок QUIC инкапсулирует всё, включая криптографические токены и Connection ID, а часть заголовка шифруется сразу после Initial-пакета.
Структура Long Header:
Используется на начальном этапе установления соединения, когда стороны еще не согласовали рабочие криптографические ключи 1-RTT (типы пакетов: Initial, 0-RTT, Handshake, Retry).
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Version | 0xQUIC | DCID Len | Destination Connection ID |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| DCID Len | SCID Len | 0xQUIC | Initial Token Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Initial Token Length (var) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Payload (encrypted) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Структура Short Header (1-RTT):
Используется для 99.9% рабочего трафика после того, как TLS-рукопожатие завершено. Здесь устранены все избыточные поля, остаётся только необходимый минимум для маршрутизации и дешифрования.
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Fixed Bit | Packet Number (var) | Length (var) | Payload |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph PacketTypes ["Типы пакетов QUIC"]
LHeader["Long Header (Рукопожатие)"]:::entry --> LH1["Initial (Запуск TLS 1.3)"]
LHeader --> LH2["0-RTT (Ранние данные)"]
LHeader --> LH3["Handshake (Сертификаты)"]
LHeader --> LH4["Retry (Защита от спуфинга)"]
SHeader["Short Header (1-RTT Данные)"]:::process --> SH1["Минимальный overhead"]
SHeader --> SH2["DCID (Destination Connection ID)"]
SHeader --> SH3["Шифрованный Packet Number"]
SHeader --> SH4["Зашифрованные фреймы (STREAM, ACK, etc.)"]
end
2. Streams и мультиплексирование
QUIC реализует виртуальные каналы (Streams) поверх одного UDP-соединения. Это решает проблему Head-of-Line Blocking (HOLB), которая возникает в TCP: если один TCP-пакет теряется, все последующие блокируются до retransmission.
Каждый поток представляет собой независимый двунаправленный или однонаправленный упорядоченный байтовый поток. Внутри QUIC-пакета полезная нагрузка разбивается на фреймы (STREAM, ACK, MAX_DATA, RESET_STREAM). Один UDP-датаграмма может содержать одновременно фреймы STREAM 4, STREAM 8 и подтверждение ACK.
Идентификатор потока (Stream ID):
Идентификатор потока — это 62-битное число переменной длины (varint). Младшие 2 бита определяют природу потока:
| Бит 0 | Бит 1 | Биты 2-62 |
|---|---|---|
| 0 = Client-initiated 1 = Server-initiated |
0 = Bidirectional 1 = Unidirectional |
Уникальный номер потока |
0x00: Инициирован клиентом, двунаправленный (например, стандартный HTTP-запрос/ответ).0x01: Инициирован сервером, двунаправленный.0x02: Инициирован клиентом, однонаправленный (например, поток телеметрии).0x03: Инициирован сервером, однонаправленный (например, Server-Push или служебный управляющий поток HTTP/3).
Flow Control (Управление потоком):
QUIC использует оконный контроль на двух независимых уровнях:
- Connection-level: общий лимит на входящий трафик для всего соединения в целом (фрейм
MAX_DATA). - Stream-level: лимит на конкретный поток (фрейм
MAX_STREAM_DATA).
Если MAX_DATA или MAX_STREAM_DATA исчерпаны, отправитель должен ждать увеличения лимита фреймами MAX_DATA / MAX_STREAM_DATA (WINDOW_UPDATE — это HTTP/2; в QUIC ещё есть MAX_STREAMS). Это предотвращает OOM на стороне получателя и позволяет тонко настраивать приоритеты (например, притормозить загрузку изображений, пока грузится JS).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph FlowControl ["Двухуровневый Flow Control в QUIC"]
ConnWindow["Окно соединения: MAX_DATA (напр. 10 МБ)"]
Stream1Window["Окно Stream 1: MAX_STREAM_DATA (2 МБ)"]
Stream3Window["Окно Stream 3: MAX_STREAM_DATA (512 КБ)"]
ConnWindow --> Stream1Window
ConnWindow --> Stream3Window
Stream1Window --> Consume1["Чтение буфера Stream 1 в Go"]
Consume1 --> SendUpdate1["Отправка фрейма MAX_STREAM_DATA (расширение окна)"]
ConnWindow --> GlobalConsume["Чтение суммарного объема байт"]
GlobalConsume --> SendUpdateConn["Отправка фрейма MAX_DATA"]
end
3. Loss Recovery и Congestion Control под капотом
В TCP loss recovery встроен в ядро Linux. В QUIC он реализован в userspace (в пакете quic-go). Это даёт гибкость, но снимает аппаратное ускорение (TSO, GRO, checksum offload).
Ключевые механизмы:
- ACK-фреймы: Отправляются с задержкой (
AckDelay), кодируют диапазон потерянных пакетов и ECN-маркеры. QUIC используетACK FrequencyиACK Delayдля снижения overhead. Вместо единичных подтверждений фреймACKпередает список диапазонов (ACK Ranges), компактно описывая, какие именно пакеты были получены. - PTO (Probe Timeout): Вместо TCP RTO (Retransmission Timeout), QUIC использует PTO:
smoothed_rtt + max(4 * rttvar, kGranularity) + max_ack_delay(RFC 9002 §6.2.1). Если PTO истекает, отправитель шлётPINGили пустой пакет, чтобы подтвердить живость пути. Это не считается потерей пакета и предотвращает преждевременный сброс окна перегрузки. - ECN (Explicit Congestion Notification): QUIC читает ECN-кодпоинты (ECT(0), ECT(1), CE) из IP-заголовка и возвращает счётчики отправителю во фрейме
ACK_ECN(поле «ECN-Echo» — это флаг TCP). Если на пути пакеты помечены как CE, QUIC немедленно снижает окно перегрузки, не дожидаясь потери пакета. - Congestion Control: По умолчанию RFC 9002 описывает NewReno;
quic-goиспользует Cubic/Reno-подобный алгоритм, а BBR в нём не реализован. BBR (как алгоритм) строит модель пропускной способности и задержки пути, не полагаясь на потерю пакетов как на сигнал перегрузки.
stateDiagram-v2
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
[*] --> Tracking: Пакет отправлен (сохранен в retransmissionBuffer)
Tracking --> ACKed: Получен ACK фрейм
ACKed --> FreeMem: Освобождение буфера (возврат в sync.Pool)
FreeMem --> [*]
Tracking --> PTOTimer: Ожидание подтверждения
PTOTimer --> SendProbe: Истек PTO (Probe Timeout)
SendProbe --> SendProbe: Отправка зонда PING / Handshake
Tracking --> Lost: Детекция потери (3 пакета выше по PN или time-loss)
Lost --> Retransmit: Присвоение НОВОГО Packet Number!
Retransmit --> Tracking: Повторная отправка данных в новом пакете
4. Реализация в Go: netpoll, sync.Pool и Escape Analysis
Стандартная библиотека Go до сих пор не содержит production-ready QUIC. Индустрия использует github.com/quic-go/quic-go. Архитектура строится вокруг трёх столпов:
net.UDPConn: Работа с UDP-сокетом через стандартную библиотеку; внутриnetдескриптор регистрируется в netpoller (epoll/kqueue) для событийreadable/writable. (Внутренний пакетinternal/pollвquic-goне импортируется.)sync.Poolдля пакетов: Буферы пакетов (32KB-1MB) выделяются в пуле. Это критично для снижения давления на GC.- Горутина на соединение/поток:
quic-goсоздаёт горутину на каждое соединение и дополнительные горутины на активные потоки. Планировщик Go обрабатывает это эффективно, но при 100k+ соединений нужно учитывать overhead контекста.
// Упрощённая иллюстрация (не код из quic-go) управления буфером пакета.
// Показывает работу с sync.Pool и Escape Analysis
var packetPool = sync.Pool{
New: func() any {
// 32KB часто попадает в L2/L3 кэш CPU (L1 обычно 32KB, L2 256KB-8MB)
// Выделение в стеке невозможно из-за динамического размера
return make([]byte, 32*1024)
},
}
func (c *connection) readLoop(conn *net.UDPConn) error {
buf := packetPool.Get().([]byte)
defer packetPool.Put(buf) // Возврат в пул до выхода
// Горутина паркуется в netpoller, пока в сокете нет данных (epoll)
n, _, err := conn.ReadFromUDPAddrPort(buf)
if err != nil {
return fmt.Errorf("udp read: %w", err)
}
// Парсинг QUIC пакета
packet, err := ParsePacket(buf[:n])
if err != nil {
return fmt.Errorf("parse quic packet: %w", err)
}
// Обработка фреймов...
return nil
}
5. Ловушки, сравнение с TCP и вопросы для собеседования
| Аспект | TCP (Kernel) | QUIC (Userspace/Go) |
|---|---|---|
| Оптимизация ядра | TSO, GRO, Checksum Offload, TCP BBR | Отсутствуют. CPU тратит время на сборку пакетов |
| HOL Blocking | Есть на уровне соединения | Решён на уровне потоков (Streams) |
| Миграция IP | Разрывает соединение (5-tuple) | Поддерживается через Connection ID |
| Безопасность | TLS отдельно (или TLS 1.3 поверх) | TLS 1.3 встроен в транспорт (0-RTT, key rotation) |
| CPU Utilization | Низкий (ядро) | Выше (криптография + loss recovery) |
Итог
QUIC переносит транспортный слой из ядра Linux в пространство пользователя, что даёт Go-разработчикам полный контроль над мультиплексированием, криптографией и congestion control, но требует ручного управления аллокациями (sync.Pool), awareness к Escape Analysis и настройки MTU в контейнеризованных средах. Понимание netpoll, пакетных структур и loss recovery engine критично для написания высоконагруженных QUIC-серверов без утечек памяти и GC pauses.
Мы завершили глубокое погружение в транспортный уровень и протоколы поверх UDP. В следующей статье мы рассмотрим долгоживущие двунаправленные каналы связи: 25. WebSocket, SSE и долгоживущие соединения, где разберём, как Go управляет keep-alive соединениями, heartbeat-механизмами и backpressure в real-time сценариях.