Откройте любую статью с обзором HTTP/1.1. Скорее всего, там найдётся хотя бы один пример запроса и ответа, допустим, такие:
GET / HTTP/1.1
Host: localhost
HTTP/1.1 200 OK
Date: Sat, 09 Oct 2010 14:28:02 GMT
Server: Apache
Content-Length: 38
Content-Type: text/html; charset=utf-8
<!DOCTYPE html>
<h1>Привет!</h1>
Теперь откройте статью с обзором HTTP/2 или HTTP/3. Вы узнаете о мультиплексировании запросов, о сжатии заголовков, о поддержке push-технологий, но вряд ли увидите хоть одно конкретное сообщение. Ясно, почему так: HTTP/1.1 — текстовый протокол, тогда как сиквелы к нему бинарные. Это очевидное изменение открывает дорогу ко множеству оптимизаций, но упраздняет возможность просто и доступно записать сообщения.
Поэтому в этой статье предлагаю покопаться в кишках у HTTP/2: разобрать алгоритмы установки соединения, формат кадров, примеры взаимодействия клиента с сервером.
Статья рассчитана как на давно знакомых с HTTP, так и начинающих фронтендеров недавно изучивших HTTP/1.1, и пытающихся осознать, что там с HTTP/2. Естественно, я не могу описывать все подробности, поэтому новичкам в сетевых технологиях для лучшего понимания материала предлагается ознакомиться хотя бы с тем, как работает стек TCP/IP. Для этого очень рекомендую плейлист Networking tutorial на канале Ben Eater. Кроме того, как увидим далее, HTTP/2 на практике работает только поверх защищённого соединения (https), поэтому рекомендуется что-то в общих чертах понимать про TLS.
Спецификация HTTP/2 описана в RFC 9113 — здесь нужно искать все подробности о протоколе. Также пару раз я буду ссылаться на устаревшую спецификацию — RFC 7540. Она не актуальна, но для исторической справки бывает полезна. Плюс на ней были основаны большинство реализаций серверов, так что если вам доведётся с ними работать, возможно, пересечётесь с какими-нибудь отголосками прошлого.
Немножко терминологии
Далее везде
- Клиент — программа, реализующая клиентскую часть протокола.
- Сервер — программа, реализующая серверную часть протокола.
- Стороны — клиент и/или сервер.
- Собеседник — когда речь идёт о клиенте, это сервер; когда о сервере — клиент.
Кроме того, все числа с приставкой 0x
— шестнадцатеричные, с 0b
— двоичные, без приставки — десятичные, кроме блоков с представлением кадров в памяти. Также в блоках кода #
отделяет комментарии.
Вспомним былое
Что значит фраза "HTTP/1.1 — текстовый протокол"? То, что все служебные данные являются простыми строками. Клиент, отправляя запрос, записывает метод, путь и заголовки в виде строки ASCII в своей памяти, и затем отправляет по сети получившиеся байты. Например, запрос из начала статьи в памяти и при передаче по сети, выглядит так:
47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 # GET / HTTP/1.1
0d 0a # \r\n
48 6f 73 74 3a 20 6c 6f 63 61 6c 68 6f 73 74 # Host: localhost
0d 0a 0d 0a # \r\n\r\n
Тело же сообщения — просто какой-то набор байтов, а то, как их следует интерпретировать, указано в заголовке Content-Type
. Например, в ответе из начала статьи указано Content-Type: text/html; charset=utf-8
. Это значит, что тело представляет собой разметку html, передаваемую в кодировке UTF-8. Если не указать атрибут charset
, браузеру ничего не останется, кроме как попытаться угадать кодировку самостоятельно (спойлер: вряд ли угадает).
# Строка ответа и заголовки в ASCII
48 54 54 50 2f 31 2e 31 20 32 30 30 20 4f 4b # HTTP/1.1 200 OK
0d 0a # \r\n
44 61 74 65 3a 20 53 61 74 2c 20 30 39 20 4f 63 74 20 # Date: ...
32 30 31 30 20 31 34 3a 32 38 3a 30 32 20 47 4d 54
0d 0a # \r\n
53 65 72 76 65 72 3a 20 41 70 61 63 68 65 # Server: Apache
0d 0a # \r\n
43 6f 6e 74 65 6e 74 2d 4c 65 6e 67 74 68 3a 20 33 38 # Content-Length: 38
0d 0a # \r\n
43 6f 6e 74 65 6e 74 2d 54 79 70 65 3a 20 # Content-Type:
74 65 78 74 2f 68 74 6d 6c 3b 20 # text/html;
63 68 61 72 73 65 74 3d 75 74 66 2d 38 # charset=utf-8
0d 0a 0d 0a # \r\n\r\n
# Заголовки закончились, дальше идёт тело в UTF-8 (как указано в Content-Type)
3c 21 44 4f 43 54 59 50 45 20 68 74 6d 6c 3e 0a # <!DOCTYPE html>\n
3c 68 31 3e d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82 21 3c 2f 68 31 3e # <h1>Привет!</h1>
Можно обратить внимание, что в теле 32 символа (включая один перевод строки), но в заголовках написано Content-Length: 38
. Это связано с тем, что Content-Length
содержит длину тела в октетах, а не символах. А так как тело записано в UTF-8, некоторые символы (а именно 6 кириллических в слове Привет
) занимают больше одного октета. То есть HTTP вообще не имеет особого понятия, что написано в теле, с точки зрения протокола это просто какой-то набор байтов. Смысл в этот набор вкладывают приложения (клиент и сервер) с помощью подсказок в заголовках.
HTTP/2
Перейдём к виновнику торжества. Развлекательная программа следующая: сначала рассмотрим основные абстракции, которыми оперирует HTTP/2. Затем посмотрим на то, какие существуют кадры, и какое у них назначение. В заключение, разберём, как происходит обмен сообщениями.
Абстрактные основы
В HTTP/1.1 по большому счёту была одна сущность — сообщение. Клиент и сервер обменивались сообщениями, которые бывали двух видов: запросы и ответы. Внутри TCP-соединения они чередовались: клиент отправляет запрос, ждёт на него ответ, затем отправляет следующий.
Одной из задач HTTP/2 же стало мультиплексировать запросы, то есть позволить клиенту отправить второе сообщение-запрос по тому же TCP-соединению, не дожидаясь ответа на первый. В такой схеме клиенту при получении сообщения от сервера нужно как-то узнать, на какой из запросов это ответ. Очевидный способ — клиенту генерировать для каждого сообщения идентификатор и, например, отправлять его в условном заголовке Request-Id
, а серверу указывать этот же идентификатор в заголовках ответа. Однако есть ещё одна проблема: сейчас мы отправляем каждое сообщение целиком. Если первый запрос уже начал отправляться, то второй должен будет дождаться конца передачи. Если первый запрос очень большой и менее приоритетный, чем второй, получается неловкая ситуация. Хотелось бы иметь возможность дробить сообщения на более мелкие куски, тогда можно было бы, чередуя их, отправлять несколько сообщений параллельно.
Из этих соображений в HTTP/2 возникают следующие абстракции:
- Сообщения (messages) — запросы и ответы, всё как в HTTP/1.1. Состоят из заголовков и (необязательно) тела и трейлеров. Дробятся на более мелкие составные части — кадры.
- Кадр (frame) — простейшая единица обмена информацией, передаются целиком (нельзя начать передавать второй кадр, пока полностью не передался один). Бывают разных типов, в зависимости от чего чуть отличается их содержимое. Есть служебные кадры, например, для управления соединением и потоками; есть кадры, из которых составляются сообщения.
Замечание для смышлёных: кадры HTTP никак не связаны с пакетами TCP. Один пакет может содержать несколько кадров, один кадр может начинаться и заканчиваться в разных пакетах.
- Поток (stream) — последовательность кадров, составляющих один запрос и ответ на него. Тогда как предыдущие абстракции имеют физическое представление (каждый кадр — это последовательность октетов1 в определённом формате, а сообщение — некоторый набор кадров), потоки существуют только в наших
сердцахмыслях. В каждом кадре есть поле с идентификатором потока, соответственно все кадры с одинаковым идентификатором считаются принадлежащими одному потоку.
Новый поток может создать как клиент (отправив запрос с идентификатором, который не использовался ранее), так и сервер (если используется server push). По одному потоку можно передать только одну пару сообщений: после того, как получено сообщение-ответ, поток считается закрытым, и по нему больше нельзя ничего передавать (то есть если одна из сторон получает кадр с идентификатором потока, который закрыт, она либо его игнорирует, либо закрывает соединение с ошибкой).
1 Так как по определению (по крайней мере изначальному) байт не обязательно состоит из восьми бит, а октет — обязательно, то при обсуждении сетевых протоколов принято использовать именно октет. И я буду, чем я хуже.
Раздел кадров
Каждый кадр условно делится на заголовок (header, не путать с заголовками сообщения) и содержимое (payload). Заголовок содержит общую информацию о кадре, и одинаковый у них всех, а содержимое уже зависит от конкретного типа. Формат такой:
HTTP Frame {
# Заголовок
Length (24),
Type (8),
Flags (8),
Reserved (1),
Stream Identifier (31),
# Содержимое
Frame Payload (..),
}
Здесь
-
Length
(24 бита/3 октета) — длина кадра в октетах, не включая заголовок. Читатель, слышавший что-то об арифметике, заметит, что длина заголовка — 9 октетов, так что длина всего кадра отличается от значенияLengh
ровно на 9. -
Type
(8 бит/1 октет) — тип кадра. Например, значение 0x00 значит, что это кадрDATA
, а 0x04 — кадрSETTINGS
. -
Flags
(8 бит/1 октет) — набор булевых флагов. Каждый из восьми бит может быть 0 или 1, получается восемь флагов, которые можно устанавливать независимо. Доступные флаги зависят от типа кадра, так что о них будем говорить, обсуждая конкретные типы. -
Reserved
(1 бит) — не используется, всегда равен 0. -
Stream Identifier
(31 бит/почти 4 октета) — идентификатор потока, которому принадлежит кадр. Чтобы создать новый поток, клиент отправляет кадр (определённого типа, увидим позже) со значениемStream Identifier
, которое не использовалось ранее. Чтобы отправить кадр по конкретному потоку, туда пишется соответствующий идентификатор. Есть кадры, которые относятся не к конкретному потоку, а к соединению в целом — для них используется зарезервированный идентификатор потока0
. -
Frame Payload
(Length
октетов) — содержимое, зависит от типа кадра.
Рассмотрим конкретные типы кадров и их назначения. В скобках после названия указан идентификатор типа (значение поля Type
в заголовке). Здесь приведены типы в порядке не по возрастанию идентификатора, а по зову сердца.
HEADERS (0x01) и CONTINUATION (0x09)
Вернёмся к сообщениям. В HTTP/1.1 они состояли из трёх основных частей: заголовков (включая строку запроса/ответа) и (необязательно) тела и трейлеров. В HTTP/2 части те же, но передаются с помощью двух типов кадров: HEADERS и DATA.
Сначала посмотрим на заголовки и трейлеры. Несмотря на название, и для того, и для того используется кадр HEADERS, который имеет такой формат, самый сложный из основных кадров:
HEADERS Frame {
Length (24),
Type (8) = 0x01,
Unused Flags (2),
PRIORITY Flag (1), # Устарело
Unused Flag (1),
PADDED Flag (1),
END_HEADERS Flag (1),
Unused Flag (1),
END_STREAM Flag (1),
Reserved (1),
Stream Identifier (31),
[Pad Length (8)],
[Exclusive (1)], # Устарело
[Stream Dependency (31)], # Устарело
[Weight (8)], # Устарело
Field Block Fragment (..),
Padding (..2040),
}
Первые 9 октетов — стандартный для всех кадров заголовок. Содержимое состоит из так называемого "блока заголовков" (header block, здесь уже речь о заголовках сообщения, а не кадра) и дополнения (padding). Вообще говоря, в новой спецификации используется термин "блок полей" (field block), а не "блок заголовков". Это связано как раз с тем, что HEADERS используется для передачи не только заголовков, но и трейлеров, поэтому говорить "блок заголовков" не совсем корректно. Но, я считаю, название "блок полей" может сбить с толку, поэтому дальше везде буду говорить только о заголовках и подразумевать, что всё что здесь описано, применимо и к трейлерам.
Флаг PRIORITY, как и значения Exclusive, Stream Dependency и Weight в содержимом, — останки механизма приоритизации потоков, который был предложен в первой итерации HTTP/2. На практике он оказался довольно сложным, и большинство клиентов и серверов либо вообще его не реализовали, либо реализовали очень неоптимально. Поэтому этот механизм и все сопутствующие части кадров в новой спецификации были признаны устаревшими, и предложен новый механизм приоритизации на основе заголовков и специальных кадров PRIORITY_UPDATE, который описан отдельно (RFC 9218). Его разбирать здесь не будем.
Если установлен флаг PADDED, первые 8 бит/1 октет в содержимом указывают длину дополнения (Pad Length), а само дополнение идёт в конце кадра, после заголовков. Дополнение представляет собой просто Pad Length
нулевых октетов. Оно используется в качестве меры безопасности в дополнение к шифрованию — если нужно скрыть истинную длину заголовков.
Гвоздь кадра — блок заголовков. Заголовки передаются в сжатом с помощью алгоритма HPACK (RFC 7541) виде. Алгоритм довольно хитрый, так что подробно разбирать здесь его не будем. Вместо этого в примерах я буду записывать заголовки и трейлеры самым простым (по совместительству неэффективным) способом, каким позволяет HPACK.
У меня есть сомнения, можно ли кодировать заголовки, для которых есть сокращённые формы записи (например, accept
в HPACK, вообще говоря, можно кодировать всего одним октетом 0x13 плюс сколько-то байт на значение), не используя эти сокращённые формы, то есть полностью выписывая название заголовка в ASCII.
В спецификации HPACK я не нашёл раздела, где такой способ бы был запрещен или хотя бы не рекомендован. Тем не менее, судя по всему, некоторые реализации его не поддерживают. В моих тестах самописный HTTP/2 сервер на NodeJS (использующий встроенную библиотеку node:http2, которая, в свою очередь, использует C++ библиотеку nghttp2) вполне себе замечательно обрабатывает заголовки, записанные таким образом. А сервера гугла при попытке отправить заголовки в таком виде возвращают ошибку сжатия. Но пока меня не ткнут носом, где написано, что так кодировать нельзя, буду использовать эту простую схему.
Сначала пишется один нулевой октет. Далее один октет — длина названия заголовка (только старший бит длины должен быть 0, но это детали) и само название в ASCII, затем длина значения и само значение тоже в ASCII. Все названия заголовков в HTTP/2 пишутся в нижнем регистре. Рассмотрим конкретный пример кадра HEADERS для наглядности:
00 00 4b 01 # Длина содержимого 0x00 00 4b=75 октетов, тип HEADERS (0x01)
05 # Установлены флаги END_HEADERS и END_STREAM (6й и 8й биты, 0x05=0b0000 0101)
00 00 00 01 # Идентификатор потока 1
00 # Начинаются заголовки. Сначала нулевой октет
07 3a 6d 65 74 68 6f 64 # Длина названия 7 октетов, само название ":method" записано в ASCII
03 47 45 54 # Длина значения 3 октета, значение "GET"
00 # Начало второго заголовка
07 3a 73 63 68 65 6d 65 # Длина 7, название ":scheme"
05 68 74 74 70 73 # Длина 5, значение "https"
00
05 3a 70 61 74 68 # Название ":path"
01 2f # Значение "/"
00
0a 3a 61 75 74 68 6f 72 69 74 79 # Название ":authority"
09 6c 6f 63 61 6c 68 6f 73 74 # Значение "localhost"
00
04 68 6f 73 74 # Название "host"
09 6c 6f 63 61 6c 68 6f 73 74 # Значение "localhost"
Только что мы невольно лицезрели простейший запрос в HTTP/2, аналогичный запросу HTTP/1.1 из самого начала статьи. Сразу бросаются в глаза заголовки, начинающиеся с двоеточия. Это так называемые псевдозаголовки. Они содержат в том числе данные, которые в HTTP/1.1 передавались в строке запроса. Невероятно, но :method
— это метод, :path
— путь. :scheme
— схема URI, на который поступает запрос, обычно это https
(браузеры не поддерживают использование HTTP/2 по http, это ещё обсудим позднее), :authority
— имя сервера, на который поступает запрос, грубо говоря то, что в HTTP/1.1 указывалось в заголовке Host
. Заголовок host
однако, как видно в примере, в HTTP/2 тоже есть, только теперь не обязателен. Чем отличается от :authority
позволю себе здесь не обсуждать.
Псевдозаголовки чуть отличаются от обычных заголовков, например, они обязательно должны идти первыми. Кому интересны подробности, тот почитает спецификацию. Ответы тоже не обделили псевдозаголовками, правда, он всего один: :status
— код статуса. Простейший пример ответа в HTTP/2:
00 00 0d 01 # Длина содержимого 0x0d=13, тип кадра HEADERS
05 00 00 00 01 # Флаги END_HEADERS и END_STREAM, идентификатор потока 1
00
07 3a 73 74 61 74 75 73 # Псевдозаголовок ":status"
03 32 30 34 # Значение "204"
Постоянно писать байты не удобно, поэтому кадры будем записывать в более простом виде, указывая только тип, установленные флаги и содержимое. После типа кадра через черту будем писать номер потока, по которому кадр отправляется:
# Запрос
HEADERS/1
+ END_HEADERS
+ END_STREAM
:method = GET
:scheme = https
:path = /
:authority = localhost
host = localhost
# Ответ
HEADERS/1
+ END_HEADERS
+ END_STREAM
:status = 204
Вот и вышел простейший обмен сообщениями (предполагая, что стороны уже обменялись всеми необходимыми рукопожатиями. Конкретно о том, что должно произойти до обмена сообщениями, речь пойдёт позже).
Осталось что-то сказать про флаги END_HEADERS и END_STREAM. Бывают ситуации (например, большие файлы куки), когда заголовков нужно отправить очень много, и они не влезают в максимально разрешённый размер кадра (такой есть, им можно управлять с помощью кадров SETTINGS). В таком случае можно разбить их на несколько частей. Первую часть отправить в кадре HEADERS, а оставшиеся в одном или нескольких кадрах CONTINUATION, которые выглядят почти также, только проще:
CONTINUATION Frame {
Length (24),
Type (8) = 0x09,
Unused Flags (5),
END_HEADERS Flag (1),
Unused Flags (2),
Reserved (1),
Stream Identifier (31),
Field Block Fragment (..),
}
Флаг END_HEADERS нужен, чтобы обозначить, где заканчиваются заголовки. Он устанавливается на последнем кадре CONTINUATION, либо на самом кадре HEADERS, если CONTINUATION не требуются. Стоит отметить, что кадр HEADERS и соответствующие CONTINUATION должны идти подряд: между ними не может быть других кадров, даже из других потоков. Это связано с особенностями HPACK, которые, возможно, обсудим в другой раз.
Флаг END_STREAM обозначает последний кадр сообщения. Так как по одному потоку можно отправить только одно сообщение с каждой стороны, то после отправки такого кадра сторона больше не может передавать по потоку данные. Если сообщение состоит только из заголовков, этот флаг стоит на первом кадре HEADERS; если из заголовков и тела — на последнем кадре DATA; если из заголовков, тела и трейлеров — на последнем кадре HEADERS, который содержит трейлеры.
DATA (0x00)
Используется для передачи тела сообщения. Формат простой:
DATA Frame {
Length (24),
Type (8) = 0x00,
Unused Flags (4),
PADDED Flag (1),
Unused Flags (2),
END_STREAM Flag (1),
Reserved (1),
Stream Identifier (31),
[Pad Length (8)],
Data (..),
Padding (..2040),
}
Ситуация с дополнением (padding) точно такая же, как в кадрах HEADERS. С END_STREAM уже разобрались, поэтому без лишних слов посмотрим на более сложный пример сообщения.
HEADERS/1
+ PADDED
:status = 200
date = Sat, 09 Oct 2010 14:28:02 GMT
server = apache
<4 октета дополнения>
CONTINUAION/1
+ END_HEADERS
content-length = 38
content-type = text/html; charset=utf-8
DATA/1
<!DOCTYPE html>\n
DATA/1
+ PADDED
+ END_STREAM
<h1>Привет!</h1>
<8 октетов дополнения>
# Первый кадр
00 00 45 01 # Длина содержимого 0x45=73 октета, тип HEADERS
08 00 00 00 01 # Флаг PADDED, поток 1
04 # Длина дополнения 4 октета
00
07 3a 73 74 61 74 75 73 # Псевдозаголовок ":status"
03 32 30 30 # Значение "200"
00
04 64 61 74 65 # Заголовок "date", значение "Sat, 09 Oct...."
1d 53 61 74 2c 20 30 39 20 4f 63 74 20 32 30
31 30 20 31 34 3a 32 38 3a 30 32 20 47 4d 54
00
06 73 65 72 76 65 72 # Заголовок "server"
06 61 70 61 63 68 65 # Значение "apache"
00 00 00 00 # 4 октета дополнения (padding)
# Второй кадр
00 00 3a 09 # Длина содержимого 0x3a=58 октетов, тип CONTINUATION
04 00 00 00 01 # Флаг END_HEADERS, поток 1
00
0e 63 6f 6e 74 65 6e 74 2d 6c 65 6e 67 74 68 # Заголовок "content-length"
02 33 38 # Значение "38"
00
0c 63 6f 6e 74 65 6e 74 2d 74 79 70 65 # Заголовок "content-type",
18 74 65 78 74 2f 68 74 6d 6c 3b 20 63 # Значение "text/html; charset=utf-8"
68 61 72 73 65 74 3d 75 74 66 2d 38
# Третий кадр
00 00 10 00 # Длина содержимого 0x10=16 октетов, тип DATA
00 00 00 00 01 # Флагов нет, поток 1
3c 21 44 4f 43 54 59 50 # Строка "<!DOCTYPE html>\n" в UTF-8
45 20 68 74 6d 6c 3e 0a
# Четвёртый кадр
00 00 1f 00 # Длина содержимого 0x1f=31 октет, тип DATA
09 00 00 00 01 # Флаги PADDED и END_STREAM, поток 1
08 # Длина дополнения 8 октетов
3c 68 31 3e d0 9f d1 80 d0 b8 d0 # Строка "<h1>Привет!</h1>" в UTF-8
b2 d0 b5 d1 82 21 3c 2f 68 31 3e
00 00 00 00 00 00 00 00 # 8 октетов дополнения
Это сообщение, аналогичное примеру ответа из начала статьи. Только я разбил заголовки и тело на несколько кадров как мне вздумалось, чтобы жизнь мёдом не казалась.
SETTINGS (0x04)
Используется для установки параметров соединения. Формат такой:
SETTINGS Frame {
Length (24),
Type (8) = 0x04,
Unused Flags (7),
ACK Flag (1),
Reserved (1),
Stream Identifier (31) = 0,
Setting (48) ...,
}
Setting {
Identifier (16),
Value (32),
}
Потоки не имеют настраиваемых параметров, SETTINGS применяется только к соединению в целом, так что идентификатор потока всегда 0. В содержимом перечисляются параметры, которые нужно изменить. Каждый параметр занимает 48 бит/6 октетов, из которых 2 октета — идентификатор, а остальные 4 — значение. Рассмотрим некоторые параметры в качестве примера. Список не полный, полный список и ссылки на спецификации, где параметры описаны, нужно искать на сайте IANA.
- SETTINGS_ENABLE_PUSH (идентификатор 0x02) — клиент может установить значение 0, чтобы запретить использование server push. Эту технологию оказалось довольно сложно использовать с толком, поэтому, например, хромиум всегда так делает.
- SETTINGS_MAX_CONCURRENT_STREAMS (0x03) — максимальное количество потоков, которое собеседнику разрешается использовать одновременно. Считаются только открытые потоки, то есть по которым что-то ещё передаётся. Можно указать значение 0: тогда собеседник не сможет отправлять новые сообщения, пока сторона его снова не увеличит.
- SETTINGS_INITIAL_WINDOW_SIZE (0x04) — элемент управления потоком (flow control). Подробнее в разделе про кадры WINDOW_SIZE.
- SETTINGS_MAX_FRAME_SIZE (0x05) — максимальный размер кадра в октетах, который собеседнику разрешено отправлять. Значение по умолчанию, оно же минимальное — 16 384=214 октетов.
- SETTINGS_NO_RFC7540_PRIORITIES (0x09) — можно установить значение 1, чтобы запретить использование той самой неудачной системы приоритизации из первой итерации HTTP/2.
Часто важно знать, получил и применил ли собеседник их новые значения. Для этого каждая сторона после получения SETTINGS отправляет в ответ ещё один такой кадр, на котором установлен единственный доступный для этого типа флаг ACK (acknowledged). Например, сервер отправил кадр SETTINGS и указал, что максимальное количество потоков, которые он готов одновременно обрабатывать (SETTINGS_MAX_CONCURRENT_STREAMS), равно 100. Как только клиент его получит, он должен запомнить новое значение параметра, и отправить в ответ пустой кадр SETTINGS с флагом ACK. Пока сервер не получил это подтверждение, он не может быть уверен, что клиент узнал о новом значении параметра, поэтому не может рассчитывать, что клиент не откроет больше 100 потоков.
Пример такого обмена:
# Сервер отправляет клиенту
SETTINGS/0
SETTINGS_ENABLE_PUSH = 0
SETTINGS_MAX_CONCURRENT_STREAMS = 100
# Клиент отправляет в ответ
SETTINGS/0
+ ACK
# Сервер клиенту
00 00 0c 04 # Длина содержимого 0x0c=12 октетов, тип SETTINGS
00 00 00 00 00 # Флагов нет, поток 0
00 02 00 00 00 00 # Параметру SETTINGS_ENABLE_PUSH (идентификатор 0x00 02) установить значение 0
00 03 00 00 00 64 # Параметру SETTINGS_MAX_CONCURRENT_STREAMS (0x00 03) установить значение 100 (0x64)
# Клиент серверу
00 00 00 04 # Длина содержимого 0 (пустой кадр), тип SETTINGS
01 00 00 00 00 # Флаг ACK, поток 0
PING (0x06)
Пока не происходит обмена сообщениями, клиент и сервер периодически обмениваются такими кадрами, чтобы проверить, не закрылось ли соединение, жив ли собеседник. Также с помощью них можно оценивать круговую задержку (round-trip time): сколько времени нужно, чтобы отправить кадр и получить на него ответ. Формат:
PING Frame {
Length (24) = 0x08,
Type (8) = 0x06,
Unused Flags (7),
ACK Flag (1),
Reserved (1),
Stream Identifier (31) = 0,
Opaque Data (64),
}
Содержимое состоит из 64 бит/8 октетов произвольных данных (Opaque Data). Сами по себе они ничего не значат, но если одна из сторон получает кадр PING, она должна отправить в ответ ещё один PING с флагом ACK и продублировать содержимое.
# Клиент отправляет серверу
00 00 08 06 # Длина содержимого 8 октетов, тип PING
00 00 00 00 00 # Флагов нет, поток 0
2f b0 7a ee # 8 произвольных, ничего не значащих октетов
92 01 8a bc
# Сервер отвечает
00 00 08 06 # Длина содержимого 8 октетов, тип PING
01 00 00 00 00 # Флаг ACK, поток 0
2f b0 7a ee # Продублировано из предыдущего кадра
92 01 8a bc
WINDOW_UPDATE (0x08)
Во многих сетевых протоколах, в том числе TCP и HTTP, обработка данных происходит не мгновенно по прибытии. Операционная система, получая сетевые пакеты, складывает их в некоторый буфер, из которого приложение потом может их считать сразу или через какое-то время. Из-за этого возникает необходимость в некотором механизме управления потоком (flow control): нельзя отправлять слишком много данных сразу — если они обрабатываются медленно, у собеседника может банально не хватить памяти всё сохранить для последующей обработки.
Самые большие кадры в HTTP — это кадры DATA, поэтому flow control применяется только к ним. Все остальные кадры обычно не слишком большие, поэтому обрабатываются сразу. Разве что HEADERS могут быть приличного размера, но их обработку очень нежелательно откладывать в том числе из-за пресловутых особенностей HPACK. За то, чтобы не отправить слишком много заголовков, отвечает механизм flow control, который уже есть в TCP.
У каждого потока и у соединения в целом есть "размер окна" (window size) — максимальное количество октетов, которые собеседнику разрешено отправить в данный момент на данном потоке/соединении. С каждым полученным кадром DATA размер окна уменьшается на размер его содержимого. Если отправить слишком много, размер окна станет слишком маленьким, и сторона откажется принимать новые кадры. Размер окна свой у клиента и у сервера.
Когда же сторона обработает полученные ранее данные и решит, что готова получать новые, она отправит кадр WINDOW_UPDATE, в котором увеличит свой размер окна, и позволит собеседнику отправлять новые кадры. Формат такой:
WINDOW_UPDATE Frame {
Length (24) = 0x04,
Type (8) = 0x08,
Unused Flags (8),
Reserved (1),
Stream Identifier (31),
Reserved (1),
Window Size Increment (31),
}
Тело состоит из 4 октетов (только старший бит не используется), которые указывают, на сколько надо увеличить размер окна.
Изначальный размер окна задаётся параметром SETTINGS_INITIAL_WINDOW_SIZE, по умолчанию 65 535 октетов. Предположим, сервер отправил кадр размером 65 500 октетов:
00 ff dc 00 # Размер 0xff dc=65 500 октетов, тип DATA
01 00 00 00 01 # Флаг END_STREAM (но не END_HEADERS), поток 1
1a 2b 3c 4d... # Куча данных на 65 500 октетов
И теперь хочет отправить ещё столько же. Но размер окна сервера только что уменьшился на 65 500 и стал 35 октетов, то есть ещё один такой же кадр не поместится. Поэтому сервер не может отправить его сразу же, он должен подождать некоторое время. Когда клиент получит первый кадр и обработает его, он отправит, например, такой WINDOW_UPDATE:
00 00 04 08
00 00 00 00 01
00 00 ff ff
Таким образом, увеличив размер окна для потока 1 ещё на 65 535 октетов. То есть после отправки этого кадра размер окна клиента станет 65 570 октетов. Rогда сервер получит этот WINDOW_UPDATE, он узнает, что размер окна клиента увеличился, и сможет отправить следующий кадр DATA.
Ещё замечание юридического характера: я не нахожу в спецификации места, где было бы оговорено, нужно ли увеличивать в этом сценарии размер окна для соединения в целом и для потока 1 двумя отдельными WINDOW_UPDATE, или же увеличение окна потока 1 автоматически увеличивает окно всего соединения. В моих тестах с самодельным HTTP/2 сервером на NodeJS и серверами гугла выглядит, как будто достаточно одного кадра, который увеличивает окно только для потока 1.
RST_STREAM (0x03) и GOAWAY (0x07)
Эти кадры позволяют преждевременно закрыть поток/соединение. Например, если клиент на каком-то потоке отправил кадр с ошибкой (не соответствующий спецификации HTTP/2), сервер скорее всего закроет этот поток с помощью RST_STREAM (по-русски reset stream), в котором укажет код ошибки. Либо, например, пока сервер отправляет ответ на запрос, клиент решил, что он больше не нужен (скажем, пользователь отменил загрузку картинки в вотсапе). Тогда он может остановить передачу ответа с помощью RST_STREAM. В общем, причин закрывать поток может быть множество.
Аналогично с GOAWAY: он служит для закрытия соединения либо в случае ошибки, либо просто по инициативе одной из сторон. В кадре GOAWAY содержится самый большой идентификатор потока, который отправитель смог получить и каким-то образом обработать — Last-Stream-Id. Таким образом, получатель GOAWAY узнаёт, какие сообщения нужно будет отправлять заново, а какие нет.
RST_STREAM Frame {
Length (24) = 0x04,
Type (8) = 0x03,
Unused Flags (8),
Reserved (1),
Stream Identifier (31),
Error Code (32),
}
GOAWAY Frame {
Length (24),
Type (8) = 0x07,
Unused Flags (8),
Reserved (1),
Stream Identifier (31) = 0,
Reserved (1),
Last-Stream-ID (31),
Error Code (32),
Additional Debug Data (..),
}
Возможные коды ошибок и спецификации, в которых они определены, надо вновь искать на страничке IANA.
Additional Debug Data — произвольны данные, которые могут помочь при отладке ошибок. Обычно это какая-нибудь строка в ASCII с более подробным описанием ошибки, чем позволяет Error Code, но в принципе там может быть что угодно.
Пример: клиент закончил передачу запроса на потоке 5, отправив все необходимые кадры и установив в последнем флаг END_STREAM, а затем пытается отправить ещё один кадр HEADERS на том же потоке 5. Сервер закрывает его с ошибкой:
00 00 04 03 # Длина содержимого 4 октета, тип RST_STREAM
00 00 00 00 05 # Флагов нет, поток 5
00 00 00 05 # Ошибка 0x05 — STREAM_CLOSED
Ещё пример: на клиенте некорректно реализован алгоритм HPACK, и сервер не может понять заголовки в запросе из потока 7. В связи с очередными особенностями HPACK, в этом случае сервер должен закрыть соединение. При этом запросы, которые ранее приходили на потоках 1 и 3 уже обработаны, и на них отправлены ответы, а на потоке 5 сообщение начало обрабатываться, но не закончило, и ответ ещё даже не сформирован. Тогда сервер отправляет такой кадр:
00 00 14 07 # Длина содержимого 0x14=20 октетов, кадр GOAWAY
00 00 00 00 00 # Флагов нет, поток 0
00 00 00 05 # Последний обработанный поток — пятый
00 00 00 09 # Ошибка 0x09 — COMPRESSION_ERROR
68 70 61 63 6b 5f 65 72 72 6f 72 0a # Строка "hpack_error" для отладки, записанная в ASCII
После этого он закрывает TCP-соединение, но может продолжить обработку запроса из потока 5 (скажем, это был запрос на перевод денег, который нельзя просто оборвать посередине, чтобы база данных не оказалось в невалидном состоянии).
На этом завершим обзор кадров. Необсуждёнными из спецификации HTTP/2 остались только кадры PRIORITY (устаревшие останки вышеупомянутой системы приоризитации) и PUSH_PROMISE (используется для server push. Позволю себе пропустить его обсуждение, так как технология на данный момент не слишком востребованная, да и интересных подробностей нет: просто сервер отправляет PUSH_PROMISE с описанием запроса, на который он хочет без спросу выдать ответ, а затем сам ответ). Кроме того, упустили кадры, которые были введены в отдельных RFC. Полным списком кадров со ссылками в очередной раз предлагаю искать на страничке IANA.
Как завязать общение с понравившимся сервером
Обсудили кусочки, из которых составляется обмен данными в HTTP/2. Теперь посмотрим на соединение с высоты: как оно начинается и живёт.
Сервера HTTP/1.1 и HTTP/2 по умолчанию используют один и тот же порт — 443. Передаваемые данные у протоколов сильно отличаются, поэтому если клиент не знает заранее, какую версию поддерживает сервер, нужен какой-то механизм, как её выбрать.
Изначально (в старой спецификации) таких было два: один для защищённых соединений (тогда протокол называется HTTP/2 over TLS, сокращённо h2), один для незащищённых (HTTP/2 over cleartext TCP, сокращённо h2c). Однако большинство браузеров в принципе не стали поддерживать возможность использовать h2c, поэтому второй механизм в новой спецификации был признан устаревшим. Но для исторической справки и полноты картины описание, как это должно было работать, приведу в спойлере.
В случае защищённого соединения (с HTTP over TLS, он же h2, схема https) идея очень простая, но только если вы знаете TLS. Этот протокол помимо своей основной цели (шифрование трафика) поддерживает множество расширений — дополнительных полей, которыми сервер и клиент могут обменяться во время установки зашифрованного соединения. Одно из них — ALPN (Application-Level Protocol Negotation, согласование протокола прикладного уровня). Клиент отправляет список поддерживаемых протоколов (например, http/1.1
и h2
), а сервер в ответе выбирает, какой из них предпочтителен. К сожалению, разбор TLS по байтам в этой статье не предусмотрен, так что как именно отправляется информация в ALPN как-нибудь в другой раз. Но суть в том, что сервер, увидев, что клиент поддерживает h2
, просто сразу с помощью того же ALPN сообщает "будем использовать HTTP/2".
Так как TLS в этом случае не используется, а в TCP расширения ALPN не предусмотрено, выбор протокола происходил бы на уровне HTTP. Если бы клиент заранее знал, что сервер поддерживает HTTP/2 (например, вы ручками указали в настройках, либо уже подключались к этому серверу ранее, и браузер сохранил версию протокола в кеш), то клиент сразу бы отправил свою преамбулу, как описано ниже, по которой сервер бы понял, что надо использовать HTTP/2. Если бы клиент ошибся (думал, что сервер поддерживает HTTP/2, а на самом деле нет), сервер бы просто закрыл соединение, не поняв, что от него требуется.
Иначе же, сначала клиент отправил бы запрос HTTP/1.1, в котором, среди прочего, указал заголовки Upgrade
и HTTP2-Settings
:
GET /hot-asian-girls.php HTTP/1.1
Host: example.com
Accept: text/html
Accept-Language: en
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: ...
Значением Upgrade
являлся бы идентификатор протокола HTTP/2 over cleartext TCP, то есть h2c
. А HTTP2-Settings
служил бы, чтобы сразу установить параметры соединения: в нём бы та же последовательность, что и в содержимом кадра SETTINGS, только закодированная в base64. Если бы сервер не поддерживал HTTP/2, он бы просто отправил ответ, как будто это обычный запрос HTTP/1.1. Иначе, он бы отправил ответ со статусом 101:
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: h2c
За ним сразу последовала бы преамбула сервера и ответ на запрос клиента. Но сейчас вся эта схема считается устаревшей, и не поддерживается практически ни одним браузером. Здесь приведена только в качестве исторической справки.
Итак, соединение установили, протокол выбрали, пора бы и чем-нибудь содержательным обменяться.
Что-нибудь содержательное
Первым делом, клиент сразу же отправляет серверу так называемую преамбулу (connection preface). Первая её часть — это фиксированная последовательность октетов
50 52 49 20 2a 20 48 54 54 50 2f 32
2e 30 0d 0a 0d 0a 53 4d 0d 0a 0d 0a
Если интерпретировать эти октеты как последовательность кодов символов ASCII, получится следующая строка. Если закрыть глаза, она чем-то напоминает начало запроса HTTP/1.1.
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
Кто читал спойлер про незащищённое соединение, тому будет понятен смысл. Если бы протокол использовался без TLS, и клиент заранее знал, что сервер поддерживает HTTP/2, то эта строка служила бы индикатором для сервера, что клиент собирается использовать HTTP/2. Если же сервер поддерживал бы только HTTP/1.1, он бы распарсил её как запрос с невалидным методом, неправильной версией, и вообще чёрт знает чем. Тогда бы он просто закрыл соединение, и клиент бы понял, что ошибся с протоколом.
При использовании TLS важность этой строки теряется, поскольку выбор протокола происходит уже на этапе TLS-рукопожатия. Но по историческим причинам, и как дополнительное подтверждение выбора HTTP/2, она остаётся частью протокола.
Вторая часть — (возможно, пустой) кадр SETTINGS с изначальными параметрами соединения. После своей преамбулы клиент может сразу начать отправлять запросы, не дожидаясь преамбулы сервера. А у сервера она тоже есть, правда состоит всего из одного (опять же, возможно, пустого) кадра SETTINGS.
Сообщение-запрос выглядит так:
- Один кадр HEADERS, в котором указывается идентификатор потока, который не использовался ранее. Идентификаторы выбираются обязательно по возрастанию, причём клиент может открывать только потоки с нечётными номерами. Это нужно, потому что сервер тоже может открывать новые потоки (с помощью кадров PUSH_PROMISE при использовании server push) — он будет выбирать только чётные номера. Таким образом не возникает коллизий.
Если все заголовки поместились в HEADERS, у него должен быть флаг END_HEADERS. Иначе за HEADERS могут следовать сколько угодно кадров CONTINUATION, и флаг выставляется на последнем. Если у запроса нет тела, то у HEADERS должен быть флаг END_STREAM.
- Возможно, один или больше кадров DATA, содержащих тело запроса. Если нет трейлеров, на последнем должен быть флаг END_STREAM.
- Возможно, ещё один кадр HEADERS, содержащий трейлеры. Если он есть, то должен быть с флагом END_STREAM. Если трейлеров много, далее также могут следовать кадры CONTINUATION. В этом случае на последнем должен быть флаг END_HEADERS, иначе он должен быть на самом HEADERS. Список трейлеров, так же как и в HTTP/1.1, отправляется в заголовке `Trailers`.
В кадре HEADERS запроса первыми обязательно должны быть псевдозаголовки :method
, :path
и :scheme
(кроме запросов с методом CONNECT
, но это детали); псевдозаголовок :authority
может отсутствовать.
После того, как сервер получил на потоке кадр с флагом END_STREAM, он может начинать отправлять ответ. Формат сообщения-ответа точно такой же, как и у запроса, только идентификатор потока указывается не новый, а тот, на котором пришёл запрос; и вместо :method
, :path
, :scheme
и :authority
, единственный псевдозаголовок, который ответ может и обязан содержать — это :status
.
Когда размер окна одной из сторон подходит к концу, она увеличивает его кадром WINDOW_UPDATE. Пока никакие сообщения не отправляются, стороны периодически обмениваются кадрами PING. В любой момент можно изменить параметры соединения кадром SETTINGS. Любая из сторон может закрыть любой поток кадром RST_STREAM. HTTP-соединение прерывается, когда закрывается TCP-соединение. Перед тем как закрыть TCP-соединение, сторона может отправить кадр GOAWAY с объяснением причин (вообще говоря, не обязана, но рекомендуется).
Вот так и живём. Основные детали протокола разобрали, под конец приведу содержательный пример обмена сообщениями. Домашнее задание: написать простой клиент и сервер HTTP/2 на любимом языке.
Предполагается, что TCP-рукопожатие уже произошло, TLS-рукопожатие тоже, и HTTP/2 начал действовать. Слева записаны кадры, которые отправляет клиент, справа — сервер. Кадры указаны сверху вниз в порядке, в котором они отправляются. Ещё домашнее задание: расписать этот обмен кадрами по байтам.
PRI * HTTP/2.0\r\n\r\n |
SM\r\n\r\n | SETTINGS/0
| SETTINGS_MAX_CONCURRENT_STREAMS = 100
SETTINGS/0 |
SETTINGS_ENABLE_PUSH = 0 |
SETTINGS_NO_RFC7540_PRIORITIES = 1 |
|
HEADERS/1 |
+ END_STREAM | SETTINGS/0
:method = GET | + ACK
:scheme = https |
:path = / |
:authority = example.com |
accept = text/html |
accept-language = ru |
user-agent = My-Browser/1.0 |
|
CONTINUATION/1 |
+ END_HEADERS |
cookie = <2048 октетов> |
|
SETTINGS/0 |
+ ACK | HEADERS/1
| + END_HEADERS
HEADERS/3 | + END_STREAM
+ END_HEADERS | :status = 302
+ END_STREAM | location = https://example.com/ru
:method = GET | content-length = 0
:scheme = https | vary = accept-language
:path = /favicon.ico |
:authority = example.com |
accept = image/webp |
user-agent = My-Browser/1.0 |
|
HEADERS/5 |
+ END_HEADERS |
+ END_STREAM | HEADERS/3
:method = GET | + END_HEADERS
:scheme = https | :status = 200
:path = /ru | content-length = 68792
:authority = text/html | content-type = image/webp
accept-language = ru |
user-agent = My-Browser/1.0 | DATA/3
cookie = <2048 октетов> | <65535 октетов>
|
| HEADERS/5
WINDOW_UPDATE/3 | + END_HEADERS
Window Size Increment = 65535 | :status = 200
| content-length = 38
WINDOW_UPDATE/5 | content-type = text/html; charset=utf-8
Window Size Increment = 65535 |
| DATA/3
| + END_STREAM
| <3257 октетов>
|
| DATA/5
| + END_STREAM
| <!DOCTYPE html>\n
| <h1>Привет!</h1>
|
|
|
|
PING/0 |
2f b0 7a ee 92 01 8a bc |
| PING/0
| + ACK
| 2f b0 7a ee 92 01 8a bc
|
GOAWAY/0 |
Last-Stream-Id = 0 |
Error Code = 0x00 NO ERROR |
|
# Клиент закрыл TCP-соединение |
|
Комментарии (35)
Aquahawk
31.07.2023 18:20+2я прямо чувствую всю боль автора от того что такой великолепный труд получил настолько ничтожный фидбек в виде плюсов и комментов.
Mimik_fc7
31.07.2023 18:20+2В эру http3/quic, http2 уже не так интересен, но статья -бомба, но увы, для гиков.
jabuj Автор
31.07.2023 18:20+4Не соглашусь, всё-таки HTTP/3 и QUIC сравнительно новые, ещё не обжились инфраструктурой. Особенно QUIC: учитывая, что сейчас клиент, отправляя пакеты QUIC не может быть уверен, дойдёт ли он хоть куда-нибудь, будет ли принят сервером или отвалится где-то по пути из-за файерволов, соединение HTTP/3 всё равно приходится начинать с соединения HTTP/2. Не на всех языках даже есть реализации клиентов/серверов HTTP/3 из-за терок с openssl. Взять тот же NodeJS, там библиотека для работы с QUIC всё ещё в разработке. Плюс HTTP/3 толком от 2 версии не отличается, по большому счёту просто вся чепуха с мультиплексированием перенесена с прикладного уровня на транспортный.
Ну и я полагаю позиция комитета по HTTP такая, что все три версии протокола сосуществуют, а не заменяют друг друга. Всё-таки чем дальше в лес, тем больше дров непонятно, зачем упарываться с бинарным HTTP/2, если вы пишете hello world для портфолио, там и HTTP/1.1 прекрасно справляется. Так же с HTTP/3, всё-таки TCP не без недостатков, но проверен временем. Зачем лезть в трясину QUIC, если HTTP/2 даёт достаточный выигрыш во что вы там ни играли.
vanxant
31.07.2023 18:20Во-первых, огромное человеческое спасибо за статью!
Во-вторых, не очень понятно, в чём же выигрыш http2 по сравнению с http1.1 на практике. Кажется, передать текстом
GET /
выходит как-то быстрее, чем вот это вот всё. Свою ошибку c мультиплексированием внутри TCP гугол признал (и выпустил http/3)jabuj Автор
31.07.2023 18:20+1Не за что)
Бенчмарки все поголовно говорят, что http2 быстрее, у кого-то в 2 раза, у кого-то в 1.001. Так что, по крайней мере, если вы играете в бенчмарки, выигрыш есть) А есть ли на самом деле ну надо смотреть на конкретном сайте и целевой аудитории.
И, кстати, запрос
GET / HTTP/1.1\r\n\r\n
в http2, если использовать HPACK по уму, а не как я описывал, записывается00 00 03 01 05 00 00 00 01 12 14 17
— 12 байтов вместо 18, считайте, на треть меньше B). А оценивать более содержательные запросы и включать в оценку преамбулу не будем, чтобы не портить статистику.
Mimik_fc7
31.07.2023 18:20Quic уже встроен в браузеры, уже есть миллиард реализации rfc9000, у хромиума есть все, чтобы каждый желающий его прикрутил, для квика можно обойтись и без tcp хендшейков, шифрование там очень даже интересное. Сейчас не 2005 чтобы говорить о зарезанном udp на фаерволлах. У квика есть преимущество - вы можете сделать свой контроль доставки, так сказать свой congestion control, в tcp же это на уровне ядра или qbic или bbr
Aquahawk
31.07.2023 18:20так сказать свой congestion control, в tcp же это на уровне ядра или qbic или bbr
не можете, а вынуждены делать в user space, а раньше это жило в kernel space. Каждый переход юзер/кернел весьма дорог, и вынос этого сетевого уровня из ядра оборачивается бОльшей нагрузкой на CPU
Mimik_fc7
31.07.2023 18:20Нет там сильной нагрузки, если не переключать между спейсами на каждый чих, зато в условиях проблемных сетей, выигрыш в доставке сообщений очень ощутимый, местами до 70% против классического tcp.
Chupaka
31.07.2023 18:20+1Ну и возможность пропатчить алгоритм congestion control простым обновлением либы, вместо обновления kernel, дорогого стоит.
vanxant
31.07.2023 18:20+1В эру http3/quic
In soviet Russia quic, увы, выдают по талонам
Mimik_fc7
31.07.2023 18:20Я не осилил впш сарказм, я в рф и у меня квик :) все в свободном доступе. Боьше скажу, я сделал прототип своего стриммингово сервиса на квике, андройд приложение, nginx с квиком и бакенд, вебсокеты и стримы идут по udp и отлично работает.
vanxant
31.07.2023 18:20+1Ну вот прям сейчас проверил. Белый список сайтов (проверял гугл, яндекс, вк) работают по http3, сайты попроще уже нет.
Ходят слухи, что тщ майор пока не умеет этот самый квик расшифровывать, поэтому вот так, белым списком.
SBortsov
31.07.2023 18:20Не обязательно для гиков, вот сейчас пишу мини библиотеку для микроконтроллера SV32WB06 на просто C и именно как раз по байтам приходится разбирать тело пакета
tmaxx
31.07.2023 18:20+1Отличная статья, спасибо!
Вот еще бы такую же по TLS…
jabuj Автор
31.07.2023 18:20+4Думал об этом, возможно будет :) Если будет, оставлю тут ссылку
event1
31.07.2023 18:20+1Статья отличная. Но зачем был придуман TCP с кривыми отступами полей заголовков поверх TCP, вместо того чтобы просто пользоваться самим TCP (или, допустим, его расширением MPTCP) остаётся загадкой.
jabuj Автор
31.07.2023 18:20Не понял, что вы имеете в виду под "кривыми отступами полей заголовков" и при чём здесь TCP. HTTP/2 не стремится заменить TCP ни в какой интерпретации
event1
31.07.2023 18:20Не понял, что вы имеете в виду под "кривыми отступами полей заголовков"
В традиционных протоколах отступ кратен его длине. Например, поле из 32-х бит будет расположено в 4-х, 8-и, 12-и, и т.д. байтах от начала пакета. Во всяком случае, если в этом поле целое количество октетов.
Заголовок TCP-пакета, на пример
Таким образом при обработке эти поля можно прямо сразу считать готовой структурой работать с заголовком в памяти напрямую. В случае http/2 это приведёт ко множественным невыровненным чтениям и потере производительности на ровном месте. Потому что Stream Identifier смещён на 5 байт вместо 4-х, а содержимое — на 9, вместо 8-и.
HTTP/2 не стремится заменить TCP ни в какой интерпретации
Естественно, нет. Но там реализованы все те же самые концепции. Вместо пары портов по 16 бит, есть один Sequence Identifier в 32 бита. Есть пинги. Есть управление окном доставки. Заменить отдельные потоки TCP-соединениями, пинги на keepalive, управление окном на ... управление окном и от http/2 не останется примерно ничего.
jabuj Автор
31.07.2023 18:20Таким образом при обработке эти поля можно прямо сразу считать готовой структурой работать с заголовком в памяти напрямую.
Не скажу, что я эксперт в низкоуровневом программировании, но как-то мне не верится, что сколько-нибудь значительного улучшения производительности можно было бы достичь с выровненным заголовком. Для tcp ещё могу понять: там содержимое заголовков — это набор чиселок, а содержимое пакета вообще не имеет значения, соответственно чиселки проще считывать, когда они выровнены. А реализации http2 интерпретируют содержимое кадров не как просто чиселки. В плане, как ни крути, реализации http2 работают на прикладном уровне, значит они читают все данные: и заголовок кадра, и содержимое, — из системного буфера tcp в условный массив. Даже если заголовок кадра был бы, допустим, 16 байтов, http-заголовки всё равно записаны чёрт знает как, у них всех разная длина, и значения могут быть произвольные. Так что чтобы от выровненного заголовка кадра был смысл, надо чтобы и http-заголовки были выровнены, а это трата места. И вообще, HTTP/2 пытается оптимизировать использование трафика и, пусть и не слишком удачно, количество необходимых tcp соединений, а не скорость обработки: мне кажется любые выигрыши производительности, которые можно было бы достигнуть выравниванием, мгновенно нивелируются, например, затратами на распаковку HPACK.
Заменить отдельные потоки TCP-соединениями, пинги на keepalive, управление окном на ... управление окном
И вы получите HTTP/1.1 с его недостатками, которые гугл попытался исправить в HTTP/2?) Не хочу сказать, что http2 хороший протокол. То, что возникли проблемы из-за попытки отправлять кучу запросов по одному TCP соединению, и так понятно, это упоминается в каждой статье с обзором http3. Собственно для этого и пришлось разрабатывать quic. Но если у вас вопрос "зачем был нужен такой протокол", то ответ — не совсем удачная попытка избавиться от проблем http1.1, которая перелилась в http3 — пока не совсем ясно, насколько удачную попытку избавиться от проблем http2.
Mimik_fc7
31.07.2023 18:20Я таки не смог уследить, как, как вы перешли с протокольного уровня на транспортный да еще и перемешали их? Единственный, кто выступает симбиозом - quic.
jabuj Автор
31.07.2023 18:20+1В том и дело, что http2 прикладной протокол, реализации которого тратят больше вычислительных ресурсов на то, чтобы интерпретировать содержимое кадров, а не на то, чтобы считать их из памяти. Соответственно, в моём понимании, там увеличение производительности от выравнивания было бы мизерное, если бы вообще было.
event1
31.07.2023 18:20все описанные возможности http/2 есть в простом обычном TCP. Соответственно, http/2 — это новый TCP поверх классического
jabuj Автор
31.07.2023 18:20Ну как, то, что есть похожие принципы работы не значит, что http2 это tcp в tcp. Всё-таки цели разные: tcp потоковый протокол, http2 основан на обмене сообщениями. Почему http2 тогда работает поверх tcp? Потому что выбирать приходится только из tcp и udp, либо изобретать свой транспортный протокол, а это головная боль. udp не подходит, потому что реализовывать надёжную доставку пакетов тоже головная боль, а tcp её предоставляет и проверен временем.
Теперь, вроде как у нас от tcp есть целый поток по которому отправляй данные не хочу, а мы только одно сообщение отправили и ждём, давайте этот поток использовать по полной. Для этого прилепим к каждому сообщению идентификатор и будем отправлять их целую кучу. Новая проблема: сообщения можно отправлять только целиком, ну так давайте разобьём их на более мелкие кадры. Вот и вышел http2, по мне так из вполне логичных и обоснованных рассуждений. Другое дело, что эти простые и очевидные решения оказались не совсем совместимы с реальностью, и что тот "целый поток" плохо справляется с грудой сообщений которые мы по нему теперь отправляем. Тут уже ничего не остаётся, кроме как придумывать новый транспортный протокол, собственно чем и занялись в http3. В общем, понятиями конечно http2 и tcp оперируют очень похожими, но в историческом контексте, я считаю, решения вполне обоснованные.
event1
31.07.2023 18:20Теперь, вроде как у нас от tcp есть целый поток по которому отправляй данные не хочу, а мы только одно сообщение отправили и ждём, давайте этот поток использовать по полной. Для этого прилепим к каждому сообщению идентификатор и будем отправлять их целую кучу.
Или просто откроем два (или двадцать два) TCP-соединения и будем делать тоже самое.
Новая проблема: сообщения можно отправлять только целиком, ну так давайте разобьём их на более мелкие кадры
Если использовать отдельное TCP-соединение на поток, то "Новая проблема" вообще не возникает
Заодно, получаем все остальные возможности, описанные в статье забесплатно.
Осталось переделать текстовый протокол в бинарный, сделать человеческое (по сравнению с http/1) управление соединениями и забанить нешифрованые соединения.
jabuj Автор
31.07.2023 18:20Или просто откроем два (или двадцать два) TCP-соединения и будем делать тоже самое.
Так и жили с http1.1, и одной из целей http2 как раз было избавиться от кучи TCP-соединений. Во вступлении RFC об этом речь
The resulting protocol [HTTP/2] is more friendly to the network because fewer TCP connections can be used in comparison to HTTP/1.x. This means less competition with other flows and longer-lived connections, which in turn lead to better utilization of available network capacity. Note, however, that TCP head-of-line blocking is not addressed by this protocol.
Вот, допустим, накидали причин, почему множество TCP-соединений это неоптимально.
event1
31.07.2023 18:20Так и жили с http1.1, и одной из целей http2 как раз было избавиться от кучи TCP-соединений
Проблема http1 не в том, что соединений несколько, а в том, что они постоянно открываются и закрываются. Это отстой и никуда не годится, тут спору нет.
Вот, допустим, накидали причин, почему множество TCP-соединений это неоптимально.
Их там же и опровергли, как несостоятельные.
Единственная реальная проблема с множеством долгоживущих TCP-соединений состоит в том, что могу закончится порты: между двумя хостами может существовать только 65000 соединений к порту 443. Соответственно, если есть какой-то очень большой сегмент за NAT, то очередное соединение может просто не установится. Но эта проблема точно так же [не] существует в http/2, ведь там тоже долгоживущие соединения. Просто их меньше.
martyncev
>>Вступлением" эту штуку я обозвал сам.
Преамбула?
jabuj Автор
Точно, спасибо!