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).

TEXT
 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-рукопожатие завершено. Здесь устранены все избыточные поля, остаётся только необходимый минимум для маршрутизации и дешифрования.

TEXT
 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 использует оконный контроль на двух независимых уровнях:

  1. Connection-level: общий лимит на входящий трафик для всего соединения в целом (фрейм MAX_DATA).
  2. 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. Архитектура строится вокруг трёх столпов:

  1. net.UDPConn: Работа с UDP-сокетом через стандартную библиотеку; внутри net дескриптор регистрируется в netpoller (epoll/kqueue) для событий readable/writable. (Внутренний пакет internal/poll в quic-go не импортируется.)
  2. sync.Pool для пакетов: Буферы пакетов (32KB-1MB) выделяются в пуле. Это критично для снижения давления на GC.
  3. Горутина на соединение/поток: quic-go создаёт горутину на каждое соединение и дополнительные горутины на активные потоки. Планировщик Go обрабатывает это эффективно, но при 100k+ соединений нужно учитывать overhead контекста.
Go
// Упрощённая иллюстрация (не код из 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 сценариях.