22. HTTP 2. Multiplexing, Frames, HPACK
Сдвиг парадигмы: от текста к бинарным фреймам
В компьютерных науках существует элегантный закон развития систем: протоколы начинаются как человекочитаемый текст, чтобы облегчить отладку первопроходцам, но по мере роста масштаба неизбежно уступают место компактным бинарным структурам.
В статьях 20. HTTP 1.1. Структура запроса и ответа и 21. HTTP 1.1 под капотом. Keep Alive, Chunked Encoding, Pipelining мы подробно разобрали, почему текстовый протокол стал узким местом для высоконагруженных систем. Когда Тим Бернерс-Ли создавал Всемирную паутину, возможность подключиться к серверу через утилиту telnet и вручную напечатать:
GET / HTTP/1.1\r\n
Host: example.com\r\n\r\n
казалась верхом инженерной открытости. Однако для современного процессора и сетевого стека парсинг произвольного текста — это сущий кошмар. Парсинг текста в Go требует выделения памяти под строки, работы с регулярными выражениями и сложной логики разделения заголовков, постоянного сканирования байтов в поисках разделителей \r\n и аллокаций в куче.
Стандарт HTTP/2 (RFC 7540, обновленный в RFC 9113) радикально меняет правила игры: это строгий бинарный протокол, ориентированный на максимальную производительность, мультиплексирование и бережное отношение к тактам процессора.
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 HTTP1 ["HTTP/1.1 (Текстовый поток)"]
H1["GET /index.html HTTP/1.1\r\nHost: site.com\r\n\r\n[Тело ответа]"]
end
subgraph HTTP2 ["HTTP/2 (Бинарные структурированные фреймы)"]
F1["9B Header | Stream 1 | HEADERS"]:::process
F2["9B Header | Stream 3 | HEADERS"]:::process
F3["9B Header | Stream 1 | DATA"]:::process
F4["9B Header | Stream 3 | DATA"]:::process
end
HTTP1 -.->|"Сдвиг парадигмы"| HTTP2
Бинарный протокол и структура фрейма
В HTTP/2 больше нет строк GET / HTTP/1.1\r\n. Весь трафик состоит из фреймов (frames) — бинарных пакетов фиксированного формата. Это позволяет парсить данные на уровне CPU без аллокаций строк, что критично для highload.
Процессору больше не нужно читать байт за байтом в цикле, гадая, где кончается заголовок: он читает ровно 9 байт фиксированного заголовка фрейма, за один такт извлекает размер полезной нагрузки и переходит к обработке.
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
A["Фрейм HTTP/2"]:::network --> B["Длина 24 бита"]
A --> C["Тип 8 бит"]
A --> D["Флаги 8 бит"]
A --> E["Stream ID 31 бита +<br>Reserved"]
A --> F["Payload"]
B -->|"16 КБ по умолчанию"| G["Максимальный размер"]
C -->|"HEADERS / DATA / PRIORITY"| H["Управление потоком"]
D -->|"END_STREAM / END_HEADERS"| I["Маркер завершения"]
E -->|"0 - Control / Odd - Client /<br>Even - Server"| J["Идентификатор"]
F -->|"Произвольная длина"| K["Тело фрейма"]
Раскладка заголовка фрейма в памяти (ровно 9 байт):
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|R| Stream Identifier (31) |
+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
| Frame Payload (0...) |
+-----------------------------------------------+
Каждый фрейм содержит заголовок (9 байт) и полезную нагрузку. Назначение полей:
- Length (24 бита): Длина полезной нагрузки (
Payload). По умолчанию максимальный размер фрейма ограничен 16 384 байтами (16 КБ), но стороны могут согласовать размер до 16 777 215 байт черезSETTINGS_MAX_FRAME_SIZE. - Type (8 бит): Определяет семантику фрейма.
- Flags (8 бит): Битовые флаги для специфических оптимизаций (например,
END_STREAMсигнализирует о завершении передачи, аEND_HEADERS— о конце блока заголовков). - R (1 бит): Зарезервированный бит (всегда 0).
- Stream ID (31 бит): Уникальный идентификатор логического потока, к которому принадлежит данный фрейм.
Основные типы фреймов:
HEADERS— заголовки запроса/ответа (сжаты через HPACK). Может открывать новый логический поток.DATA— тело сообщения (нагрузка запроса или ответа).WINDOW_UPDATE— управление flow control (механизм предотвращения переполнения буферов на уровне потока или соединения в целом).PRIORITY— дерево приоритетов потоков (сообщает серверу, какие ресурсы отдавать в первую очередь). Эта схема объявлена устаревшей в RFC 9113 (§5.3.2); ей на смену пришла схема приоритетов из RFC 9218 (заголовокPriority).SETTINGS,PING,GOAWAY— управление соединением.SETTINGSсогласует параметры сессии,PINGизмеряет RTT и проверяет живость канала, аGOAWAYобеспечивает graceful shutdown соединения.
Мультиплексирование и управление потоками (Streams)
Ключевое отличие от 21. HTTP 1.1 под капотом. Keep Alive, Chunked Encoding, Pipelining — multiplexing.
В HTTP/1.1 одно TCP-соединение могло обрабатывать только один запрос-ответ за раз. Если клиенту требовалось 50 ресурсов, браузер открывал пул из 6 TCP-соединений (лимит большинства браузеров), создавая тяжелую нагрузку на память ядра и сетевой стек.
В HTTP/2 один TCP-канал обслуживает сотни логических streams (потоков). Каждый stream имеет уникальный 31-битный ID. Клиент использует нечетные ID, сервер — четные. Идентификатор 0x0 зарезервирован для управляющих фреймов всего соединения в целом.
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
Client["Клиент"]:::entry -->|"TCP Connection"| Server["Сервер"]
Server -->|"Stream 1: HEADERS + DATA"| Client
Server -->|"Stream 3: HEADERS + DATA"| Client
Server -->|"Stream 5: HEADERS + DATA"| Client
Client -->|"Stream 2: WINDOW_UPDATE"| Server
Client -->|"Stream 4: PRIORITY"| Server
Фреймы различных потоков чередуются (interleave) в одном непрерывном TCP-байт-стриме, словно вагоны разных поездов на одной скоростной железнодорожной магистрали.
Это решает проблему Head-of-Line blocking (HOL blocking) уровня приложения, которая убивала pipelining в HTTP/1.1. Если DATA для stream 1 задерживается из-за медленного клиента, stream 3 продолжает передаваться. Если генерация большого PDF-отчета на Stream 1 занимает 5 секунд, это больше не мешает параллельной мгновенной отдаче легковесных JSON-ответов на Stream 3 и Stream 5.
sequenceDiagram
autonumber
participant Client as HTTP/2 Клиент
participant Server as HTTP/2 Сервер
Note over Client,Server: Чередование фреймов в одном TCP-соединении
Client->>Server: [Stream 1] HEADERS: GET /slow-report
Client->>Server: [Stream 3] HEADERS: GET /fast-user-info
Server-->>Client: [Stream 3] HEADERS: 200 OK
Server-->>Client: [Stream 3] DATA: {"name": "Alice"} (END_STREAM)
Server-->>Client: [Stream 1] HEADERS: 200 OK
Server-->>Client: [Stream 1] DATA: [часть 1 отчета...]
Server-->>Client: [Stream 1] DATA: [часть 2 отчета...] (END_STREAM)
HPACK: сжатие заголовков и память
В веб-приложениях заголовки запросов часто превышают размер полезной нагрузки: массивные JWT-токены авторизации, длинные куки, служебные заголовки отслеживания, User-Agent и заголовки маршрутизации Envoy/Istio могут весить 1–2 КБ на каждый HTTP-запрос. В HTTP/1.1 эти метаданные передавались в открытом текстовом виде снова и снова с каждым запросом.
Почему нельзя было просто применить стандартный gzip/deflate?
В 2012 году исследователи продемонстрировали атаку CRIME (Compression Ratio Info-leak Made Easy): если злоумышленник мог подмешать свои данные в запрос (например, через JavaScript в браузере), по размеру сжатого gzip-пакета он мог посимвольно подобрать секретные cookie и токены авторизации.
Специально для HTTP/2 был разработан алгоритм сжатия HPACK (RFC 7541). Он спроектирован так, чтобы быть устойчивым к атакам утечки через компрессию, и использует два словаря:
- Static Table: Фиксированный набор (например,
:method,:scheme,:path,host). Индексация по номеру. Содержит 61 предопределенный индекс наиболее часто встречающихся пар заголовок-значение (например, индекс 2 —method: GET, индекс 3 —method: POST, индекс 4 —path: /). - Dynamic Table: FIFO-таблица ограниченного размера (при переполнении вытесняется самая старая запись, а не «давно не использованная»); каждая сторона ведёт свою таблицу для того направления, которое она кодирует. Позволяет сжимать повторяющиеся заголовки (например,
cookie,user-agent). Когда клиент впервые передает заголовокauthorization: Bearer eyJhbGci..., обе стороны заносят его в динамическую таблицу под индексом 62. Во всех последующих запросах клиент отправляет всего один байт — номер индекса!
Сжатие работает через индексацию (отправка номера в статической/динамической таблице) или Huffman-кодирование (для новых значений). Статическое дерево Хаффмана встроено в спецификацию и оптимизировано под частоту символов в заголовках ASCII.
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 HPACK ["Архитектура HPACK"]
HIn["Входящий заголовок"]:::warning --> Check{"Есть в таблице?"}
Check -- "В статической (1-61)" --> SendStatic["Отправка 1-байтового индекса (напр. :method: GET)"]
Check -- "В динамической (>61)" --> SendDynamic["Отправка динамического индекса"]
Check -- "Новый заголовок" --> AddDyn["Сжатие Хаффманом + Добавление в Dynamic Table (FIFO)"]
end
Go под капотом: golang.org/x/net/http2
В Go поддержка HTTP/2 встроена в net/http, но активна только при использовании TLS (ALPN negotiation: h2). Реализация вынесена в golang.org/x/net/http2.
Когда сервер на базе http.ListenAndServeTLS стартует, рантайм Go автоматически конфигурирует параметры протокола в структуре tls.Config. Во время TLS Client Hello клиент отправляет расширение ALPN (Application-Layer Protocol Negotiation) со списком поддерживаемых протоколов: ["h2", "http/1.1"]. Если сервер поддерживает h2, он выбирает его в Server Hello, и соединение немедленно переходит в бинарный режим без дополнительных сетевых задержек.
Ключевые структуры в рантайме Go:
http2.conn: Обертка надnet.Conn, содержитhttp2.framer,http2.writer,http2.streamMap. Управляет состоянием соединения и фоновыми горутинами чтения/записи.http2.stream: Представляет логический поток. Содержитhttp2.dataBuffer(для chunked чтения),http2.streamState,http2.headerFrame.http2.framer: Парсит входящие бинарные фреймы. Используетbytes.Readerдля zero-copy чтения заголовка.http2.streamMap:sync.RWMutex+map[uint32]*http2.streamдля быстрого поиска потоков.
// Пример внутренней логики создания потока в http2.server
func (s *conn) newStream(id uint32, writeState writeState) *stream {
// Аллокация stream. В Go это может уйти в кучу из-за escape analysis,
// если stream передается в интерфейс или замыкание.
st := &stream{
id: id,
state: streamActive,
data: new(dataBuffer),
headerBuf: &bytes.Buffer{}, // Буфер для декомпрессии HPACK
}
// Регистрация в мапе потоков
s.streams[id] = st
return st
}
Mechanical Sympathy и влияние на производительность
Переход на бинарный транспорт меняет профиль утилизации аппаратных ресурсов машины:
- CPU Cache & Parsing: Бинарные фреймы парсятся за O(1) без аллокаций строк. Заголовок фрейма (9 байт) помещается в один кэш-лайн L1. Это сокращает CPU cycles на парсинг на 40-60% по сравнению с HTTP/1.1. Процессор декодирует поля фрейма простыми битовыми сдвигами (
>>) и побитовыми масками (&), что исключает ветвления (branch prediction misses) в машинном коде. - GC Pressure: HPACK динамическая таблица создает постоянные аллокации (новые
hpack.Entry). При highload это увеличивает нагрузку на GC. Оптимизация: ограничение размера динамической таблицы (SETTINGS_HEADER_TABLE_SIZE) и использованиеsync.Poolдля временных буферов вframer. - Контекстные переключения: Мультиплексирование на одном TCP-сокете снижает количество системных вызовов
socket(),connect(),close()в ядре Linux. Это критично для сервисов с короткими запросами (microservices). Ядру не нужно держать тысячи сокетов вTIME_WAITи перегружать планировщик. - Escape Analysis:
http2.streamчасто аллоцируется в кучу, так как его жизнь может превышать время выполнения функции-обработчика (асинхронная обработка). Использованиеhttp2.Transportс пулом соединений (http2.ConfigureTransports) позволяет переиспользоватьhttp2.connи отложить GC.
Итог
- HTTP/2 заменил текстовый парсинг на бинарные фреймы, что ускорило обработку на уровне CPU.
- Мультиплексирование решает проблему HOL blocking приложения, но не отменяет TCP-ограничений.
- HPACK сжимает заголовки через статические/динамические таблицы и Huffman, но создает нагрузку на GC при высокой частоте уникальных заголовков.
- В Go реализация строится вокруг
golang.org/x/net/http2, гдеconn,streamиframerуправляют жизненным циклом потоков. - TLS + ALPN обязательны для автоматического апгрейда.
Мы завершили детальный разбор HTTP/2, который стал стандартом де-факто для внутреннего и внешнего API. Однако мультиплексирование все еще привязано к TCP, что сохраняет проблему Head-of-Line blocking на транспортном уровне. В следующей статье мы рассмотрим, как протокол 23. HTTP 3 и QUIC. Почему будущее уходит от TCP устраняет эти ограничения на уровне ядра.