Обработка TCP-соединений легко может стать узким местом, когда скорость приближается к 10 тысячам запросов в секунду: эффективное чтение и запись становится отдельной проблемой, а большая часть вычислительных ядер простаивает. В этой статье я предлагаю оптимизации, улучшающие три аспекта работы с TCP: принятие соединений, получение сообщений и ответ на них.
Статья адресована как Erlang-программистам, так и всем, кто просто интересуется Erlang. Глубокое знание языка не требуется.
«Работу с TCP» я делю на три части:
- Получение соединений
- Получение сообщений
- Ответ на сообщения
Я рассмотрю два подхода к написанию TCP-сервисов — напрямую через gen_tcp и с помощью ранчо , самая популярная библиотека для пула соединений в Erlang. Некоторые из предложенных оптимизаций будут применимы только в одном из случаев.
Чтобы измерить изменение производительности, я использую MZBench с tcp_worker, который реализует функции подключения и запроса, а также функции синхронизации.
Будут использоваться два скрипта «fast_connect» и «fast_receive».
Первый открывает соединения с линейно возрастающей скоростью, а второй пытается отправить как можно больше пакетов по уже открытым соединениям.
Каждый из сценариев запускался на узле Amazon c4.2xlarge. Версия Эрланга — 18. Скрипты и код функций для MZBench доступен на GitHub .
Получение соединений
Быстрое принятие подключений важно, если у вас много клиентов, которые постоянно повторно подключаются, например, если клиентские процессы очень ограничены по времени или не поддерживают постоянные соединения.
Оптимизация ранчо
TCP-сервисы, использующие ранчо создаются достаточно просто.Я изменю пример кода эхо-сервис, который поставляется с ранчо чтобы он отвечал «ОК» на любой входящий пакет, ниже приведены различия:
Начну с запуска скрипта fast_connect (с увеличением скорости открытия соединений):--- a/examples/tcp_echo/src/echo_protocol.erl +++ b/examples/tcp_echo/src/echo_protocol.erl @@ -16,8 +16,8 @@ init(Ref, Socket, Transport, _Opts = []) -> loop(Socket, Transport) -> case Transport:recv(Socket, 0, 5000) of - {ok, Data} -> - Transport:send(Socket, Data), + {ok, _Data} -> + Transport:send(Socket, <<"ok">>), loop(Socket, Transport); _ -> ok = Transport:close(Socket) --- a/examples/tcp_echo/src/tcp_echo_app.erl +++ b/examples/tcp_echo/src/tcp_echo_app.erl @@ -11,8 +11,8 @@ %% API. start(_Type, _Args) -> - {ok, _} = ranch:start_listener(tcp_echo, 1, - ranch_tcp, [{port, 5555}], echo_protocol, []), + {ok, _} = ranch:start_listener(tcp_echo, 100, + ranch_tcp, [{port, 5555}, {max_connections, infinity}], echo_protocol, []), tcp_echo_sup:start_link().
На графике слева виден всплеск размером 214 мс, остальные линии соответствуют процентилям задержек, разбитым на пятисекундные интервалы.
График справа показывает скорость открытия соединений; например, в зоне выброса это было около 3,5 тысяч подключений в секунду.
В этом сценарии каждый раз отправляется одно сообщение, поэтому количество сообщений соответствует количеству открытых соединений.
Дальнейшее увеличение скорости дает следующие результаты:
Превышение 1000 мс соответствует тайм-ауту.
Если вы продолжите увеличивать скорость открытия соединений, всплески станут более частыми.
Первые всплески появляются на 5к об/с и постоянно присутствуют на 11к об/с.
Замена таймаута при получении пакета на timer:sleep()
Я обнаружил, что простой пропуск параметра тайм-аута при получении сообщения значительно повышает скорость установления соединений.
Чтобы не опрашивать сокет на максимальной скорости, я добавил timer:sleep(20): --- a/examples/tcp_echo/src/echo_protocol.erl
+++ b/examples/tcp_echo/src/echo_protocol.erl
@@ -15,10 +15,11 @@ init(Ref, Socket, Transport, _Opts = []) ->
loop(Socket, Transport).
loop(Socket, Transport) ->
- case Transport:recv(Socket, 0, 5000) of
- {ok, Data} ->
- Transport:send(Socket, Data),
+ case Transport:recv(Socket, 0, 0) of
+ {ok, _Data} ->
+ Transport:send(Socket, <<"ok">>),
loop(Socket, Transport);
+ {error, timeout} -> timer:sleep(20), loop(Socket, Transport);
_ ->
ok = Transport:close(Socket)
end.
Благодаря этой оптимизации приложение ранчо может принимать больше соединений, причем первый всплеск появляется только на скорости 11 000 об/с:
Если вы попытаетесь еще больше увеличить скорость, выбросов будет еще больше.
Таким образом, максимальное число составляет 24 тыс.
об/с.
Заключение При предложенной оптимизации я получил примерно двукратный прирост скорости приёма соединений, с 11к до 24к рпс.
оптимизация gen_tcp
Ниже приведена чистая реализация с использованием gen_tcp, похожая на то, что я сделал с помощью ranch (текст доступен как простой.erl
в репозитории с примерами): -export([service/1]).
-define(Options, [
binary,
{backlog, 128},
{active, false},
{buffer, 65536},
{keepalive, true},
{reuseaddr, true}
]).
-define(Timeout, 5000).
main([Port]) ->
{ok, ListenSocket} = gen_tcp:listen(list_to_integer(Port), ЭOptions),
accept(ListenSocket).
accept(ListenSocket) ->
case gen_tcp:accept(ListenSocket) of
{ok, Socket} -> erlang:spawn(ЭMODULE, service, [Socket]), accept(ListenSocket);
{error, closed} -> ok
end.
service(Socket) ->
case gen_tcp:recv(Socket, 0, ЭTimeout) of
{ok, _Binary} -> gen_tcp:send(Socket, <<"ok">>), service(Socket);
_ -> gen_tcp:close(Socket)
end.
Запустив тот же скрипт, я получил результаты:
Как видите, около 18к рпс прием соединения становится ненадежным.
Предположим, мы можем принять 18 тысяч.
Замена таймаута при получении пакета на timer:sleep()
Я просто применю ту же оптимизацию, что и для ранчо: service(Socket) ->
case gen_tcp:recv(Socket, 0, 0) of
{ok, _Binary} -> gen_tcp:send(Socket, <<"ok">>), service(Socket);
{error, timeout} -> timer:sleep(20), service(Socket);
_ -> gen_tcp:close(Socket)
end.
В этом случае можно обработать 23 тыс.
об/с:
Добавление хост-процессов
Вторая идея — увеличить количество процессов, принимающих соединение.
Этого можно добиться, вызвав gen_tcp:accept из нескольких процессов: main([Port]) ->
{ok, ListenSocket} = gen_tcp:listen(list_to_integer(Port), ЭOptions),
erlang:spawn(ЭMODULE, accept, [ListenSocket]),
erlang:spawn(ЭMODULE, accept, [ListenSocket]),
accept(ListenSocket).
Нагрузочное тестирование дает 32к об/с:
При дальнейшем увеличении нагрузки задержки увеличиваются.
Заключение Оптимизация таймаута для gen_tcp увеличивает скорость приема на 5 тысяч об/с, с 18 до 23 тысяч.
При нескольких процессах приема gen_tcp обрабатывает 32 тыс.
rps, что в 1,8 раза больше, чем без оптимизаций.
Полученные результаты
- Параметр timeout в функции вызова лучше не использовать, лучше timer:sleep. Это относится как к ranch, так и к чистому gen_tcp. Для ранчо это удваивает скорость получения соединений.
- Из нескольких процессов соединения принимаются быстрее.
Это применимо только для чистого gen_tcp. В моем случае это дало улучшение скорости приема соединений на 40% в сочетании с заменой таймаута на timer:sleep().
Получение сообщений
Эта часть о том, как получать большое количество коротких сообщений по уже установленным соединениям.Новые соединения открываются редко, а читать сообщения и отвечать на них нужно как можно быстрее.
Этот сценарий реализуется в загруженных приложениях с веб-сокетами.
Открываю 25к соединений с нескольких нод и постепенно увеличиваю скорость отправки сообщений.
Оптимизация ранчо
Ниже приведены результаты для неоптимизированного кода с использованием ранчо (слева задержки, справа скорость обработки сообщений):Без оптимизации ранчо обрабатывает 70 тыс.
об/с с максимальной задержкой 800 мс.
Увеличение буферов Linux
Достаточно популярная оптимизация увеличение буферов Linux для сокетов .
Посмотрим, как такая оптимизация повлияет на результаты:
Заключение
В этом случае увеличение буферов не дает особой пользы.
оптимизация get_tcp
Ниже я проверил скорость обработки пакетов решением gen_tcp из предыдущей части статьи:70 тысяч рупий в секунду, как и на ранчо.
Уменьшение количества процессов чтения
В предыдущем случае у меня есть 25 тысяч процессов, читающих из сокетов — по одному процессу на одно соединение.Теперь попробую уменьшить эту сумму и проверю результаты.
Я создам 100 процессов и распределяю между ними новые сокеты: main([Port]) ->
{ok, ListenSocket} = gen_tcp:listen(list_to_integer(Port), ЭOptions),
Readers = [erlang:spawn(ЭMODULE, reader, []) || _X <- lists:seq(1, ЭReaders)],
accept(ListenSocket, Readers, []).
accept(ListenSocket, [], Reversed) -> accept(ListenSocket, lists:reverse(Reversed), []); accept(ListenSocket, [Reader | Rest], Reversed) -> case gen_tcp:accept(ListenSocket) of {ok, Socket} -> Reader ! Socket, accept(ListenSocket, Rest, [Reader | Reversed]); {error, closed} -> ok end. reader() -> reader([]).
read_socket(S) ->
case gen_tcp:recv(S, 0, 0) of
{ok, _Binary} -> gen_tcp:send(S, <<"ok">>), true;
{error, timeout} -> true;
_ -> gen_tcp:close(S), false
end.
reader(Sockets) ->
Sockets2 = lists:filter(fun read_socket/1, Sockets),
receive
S -> reader([S | Sockets2])
after ЭSmallTimeout -> reader(Sockets)
end.
Такая оптимизация дает существенный прирост производительности:
Помимо увеличения скорости, задержки выглядят намного лучше, а количество обработанных пакетов составляет около 100к, кроме того, можно обрабатывать даже 120к сообщений, но с большими временными задержками.
Хотя без оптимизации этого сделать было невозможно.
Заключение
Обработка нескольких соединений из одного процесса дает увеличение производительности как минимум на 50% для чистого сервера gen_tcp.
Увеличение буферов Linux
Я применю ту же оптимизацию к системе с ванильным скриптом gen_tcp:Как и в случае с ранчо, значимых результатов не видно, появились лишь дополнительные выбросы в виде больших временных задержек.
Применяя оптимизацию к уже оптимизированному gen_tcp, я получаю много всплесков временной задержки:
Заключение
Решения, основанные на чистом gen_tcp, также не выигрывают от увеличения буферов Linux. Уменьшение количества процессов, читающих из сокетов, дает выигрыш в скорости обработки на 50%.
Полученные результаты
- Изначально оба решения позволяют обрабатывать примерно одинаковое количество сообщений, около 70 тыс.
запросов в секунду.
- Увеличение буферов существенно не увеличивает скорость обработки и, в случае чистого gen_tcp, добавляет потери в виде больших задержек.
- Решение Gen_tcp с несколькими сокетами на процесс как минимум в 1,5 раза быстрее, чем неоптимизированное, и имеет гораздо лучшую задержку.
К сожалению, эта оптимизация не применима к ранчо без изменения его архитектуры.
Ответ на сообщения
Технически в предыдущих главах цикл обработки сообщений включал в себя реакцию на него, но я не делал ничего для оптимизации этой части.Я попробую применить те же идеи к функциям отправки сообщений.
Здесь я использую сценарий из предыдущей главы, в котором пакеты передаются через уже установленные соединения.
Тайм-аут и оптимизация процессов
Те же идеи, которые я использовал в предыдущих главах, можно применить к функции отправки: убрать тайм-аут и отвечать от меньшего количества процессов.В функции отправки нет такого параметра, как тайм-аут; вам необходимо установить параметр {send_timeout, 0} при открытии соединения.
К сожалению, данная оптимизация практически ничего не меняет, а изменение кода сводится к простому добавлению опции, по этой причине я решил не утомлять читателя диффом и графиком.
Чтобы проверить, как влияет количество процессов, я использовал следующий скрипт: -export([responder/0, service/2]).
-define(Options, [ binary, {backlog, 128}, {active, false}, {buffer, 65536}, {keepalive, true}, {send_timeout, 0}, {reuseaddr, true} ]).
-define(SmallTimeout, 50).
-define(Timeout, 5000).
-define(Responders, 200).
main([Port]) -> {ok, ListenSocket} = gen_tcp:listen(list_to_integer(Port), ЭOptions), Responders = [erlang:spawn(ЭMODULE, responder, []) || _X <- lists:seq(1, ЭResponders)], accept(ListenSocket, Responders, []).
accept(ListenSocket, [], Reversed) -> accept(ListenSocket, lists:reverse(Reversed), []);
accept(ListenSocket, [Responder | Rest], Reversed) ->
case gen_tcp:accept(ListenSocket) of
{ok, Socket} -> erlang:spawn(ЭMODULE, service, [Socket, Responder]), accept(ListenSocket, Rest, [Responder | Reversed]);
{error, closed} -> ok
end.
responder() ->
receive
S -> gen_tcp:send(S, <<"ok">>), responder()
after ЭSmallTimeout -> responder()
end.
service(Socket, Responder) ->
case gen_tcp:recv(Socket, 0, ЭTimeout) of
{ok, _Binary} -> Responder ! Socket, service(Socket, Responder);
_ -> gen_tcp:close(Socket)
end.
Здесь отвечающие процессы отделены от читателей; У меня 25 000 читателей и 200 ответов.
Но опять же, эта оптимизация не показывает значительного прироста производительности по сравнению с решением gen_tcp из предыдущего раздела:
Настройка Эрланга
Если один процесс используется для работы с несколькими сокетами, один медленный клиент может замедлить работу всех остальных.Чтобы избежать такой ситуации, можно установить {send_timeout, 0} при открытии сокета и в случае неудачи повторить отправку в следующем цикле.
К сожалению, функция отправки не возвращает количество отправленных байтов.
Возвращается только ошибка POSIX или атом «ОК».
Это делает невозможным отправку последнего успешно отправленного байта.
Кроме того, зная это число, можно более эффективно использовать сеть, что становится особенно важным, если у клиентов плохие каналы.
Ниже я привожу пример того, как это можно исправить:
- Загрузите исходники Erlang с официального сайта:
$ wget http://erlang.org/download/otp_src_18.2.1.tar.gz $ tar -xf otp_src_18.2.1.tar.gz $ cd otp_src_18.2.1
- Давайте обновим функцию драйвера inet erts/emulator/drivers/common/inet_drv.c:
- Добавим возможность отвечать цифрой:
static int inet_reply_ok_int(inet_descriptor* desc, int Val) { ErlDrvTermData spec[2*LOAD_ATOM_CNT + 2*LOAD_PORT_CNT + 2*LOAD_TUPLE_CNT]; ErlDrvTermData caller = desc->caller; int i = 0; i = LOAD_ATOM(spec, i, am_inet_reply); i = LOAD_PORT(spec, i, desc->dport); i = LOAD_ATOM(spec, i, am_ok); i = LOAD_INT(spec, i, Val); i = LOAD_TUPLE(spec, i, 2); i = LOAD_TUPLE(spec, i, 3); ASSERT(i == sizeof(spec)/sizeof(*spec)); desc->caller = 0; return erl_drv_send_term(desc->dport, caller, spec, i); }
- Удалим отправку атома «ок» из функции tcp_inet_commandv:
else inet_reply_error(INETP(desc), ENOTCONN); } else if (desc->tcp_add_flags & TCP_ADDF_PENDING_SHUTDOWN) tcp_shutdown_error(desc, EPIPE); >> else tcp_sendv(desc, ev); DEBUGF(("tcp_inet_commandv(%ld) }\r\n", (long)desc->inet.port)); }
- Давайте добавим отправку int вместо возврата 0 в функцию tcp_sendv:
default: if (len == 0) >> return inet_reply_ok_int(desc, 0); h_len = 0; break; } ----------------------------------- else if (n == ev->size) { ASSERT(NO_SUBSCRIBERS(&INETP(desc)->empty_out_q_subs)); >> return inet_reply_ok_int(desc, n); } else { DEBUGF(("tcp_sendv(%ld): s=%d, only sent " LLU"/%d of "LLU"/%d bytes/items\r\n", (long)desc->inet.port, desc->inet.s, (llu_t)n, vsize, (llu_t)ev->size, ev->vsize)); } DEBUGF(("tcp_sendv(%ld): s=%d, Send failed, queuing\r\n", (long)desc->inet.port, desc->inet.s)); driver_enqv(ix, ev, n); if (!INETP(desc)->is_ignored) sock_select(INETP(desc),(FD_WRITE|FD_CLOSE), 1); } >> return inet_reply_ok_int(desc, n);
- Добавим возможность отвечать цифрой:
- Запустите /configure && make && make install.
Следующий фрагмент кода выведет цифру «9»: {ok, Sock} = gen_tcp:connect(SomeHostInNet, 5555,
[binary, {packet, 0}]),
{ok, N} = gen_tcp:send(Sock, "Some Data"),
io:format("~p", [N])
Заключение
Если вы обрабатываете несколько подключений из одного процесса, вы должны использовать параметр {send_timeout, 0} при создании сокета, иначе один медленный клиент может замедлить отправку всем остальным.
Если ваш протокол может обрабатывать частичные сообщения, лучше исправить OTP и учесть количество отправленных байт.
Кратко
- Если вам нужно быстро принимать соединения, вам нужно принимать их от нескольких процессов.
- Если вам нужно быстро читать из сокетов, вам нужно обрабатывать несколько сокетов одним процессом, а не использовать ranch.
- Увеличение буферов Linux приводит к снижению стабильности системы и не дает существенного прироста производительности.
- При использовании нескольких сокетов из одного процесса необходимо убрать таймаут отправки.
- Если вам нужно узнать точное количество отправленных байтов, вы можете исправить OTP.
Ссылки
- Библиотека, позволяющая обрабатывать сокеты по описанному выше сценарию: https://github.com/parsifal-47/socketpool
- Примеры из статьи: https://github.com/parsifal-47/server-examples
-
Проблемы С Созданием Веб-Сайта
19 Oct, 24 -
Лакруа, Жан
19 Oct, 24 -
Сунами Идет!
19 Oct, 24 -
Изучение C# — Практический Подход
19 Oct, 24 -
Конкурсы Инди-Игр: 12 Советов Разработчикам
19 Oct, 24