Повесть о том, как один инженер HTTP/2 Client разгонял
На примере «JEP 110: HTTP/2 Client» (который в будущем появится в JDK) Сергей Куксенко из Oracle показывает, как команда его запускала, где смотрела и что крутила, чтобы сделать его быстрее.
Предлагаем вам расшифровку его доклада с JPoint 2017. В целом речь тут пойдет не про HTTP/2. Хотя, конечно, без ряда деталей по нему обойтись не удастся.
HTTP/2 (a. k. a. RFC 7540)
HTTP 2 — это новый стандарт, призванный заменить устаревший HTTP 1.1. Чем отличается реализация HTTP 2 от предыдущей версии с точки зрения производительности?
Ключевая вещь HTTP 2 — то, что у нас устанавливается одно единственное TCP-соединение. Потоки данных режутся на фреймы, и все эти фреймы отправляются через это соединение.
Также предусмотрен отдельный стандарт сжатия заголовков — RFC 7541 (HPACK). Он очень хорошо работает: позволяет ужать до 20 байт HTTP-шный header размером порядка килобайта. Для некоторых наших оптимизаций это важно.
В целом в новой версии есть много интересного — приоретизация запросов, server push (когда сервер сам посылает данные клиенту) и прочее. Однако в рамках этого повествования (с точки зрения производительности) это не важно. Кроме того, многие вещи остались прежними. Например, как выглядит протокол HTTP сверху: у нас те же методы GET и POST, те же значения полей заголовка HTTP, коды статуса и структура «запрос → ответ → финальный ответ». На самом деле если приглядеться, HTTP 2 — это всего навсего низкоуровневая транспортная подложка под HTTP 1.1, которая убирает его недостатки.
HTTP API (a.k.a. JEP 110, HttpClient)
У нас есть проект HttpClient, который называется JEP 110. Он почти включен в JDK 9. Изначально этот клиент хотели сделать частью стандарта JDK 9, но возникли некоторые споры на уровне реализации API. И поскольку мы не успеваем к выходу JDK 9 финализировать HTTP API, решили сделать так, чтобы можно было его показать сообществу и обсудить.
В JDK 9 появляется новый модуль инкубатор (Incubator Modules a.k.a. JEP-11). Это песочница, куда с целью получения фидбека от комьюнити будут складываться новые API, которые еще не стандартизированы, но, по определению инкубатора, будут стандартизированы к следующей версии или убраны вообще («The incubation lifetime of an API is limited: It is expected that the API will either be standardized or otherwise made final in the next release, or else removed»). Все, кому интересно, могут ознакомиться с API и прислать свой фидбек. Возможно, к следующей версии — JDK 10 — где он станет стандартом, все будет исправлено.
- module: jdk.incubator.httpclient
- package: jdk.incubator.http
HttpClient — первый модуль в инкубаторе. Впоследствии в инкубаторе будут появляться прочие вещи, связанные с клиентом.
Расскажу буквально на паре примеров про API (это именно клиентский API, который позволяет делать запрос). Основные классы:
- HttpClient (его Builder);
- HttpRequest (его Builder);
- HttpResponse, который мы не строим, а просто получаем обратно.
Вот таким простым образом можно построить запрос:
HttpRequest getRequest = HttpRequest .newBuilder(URI.create("https://jpoint.ru/")) .header("X-header", "value") .GET() .build();
HttpRequest postRequest = HttpRequest .newBuilder(URI.create("https://jpoint.ru/")) .POST(fromFile(Paths.get("/abstract.txt"))) .build();
Здесь мы указываем URL, задаем header и т.п. — получаем запрос.
Как можно послать запрос? Для клиента есть два вида API. Первый — синхронный запрос, когда мы блокируемся в месте этого вызова.
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = ...;
HttpResponse response =
// synchronous/blocking
client.send(request, BodyHandler.asString());
if (response.statusCode() == 200) {
String body = response.body();
...
}
...
Запрос ушел, мы получили ответ, проинтерпретировали его как string
(handler у нас здесь может быть разный — string
, byte
, можно свой написать) и обработали.
Второй — асинхронный API, когда мы не хотим блокироваться в данном месте и, посылая асинхронный запрос, продолжаем выполнение, а с полученным CompletableFuture потом можем делать все, что захотим:
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = ...;
CompletableFuture> responseFuture =
// asynchronous
client.sendAsync(request, BodyHandler.asString());
...
Клиенту можно задать тысячу и один конфигурационный параметр, по-разному сконфигурировать:
HttpClient client = HttpClient.newBuilder()
.authenticator(someAuthenticator)
.sslContext(someSSLContext)
.sslParameters(someSSLParameters)
.proxy(someProxySelector)
.executor(someExecutorService)
.followRedirects(HttpClient.Redirect.ALWAYS)
.cookieManager(someCookieManager)
.version(HttpClient.Version.HTTP_2)
.build();
Основная фишка еще здесь в том, что клиентский API — универсальный. Он работает как со старым HTTP 1.1, так и с HTTP 2 без различения деталей. Для клиента можно указать работу по умолчанию со стандартом HTTP 2. Этот же параметр можно указать для каждого отдельного запроса.
Постановка задачи
Итак, у нас есть Java-библиотека — отдельный модуль, который базируется на стандартных классах JDK, и который нам надо оптимизировать (провести некую перформансную работу). Формально задача перформанса состоит в следующем: мы должны получить разумную производительность клиента за приемлемые затраты времени инженера.
Выбираем подход
С чего мы можем начать эту работу?
- Можем сесть читать спецификацию HTTP 2. Это полезно.
- Можем начать изучать сам клиент и переписывать говнокод, который найдем.
- Можем просто посмотреть на этот клиент и переписать его целиком.
- Можем побенчмаркать.
Начнем с бенчмаркинга. Вдруг там все и так хорошо — не придется читать спецификацию.
Бенчмарки
Написали бенчмарк. Хорошо, если у нас для сравнения есть какой-нибудь конкурент. Я в качестве клиента-конкурента взял Jetty Client. Сбоку прикрутил Jetty Server — просто потому, что мне хотелось, чтобы сервер был на Java. Написал GET и POST запросы разных размеров.
Возникает, естественно, вопрос — что мы меряем: throughput, latency (минимальный, средний). В ходе дискуссии мы решили, что это не сервер, а клиент. Это значит, что учет минимальных latency, gc-пауз и всего прочего в данном контексте не важен. Поэтому конкретно для этой работы мы решили ограничиться измерением общего throughput системы. Наша задача — его повысить.
Общий throughput системы — это обратная величина к среднему latency. То есть мы работали над средним latency, но при этом не напрягались с каждым отдельным запросом. Просто потому, что у клиента не такие требования, как у сервера.
Переделка 1. Конфигурация TCP
Запускаем GET на 1 байт. Железо выписано. Получаем:
Я беру этот же бенчмарк для HTTPClient, запускаю на других операционных системах и железе (это уже более-менее серверные машинки). Получаю:
В Win64 все выглядит получше. Но даже в MacOS все не так плохо, как в Linux.
Проблема здесь:
SocketChannel chan;
...
try {
chan = SocketChannel.open();
int bufsize = client.getReceiveBufferSize(); chan.setOption(StandardSocketOptions.SO_RCVBUF, bufsize);
} catch (IOException e) {
throw new InternalError(e);
}
Это открытие SocketChannel для соединения с сервером. Проблема заключается в отсутствии одной строки (я ее выделил в коде ниже):
SocketChannel chan;
...
try {
chan = SocketChannel.open();
int bufsize = client.getReceiveBufferSize(); chan.setOption(StandardSocketOptions.SO_RCVBUF, bufsize);
chan.setOption(StandardSocketOptions.TCP_NODELAY, true);
} catch (IOException e) {
throw new InternalError(e);
}
TCP_NODELAY
— это «привет» из прошлого века. Существуют различные алгоритмы TCP-стека. В данном контексте их два: Nagle«s Algorithm и Delayed ACK. При некоторых условиях они способны клэшиться, вызывая резкое замедление передачи данных. Это настолько известная проблема для стека TCP, что люди включают TCP_NODELAY
, который выключает Nagle«s Algorithm, по умолчанию. Но иногда даже эксперт (а писали этот код реальные эксперты TCP) могут просто об этом забыть и не вписать эту командную строку.
В принципе, в интернете существует масса объяснений, как эти два алгоритма конфликтуют и почему они создают такую проблему. Я привожу ссылку на одну статью, которая мне понравилась: TCP Performance problems caused by interaction between Nagle«s Algorithm and Delayed ACK
Детальное описание этой проблемы — за рамками нашего разговора.
После того, как была добавлена единственная строчка с включением TCP_NODELAY
, мы получили примерно такой прирост производительности:
Я не буду считать, сколько это в процентах.
Мораль: это не Java-проблема, это проблема TCP-стека и вопросов его конфигурации. Для многих областей существуют общеизвестные косяки. Настолько общеизвестные, что люди про них забывают. Про них желательно просто знать. Если вы новичок в этой области, вы легко нагуглите основные косяки, которые существуют. Проверить их можно очень быстро и без всяких проблем.
Необходимо знать (и не забывать) список общеизвестных косяков для вашей предметной области.
Переделка 2. Flow-control window
У нас есть первое изменение, и мне даже не пришлось читать спецификацию. Получилось 9600 запросов в секунду, но помним, что Jetty дает 11 тыс. Дальше профилируем при помощи любого профайлера.
Вот что я получил:
А это отфильтрованный вариант:
Мой бенчмарк занимает 93% времени CPU.
Посылка реквеста на сервер занимает 37%. Далее идет всякая внутренняя детализация, работа с фреймами, а в конце 19% — это запись в наш SocketChannel. Передаем данные и header запроса, как должно быть в HTTP. А потом мы читаем — readBody()
.
Далее мы должны прочитать пришедшие нам с сервера данные. Что же тогда это?
Если инженеры правильно назвали методы, а я им доверяю, то здесь они что-то отсылают на сервер, причем это требует столько же времени, сколько сама отправка наших запросов. Зачем при чтении ответа сервера мы что-то посылаем?
Чтобы ответить на этот вопрос, мне пришлось прочитать спецификацию.
Вообще очень много перформансных проблем решаются без знания спецификации. Где-то надо заменить ArrayList
на LinkedList
или наоборот, или Integer
на int
и так далее. И в этом смысле очень хорошо, если есть бенчмарк. Меряешь — исправляешь — работает. И ты не вдаешься в детали, как оно там работает согласно спецификации.
Но в нашем случае проблема действительно обнаружилась в спецификации: в стандарте HTTP 2 есть так называемый flow-control. Работает он следующим образом. У нас есть два пира: один посылает данные, другой — получает. У посылателя (отправителя) есть окошко — flow-control window размером в некоторое количество байт (предположим, 16 КБ).
Допустим, мы послали 8 КБ. Flow-control window уменьшается на эти 8 КБ.
После того как мы послали еще 8 КБ, flow-control window стало 0 КБ.
По стандарту в такой ситуации мы не имеем права ничего посылать. Если мы попробуем послать какие-то данные, получатель будет обязан интерпретировать эту ситуацию как ошибку протокола и закрыть connection. Это некая защита от DDOS-ов в ряде случаев, чтобы нам не посылали ничего лишнего, а посылатель подстраивался под пропускную способность получателя.
Когда получатель обработал принятые данные, он должен был послать специальный выделенный сигнал под названием WindowUpdate с указанием, на сколько байт увеличить flow-control window.
Когда WindowUpdate приходит посылателю, у него flow-control window увеличивается, мы можем посылать данные дальше.
Что у нас происходит в клиенте?
Мы получили данные с сервера — вот реальный кусок обработки:
// process incoming data frames
...
DataFrame dataFrame;
do {
DataFrame dataFrame = inputQueue.take();
...
int len = dataFrame.getDataLength();
sendWindowUpdate(0, len); // update connection window sendWindowUpdate(streamid, len); // update stream window
} while (!dataFrame.getFlag(END_STREAM));
...
Пришел некий dataFrame
— фрейм данных. Мы посмотрели, сколько там данных, обработали их и послали обратно WindowUpdate, чтобы увеличить flow-control window на нужное значение.
На самом деле в каждом таком месте работает два flow-control window. У нас есть flow-control window конкретно к этому потоку передачи данных (запросу), а также есть общий flow control window для всего connection. Поэтому мы должны послать два запроса WindowUpdate.
Как оптимизировать данную ситуацию?
Первое. В конце while
у нас есть флажок, который говорит, что нам прислали последний фрейм данных. По стандарту это значит, что больше никакие данные не придут. И мы делаем так:
// process incoming data frames
...
DataFrame dataFrame;
do {
DataFrame dataFrame = inputQueue.take();
…
int len = dataFrame.getDataLength();
connectionWindowUpdater.update(len);
if (dataFrame.getFlag(END_STREAM)) {
break;
}
streamWindowUpdater.update(len);
}
while (true);
...
Это маленькая оптимизация: если мы поймали флажок конца стрима, то для этого стрима WindowUpdate можем уже не посылать: мы уже не ждем никаких данных, сервер ничего не будет посылать.
Второе. Кто сказал, что мы должны посылать WindowUpdate каждый раз? Почему мы не можем, получив много реквестов, обработать пришедшие данные и только потом послать WindowUpdate пачкой на все пришедшие запросы?
Вот WindowUpdater
, который работает на конкретное flow-control window:
final AtomicInteger received;
final int threshold;
...
void update(int delta) {
if (received.addAndGet(delta) > threshold) {
synchronized (this) {
int tosend = received.get();
if( tosend > threshold) {
received.getAndAdd(-tosend);
sendWindowUpdate(tosend);
}
}
}
}
У нас есть некий threshold
. Мы получаем данные, ничего не посылаем. Как только мы набрали данных до этого threshold
, мы отправляем все WindowUpdate. Тут присутствует некая эвристика, которая хорошо работает, когда значение threshold
близко к половине flow-control window. Если у нас это окно изначально было 64 КБ, а получаем мы по 8 КБ, то как только мы получили несколько дата-фреймов суммарным объемом 32 КБ, посылаем window updater сразу на 32 КБ. Обычная пакетная обработка. Для хорошей синхронизации делаем еще совершенно обычный дабл-чек.
Для запроса в 1 байт получаем:
Эффект будет даже для мегабайтных запросов, где много фреймов. Но он, естественно, не настолько заметен. На практике у меня были разные бенчмарки, запросы разного объема. Но здесь для каждого кейса я не стал рисовать графики, а подобрал простые примеры. Выжимка более подробных данных будет чуть позже.
Мы получили всего +23%, но Jetty уже обогнали.
Мораль: аккуратное чтение спецификации и логика — ваши друзья.
Здесь есть некий нюанс спецификации. Там с одной стороны сказано, что, получив дата-фрейм, мы должны отослать WindowUpdate. Но, внимательно прочитав спецификацию, мы увидим: там нет требования, что мы обязаны отсылать WindowUpdate на каждый полученный байт. Поэтому спецификация допускает такое пакетное обновления flow-control window.
Переделка 3. Блокировки
Давайте изучим, как мы скалируемся (масштабируемся).
Для скалирования ноутбук не очень подходит — у него всего два настоящих и два фейковых ядра. Мы возьмем какую-нибудь серверную машину, в которой 48 хардварных тредов, и запустим бенчмарк.
Здесь по горизонтали — количество потоков, а по вертикали показан общий throughput.
Здесь видно, что до четырех тредов мы скалируемся очень хорошо. Но дальше скалируемость становится очень плохой.
Казалось бы, зачем это нам? У нас один клиент; мы из одного треда получим необходимые данные с сервера и забудем об этом. Но во-первых, у нас есть асинхронная версия API. К ней мы еще придем. Там наверняка будут какие-то треды. Во-вторых, в нашем мире сейчас кругом все многоядерное, и иметь возможность хорошо работать с многими тредами в нашей библиотеке — просто полезно — хотя бы потому, что когда кто-то начнет жаловаться на производительность однопоточной версии, ему можно будет посоветовать перейти на многопоточную и получить бенефит. Поэтому давайте искать виновного в плохой скалируемости. Я обычно делаю это так:
#!/bin/bash
(java -jar benchmarks.jar BenchHttpGet.get -t 4 -f 0 &> log.log) &
JPID=$!
sleep 5 while kill -3 $JPID;
do
:
done
Я просто пишу стектрейсы в файл. В реальности этого мне хватает в 90% случаев, когда я работаю с блокировками без всяких профилеровщиков. Только в каких-то сложных трюковых кейсах я запускаю Mission control или что-то еще и смотрю распределение блокировок.
В логе можно посмотреть, в каком состоянии у меня различные треды:
Здесь нас интересуют именно блокировки, а не waiting, когда мы ожидаем событий. Блокировок 30 тыс. штук, что достаточно много на фоне 200 тыс. runnable.
А вот такая командная строчка нам просто покажет виновного (ничего дополнительно не нужно — только command line):
Виновник пойман. Это метод внутри нашей библиотеки, который посылает фрейм данных на сервер. Давайте разбираться.
void sendFrame(Http2Frame frame) {
synchronized (sendlock) {
try {
if (frame instanceof OutgoingHeaders) {
OutgoingHeaders oh = (OutgoingHeaders) frame;
Stream stream = registerNewStream(oh);
List frames = encodeHeaders(oh, stream); writeBuffers(encodeFrames(frames));
} else {
writeBuffers(encodeFrame(frame));
}
} catch (IOException e) {
...
}
}
}
Тут у нас глобальный монитор:
А вот эта ветка —
— начало инициирования запроса. Это отправление самого первого header на сервер (тут требуются некоторые дополнительные действия, я сейчас о них еще буду рассказывать).
Это отправка на сервер всех остальных фреймов:
Все это под global lock!
Сам sendFrame
у нас занимает в среднем 55% времени.
Но вот этот метод занимает 1%:
Попытаемся понять, что можно вынести из-под глобальной блокировки.
Регистрация нового стрима из-под блокировки вынесена быть не может. Стандарт HTTP накладывает ограничение на нумерацию стримов. В registerNewStream
новый стрим получает номер. И если для передачи своих данных я инициировал стримы с номерами 15, 17, 19, 21 и послал 21, а потом 15, это будет ошибка протокола. Посылать их я должен в порядке возрастания номера. Если я вынесу их из-под лока, они могут быть посланы не в том порядке, в котором я жду.
Вторая проблема, которая не выносится из-под блокировки:
Здесь происходит сжатие заголовка.
В обычном виде у нас заголовок приставлен обычной мапой — ключ-значение (из стринга в стринг). В encodeHeaders
происходит сжатие заголовка. И здесь вторые грабли стандарта HTTP 2 — алгоритм HPACK, который работает с сжатием, statefull. Т.е. у него есть состояние (поэтому очень хорошо сжимает). Если у меня отсылается два запроса (два header-а), при этом сначала я сжал один, потом второй, то сервер обязан их получить в том же порядке. Если он их получит в другом порядке, то не сможет декодировать. Это проблема — точка сериализации. Все кодирования всех HTTP-запросов обязаны проходить через единую точку сериализации, они не могут работать в параллели, да еще после этого закодированные фреймы должны отсылаться.
Метод encodeFrame
занимает 6% времени, и его теоретически можно вынести из под блокировки.
encodeFrames
скидывает фрейм в байт-буфер в том виде, в котором это определено спецификацией (до этого мы подготавливали внутреннюю структуру фреймов). Это занимает 6% времени.
Ничто не мешает нам вынести из под блокировки encodeFrames
, кроме метода, где происходит собственно запись в сокет:
Здесь есть некоторые нюансы реализации.
Так оказалось, что encodeFrames
может закодировать фрейм не в один, а в несколько байт-буферов. Это связано в первую очередь с эффективностью (чтобы не делать слишком много копирования).
Если мы попробуем вынести из-под блокировки writeBuffers
, и writeBuffers
от двух фреймов перемешаются, мы не сможем декодировать фрейм. Т.е. мы должны обеспечить какую-то атомарность. При этом внутри writeBuffers
выполняется socketWrite
, а там стоит своя глобальная блокировка на запись в сокет.
Сделаем первое, что приходит в голову, — очередь Queue. Будем класть байт-буфера в эту очередь в правильном порядке, и пусть другой тред из нее читает.
В этом случае метод writeBuffers
вообще «уезжает» из этого треда. Его нет нужды держать под данной блокировкой (там есть своя глобальная блокировка). Нам главное — обеспечить порядок байт-буферов, которые туда приезжают.
Итак, мы убрали одну из самых тяжелых операций наружу и запустили дополнительный тред. Размер критической секции стал меньше на 60%.
Но у реализации есть и минусы:
- для некоторых фреймов в стандарте HTTP 2 существует ограничение по порядку. Но другие фреймы по спецификации можно отправить раньше. Тот же WindowUpdate я могу отослать раньше других. И это хотелось бы сделать, потому что сервер-то стоит — он же ждет (у него flow-control window = 0). Однако реализация не позволяет это сделать;
- вторая проблема заключается в том, что когда у нас очередь пуста, отсылающий поток засыпает и долго просыпается.
Давайте решим первую проблему, связанную с порядком фреймов.
Вполне очевидная идея — Deque
.
У нас есть неразрывный кусочек байт-буферов, который нельзя перемешивать ни с чем; мы сложим его в массив, а сам массив — в очередь. Тогда эти массивы между собой перемешивать можно, а там, где нам требуется фиксированный порядок, мы его обеспечиваем:
- ByteBuffer[] — атомарная последовательность буферов;
- WindowUpdateFrame — мы можем положить в начало очереди и вынести его вообще из-под блокировки (у него нет ни кодирования протоколов, ни нумерации);
- DataFrame — тоже можно вынести из-под блокировки и положить в конец очереди. В итоге блокировка становится все меньше и меньше.
Плюсы:
- меньше блокировок;
- ранняя отправка Window Update позволяет серверу раньше отсылать данные.
Но и здесь остался еще один минус. По-прежнему отсылающий поток часто засыпает и долго просыпается.
Давайте сделаем так:
У нас будет немного своя очередь. В нее мы складываем полученные массивы байт-буферов. После этого между всеми тредами, которые вышли из-под блокировки, устроим соревнование. Кто победил, тот пусть в сокет и пишет. А остальные пусть работают дальше.
Надо отметить, что в методе flush()
оказалась и другая оптимизация, которая дает эффект: если у меня много мелких данных (например, 10 массивов по три-четыре буфера) и зашифрованное SSL-соединение, он может из очереди брать не по одному массиву, а более крупными кусками, и отправлять их в SSLEngine. В этом случае затраты на кодирование резко снижаются.
Каждая из трех представленных оптимизаций позволяла очень хорошо снимать проблему со скалированием. Примерно вот так (отражен общий эффект):
Мораль: Блокировки — зло!
Все знают, что от блокировок нужно избавляться. Тем более что concurrent-библиотека становится все более продвинутой и интересной.
Переделка 4. Пул или GC?
В теории у нас есть HTTP Client, рассчитанный под 100% использование ByteBufferPool. Но на практике… Тут же баги, здесь — что-то упало, там — фрейм недоработался… А если ByteBuffer в пул обратно не вернул, функционал не сломался… В общем инженерам оказалось некогда с этим разбираться. И у нас получилась недоделанная версия, заточенная на пулы. Имеем (и плачем):
- только 20% буферов возвращается в пул;
- ByteBufferPool.getBuffer () занимает 12% времени.
Мы получаем все минусы работы с пулами, а заодно — все минусы работы без пулов. Плюсов в этой версии нет. Нам надо двигаться вперед: или сделать нормальный полноценный пул, чтобы все ByteBuffer в него возвращались после использования, или вообще выпилить пулы, но при этом они у нас есть даже в public API.
Что люди думают про пулы? Вот что можно услышать:
- Пул не нужен, пулы вообще вредны! e.g. Dr. Cliff Click, Brian Goetz, Sergey Kuksenko, Aleksey Shipil¨ev, …
- некоторые утверждают, будто пул — это круто и у них проявился эффект. Пул нужен! e.g. Netty (blog.twitter.com/2013/netty-4-at-twitter-reduced-gc-overhead), …
DirectByteBuffer или HeapByteBuffer
Прежде чем вернемся к вопросу пулов, нам нужно решить подвопрос — что мы используем в рамках нашей задачи с HTTPClient: DirectByteBuffer или HeapByteBuffer?
Сначала изучаем вопрос теоретически:
- DirectByteBuffer лучше для I/O.
sun.nio.* копирует HeapByteBuffer в DirectByteBuffer; - HeapByteBuffer лучше для SSL.
SSLEngine работает напрямую с byte[] в случае HeapByteBuffer.
Действительно, для передачи данных в сокет DirectByteBuffer лучше. Потому что если мы по цепочке Write-ов дойдем до nio, мы увидим код, где из HeapByteBuffer все копируется во внутренние DirectByteBuffer-а. И если у нас пришел DirectByteBuffer, мы ничего не копируем.
Но у нас есть и другая штука — SSL-соединение. Сам стандарт HTTP 2 позволяет работать как с plain connection, так и с SSL connection, но декларируется, что SSL должен быть стандартом де-факто для нового веба. Если точно так же проследить цепочку того, как это реализовано в OpenJDK, выясняется, что теоретически SSLEngine лучше работает с HeapByteBuffer, потому что он может достучаться до массива byte[] и там энкриптить. А в случае с DirectByteBuffer он должен сначала скопировать сюда, а потом обратно.
А измерения показывают, что HeapByteBuffer всегда быстрее:
- PlainConnection — HeapByteBuffer «быстрее» на 0%-1% — я взял в кавычки, потому что 0 — 1% — это не быстрее. Но выигрыша от использования DirectByteBuffer нет, а проблем больше;
- SSLConnection — HeapByteBuffer быстрее на 2%-3%
Т.е. HeapByteBuffer — наш выбор!
Как ни странно, чтение и копирование из DirectByteBuffer дороже, потому что там остаются чеки. Код там не очень хорошо векторизуется, поскольку работает через unsafe. А в HeapByteBuffer — intrinsic (даже не векторизация). И скоро он будет работать еще лучше.
Поэтому даже если бы HeapByteBuffer был на 2–3% медленнее, чем DirectByteBuffer, возможно, не имело бы смысла заниматься DirectByteBuffer. Так давайте избавимся от проблемы.
Сделаем различные варианты.
Вариант 1: Все в пул
- Пишем нормальный пул. Четко отслеживаем жизненные пути всех буферов, чтобы они возвращались обратно в пул.
- Оптимизируем сам пул (на базе ConcurrentLinkedQueue).
- Разделяем пулы (по размеру буфера). Возникает вопрос, какого размера должны быть буфера. Я читал, что в Jetty сделан универсальный ByteBufferPool, который позволяет работать с байт-буферами разного размера с гранулярностью в 1 КБ. Нам же нужно просто три разных ByteBufferPool, каждый работает со своим размером. И если пул работает с буферами только одного размера, все становится гораздо проще:
- SSL пакеты (SSLSession.getPacketBufferSize ());
- кодирование заголовков (MAX_FRAME_SIZE);
- все остальное.
Плюсы варианта 1:
- меньше «allocation pressure»
Минусы:
- реально сложный код. Почему инженеры не доделали это решение в первый раз? Потому что оценить, как ByteBuffer пробирается туда-сюда, когда его можно безопасно вернуть в пул, чтобы ничего не испортилось, та еще проблема. Я видел потуги некоторых людей, пытавшихся к этим буферам прикрутить референс-каунтинг. Я настучал им по голове. Это делало код еще сложнее, но проблемы не решало;
- реально плохая «локальность данных»;
- затраты на пул (а еще мешает скалируемости);
- частое копирование данных, в том числе:
- практическая невозможность использования
ByteBuffer.slice()
иByteBuffer.wrap()
. Если у нас есть ByteBuffer, из которого надо вырезать какую-то серединку, мы можем либо скопировать его, либо сделать slice (). slice () не копирует данные. Мы вырезаем кусок, но переиспользуем тот же массив данных. Мы сокращаем копирование, но с пулами полная каша. Теоретически это можно довести до ума, но здесь уже точно без референс-каунтинга не обойтись. Допустим, я прочитал из сети кусок 128 КБ, там лежит пять дата-фреймов, каждый по 128 Байт, и мне из них надо вырезать данные и отдать их пользователю. И неизвестно, когда пользователь их вернет. А ведь все это — единый байт-буфер. Надо чтобы все пять кусков померли и тогда байт-буфер вернется. Никто из участников не взялся это реализовать, поэтому мы честно копировали данные. Думаю, затраты на борьбу с копированием не стоят возрастающей сложности кода.
Вариант 2: Нет пулам — есть же GC
GC сделает всю работу, тем более у нас не DirectByteBuffer, а HeapByteBuffer.
- убираем все пулы, в том числе из Public API, потому что в реальности они не несут в себе никакой функциональности, кроме какой-то внутренней технической реализации.
- ну и, естественно, поскольку у нас теперь все собирает GC, нам не нужно копировать данные — мы активно используем
ByteBuffer.slice()
/wrap()
— режем и заворачиваем буфера.
Плюсы:
- код реально стал проще для понимания;
- нет пулов в «public API»;
- у нас хорошая «локальность данных»;
- значительное сокращение затрат на копирование, все так работает;
- нет затрат на пул.
Но две проблемы:
- во-первых, аллокация данных — выше «allocation pressure»
- и вторая проблема в том, что мы нередко не знаем, какой буфер нам нужен. Мы читаем из сети, из I/O, из сокета, мы аллоцируем буфер в 32 КБ, ну пусть даже в 16 КБ. А из сети прочитали 12 Байт. И что нам с этим буфером дальше делать? Только выкидывать. Получаем неэффективное использование памяти (когда требуемый размер буфера неизвестен) — ради 12 Байт аллоцировали 16 КБ.
Вариант 3: Смешиваем
Ради эксперимента делаем смешанный вариант. Про него я расскажу немного подробнее. Здесь выбираем подход в зависимости от данных.
Исходящие данные:
- пользовательские данные. Мы знаем их размер, за исключением кодирования в алгоритме HPACK, поэтому всегда аллоцируем буфера нужного размера — у нас нет неэффективного расходования памяти. Мы можем делать всякие нарезки и заворачивания без лишнего копирования — и пусть GC соберет.
- для сжатия HTTP-заголовков — отдельный пул, откуда берется байт-буфер и потом туда же возвращается.
- все остальное — буфера требуемого размера (GC соберет)
Входящие данные:
- чтение из сокета — буфер из пула какого-то нормального размера — 16 или 32 КБ;
- пришли данные (DataFrame) —
slice()
(GC соберет); - все остальное — возвращаем в пул.
В целом в стандарте HTTP 2 есть девять видов фреймов. Если пришли восемь из них (все, кроме данных), то мы байт-буфера там же декодируем и нам ничего из него копировать не надо, и мы возвращаем байт-буфера обратно в пул. А если пришли данные, мы выполняем slice, чтобы не надо было ничего копировать, а потом просто его бросаем — он будет собираться GC.
Ну и отдельный пул для зашифрованных буферов SSL-соединения, потому что там свой размер.
Плюсы смешанного варианта:
- средняя сложность кода (в чем-то, но в основном он проще, чем первый вариант с пулами, потому что меньше надо отслеживать);
- нет пулов в «public API»;
- хорошая «локальность данных»;
- нет затрат на копирование;
- приемлемые затраты на пул;
- приемлемое использование памяти.
Минус — один: выше «allocation pressure».
Сравнение вариантов
Мы сделали три варианта, проверили, поправили баги, добились функциональной работы. Измеряем. Смотрим аллокации данных. У меня было 32 сценария измерений, но я не хотел здесь рисовать 32 графика. Я покажу просто усредненный по всем измерениям диапазон. Здесь baseline — первоначальный недоделанный код (я его взял за 100%). Мы измеряли изменение allocation rate по отношению к baseline в каждой из трех модификаций.
Вариант, где все идет в пул, предсказуемо аллоцирует меньше. Вариант, не требующий никаких пулов, аллоцирует в восемь раз больше памяти, чем вариант без пулов. Но так ли нам нужна память для allocation rate? Померяем GC-паузу:
С такими GC-паузами на allocation rate это не влияет.
Видно, что первый вариант (в пул по максимуму) дает 25% ускорения. Отсутствие пулов по максимуму дает 27% ускорения, а смешанный вариант дает по максимуму 36% ускорения. Любой правильно доделанный вариант уже дает увеличение производительности.
На ряде сценариев смешанный вариант дает примерно на 10% больше, чем вариант с пулами или вариант вообще без пулов, поэтому на нем и было решено остановиться.
Мораль: здесь пришлось попробовать различные варианты, но реальной необходимости тотально работать с пулами с протаскиванием их в public API не было.
- Не ориентироваться на «urban legends»
- Знать мнения авторитетов
- Но нередко «истина где-то рядом»
Промежуточные итоги
Выше описаны четыре переделки, о которых я х