При написании предыдущей статьи я наткнулся на странный эффект с производительностью TCP-сокетов. При попытке отправить пачку запросов через сокет оказалось, что выгоднее заново устанавливать TCP-соединение после каждого запроса, а не переиспользовать его. Исследованию причин этого эффекта (а заодно и поиску способов от него избавиться) и посвящена эта статья.

В чем собственно дело

Имеется два процесса, клиент (написан на PHP) и сервер (на C++). Клиент подключается к серверу по TCP и отправляет ему запросы. Запрос отправляется в виде "4 байта длины, затем тело". Сервер обрабатывает запрос (для теста - он просто XOR-ит байты запрос друг с другом) и отправляет ответ (также, 4 байта длины, затем тело).

В рамках тестирования проверялись разные варианты длины запросов, их количества и количества запросов до переустановки соединения. И вот эффект:
В качестве тестового стенда используется сервер со следующими характеристиками:

  1. CPU - 2 x Intel(R) Xeon(R) CPU E5-2650 v4 @ 2.20GHz

  2. RAM - 64Gb DDR4 ECC

  3. OS - openSUSE Leap 15.4

Запускаем 1000 запросов, переподключаемся через 1000 (т.е. ни разу), 10 и 1 (после каждого запроса).

Testing 1000 requests, reconnect after 1000 size 2048
Testing IP socket:
Total: 30.043 seconds, 33.286 RPS

Testing 1000 requests, reconnect after 10 size 2048
Testing IP socket:
Total: 19.999 seconds, 50.001 RPS

Testing 1000 requests, reconnect after 1 size 2048
Testing IP socket:
Total: 10.546 seconds, 94.825 RPS

Видно, что вариант, в котором мы заново устанавливаем соединение после каждого запроса, работает существенно быстрее (почти в 3 (!) раза). Хотя казалось бы, установка соединения - дело не бесплатное. В чём же дело?

Немного теории о TCP

Протокол TCP - это протокол транспортного уровня. Его основная задача - обеспечивать надёжную (без потерь) доставку данных с сохранением порядка (данные отправленные раньше, приёмник получит раньше). От приложения TCP получает последовательность байт, которую требуется отправить на другую сторону и уже сам разделяет её на сегменты (блоки данных с заголовками), которые уходят для передачи в протокол более низкого уровня.

Не вдаваясь в подробности, скажем, что для обеспечения надёжности и сохранения порядка, TCP нумерует сегменты данных. Также, существует специальный вид сегмента - ACK (подтверждение), которым приёмник сообщает отправителю о том, что он получил сегмент с заданным номером. Если отправитель не получает ACK в течение некоторого времени, он отправляет сегмент повторно.

Также, для уменьшения нагрузки на сеть, TCP буферизует данные, получаемые от приложения (чтобы не отправлять слишком маленькие сегменты). Ещё, для ускорения передачи, отправитель может отправить несколько сегментов, не дожидаясь ACK от получателя (размер такого буфера называется размером окна).

Обмен данными по TCP можно разделить на две фазы: установка соединения и передача данных. При установке соединения клиент посылает серверу сегмент с флагом SYN, получает от сервера сегмент SYN-ACK и потом отправляет серверу ACK, после чего соединение считается установленным. Далее стороны обмениваются сегментами (в одном сегменте может быть как подтверждение получения, так и какие-то данные).

Ещё есть процедура закрытия соединения, но она нам здесь неинтересна. Про неё и прочие подробности работы TCP можно почитать в Википедии.

Продолжаем эксперименты

Возвращаемся к нашим серверу и клиенту. Попробуем запустить всё то же самое на виртуальной машине (просто потому что я на ней отлаживал). Получаем странное.

Testing 1000 requests, reconnect after 1000 size 2048
Testing IP socket:
Total: 2.852 seconds, 350.674 RPS
Testing 1000 requests, reconnect after 1 size 2048
Testing IP socket:
Total: 10.654 seconds, 93.861 RPS

С реконнектами работает медленнее. Эффекта, который меня заинтересовал, не наблюдается. При этом с реконнектами работает с такой же производительностью, как и на сервере.

В этот момент можно сделать вывод, что что-то происходит именно в случае длительного обмена данными.

Отлаживать подобные время-зависимые эффекты достаточно сложно. Отладчик использовать не получится (любая остановка на точке останова приведёт к нарушению всех таймингов). На сцену в этом случае выходят старые добрые логи. Добавив записей в лог в интересующие нас моменты, с указанием момента времени от старта теста, мы сможем понять, что именно вызывает тормоза.

Добавляем логирование в клиент

Отслеживать будем моменты начала и окончания установки соединения, отправки запроса и получения ответа. Количество тестируемых запросов уменьшаем до 10, чтобы лог был не слишком длинным.

Вариант с переподключениями:

Testing 10 requests, reconnect after 1 size 2048
Testing IP socket:
0.023 start
0.038 begin connecting
0.163 connected
0.437 begin sending
0.477 sent, receiving
10.597 received, time = 10.116
10.646 begin connecting
10.718 connected
11.013 begin sending
11.059 sent, receiving
21.205 received, time = 10.140
21.262 begin connecting
21.361 connected
21.615 begin sending
21.647 sent, receiving
31.760 received, time = 10.109
31.795 begin connecting
31.853 connected
32.092 begin sending
32.125 sent, receiving
42.262 received, time = 10.130
42.321 begin connecting
42.421 connected
42.719 begin sending
42.752 sent, receiving
52.865 received, time = 10.110
52.900 begin connecting
52.956 connected
53.212 begin sending
53.249 sent, receiving
63.387 received, time = 10.131
63.447 begin connecting
63.558 connected
63.784 begin sending
63.817 sent, receiving
73.956 received, time = 10.133
74.016 begin connecting
74.122 connected
74.350 begin sending
74.383 sent, receiving
84.522 received, time = 10.132
84.581 begin connecting
84.678 connected
84.907 begin sending
84.941 sent, receiving
95.084 received, time = 10.138
95.122 begin connecting
95.185 connected
95.413 begin sending
95.447 sent, receiving
105.551 received, time = 10.097
105.613 finish
Total: 0.106 seconds, 94.689 RPS

Вариант без переподключений:

Testing 10 requests, reconnect after 10 size 2048
Testing IP socket:
0.023 start
0.039 begin connecting
0.189 connected
0.430 begin sending
0.475 sent, receiving
10.605 received, time = 10.126
10.868 begin sending
10.910 sent, receiving
21.051 received, time = 10.134
21.429 begin sending
21.536 sent, receiving
31.683 received, time = 10.140
32.090 begin sending
32.144 sent, receiving
42.287 received, time = 10.139
42.517 begin sending
42.549 sent, receiving
93.198 received, time = 50.644
93.439 begin sending
93.462 sent, receiving
103.656 received, time = 10.186
103.947 begin sending
103.970 sent, receiving
114.161 received, time = 10.184
114.480 begin sending
114.503 sent, receiving
165.148 received, time = 50.638
165.566 begin sending
165.592 sent, receiving
175.804 received, time = 10.205
176.110 begin sending
176.146 sent, receiving
186.351 received, time = 10.200
186.384 finish
Total: 0.186 seconds, 53.656 RPS

Ага! Мы видим, что в самом клиенте проблем нет, но вот получение ответа от сервера занимает то 10мс, то 50мс (хотя в варианте с переустановкой соединения всегда около 10мс).

Добавляем логирование в сервер

Дабы не утомлять читателя подробностями, скажу, что после расстановки логов на методы приёма и отправки набора байт в сокет, я пришёл вот к такой истории:

174.513 Receive() start delta 0.038626
217.219 Receive() finish delta 42.7063

Иногда приём очередного запроса затягивается на примерно 40мс. Расставив ещё немного логов, я увидел, что время теряется внутри вызова recv.

113.78 Receive(), before recv delta 0.015474
164.457 Receive(), after recv delta 50.677

Итак, по непонятной пока причине, вызов recv может "застопориться" на целых 40мс на, казалось бы, ровном месте.

Добавляем tcpdump

Поскольку что-то происходит внутри сетевого стека системы, логичным шагом будет включить ему "отладочный лог". Конечно, имеется в виду tcpdump, который при нужных ключах выдаст нам ровно те пакеты, которые "ходят" между нашим клиентом и сервером.
Вот отрывок его лога:

22:58:52.119895 IP localhost.60874 > localhost.19876: Flags [P.], seq 18469:18473, ack 2341, win 512, options [nop,nop,TS val 1743945919 ecr 1743945918], length 4
22:58:52.162347 IP localhost.19876 > localhost.60874: Flags [.], ack 18473, win 512, options [nop,nop,TS val 1743945961 ecr 1743945919], length 0
22:58:52.162368 IP localhost.60874 > localhost.19876: Flags [P.], seq 18473:20521, ack 2341, win 512, options [nop,nop,TS val 1743945961 ecr 1743945961], length 2048

Отсюда видно, что наши сообщения режутся TCP-стеком системы на 2. И после получения первого из них сервер ждёт какое-то время, прежде чем выдать клиенту ACK-пакет с подтверждением получения.

Опции сокетов

Как справедливо заметили в комментариях, возможной причиной такому поведению может быть Nagle's algorithm. Это ещё одна оптимизация TCP, призванная группировать мелкие пакеты, чтобы уменьшить суммарный overhead на заголовки TCP. Суть его в том, чтобы "копить" данные не отправляя их, пока не будет получен ACK на уже отправленную часть. Сам по себе этот алгоритм ничего не ломает, однако в TCP есть ещё одна фича - Delayed ACK. Её суть в том, чтобы дождаться некоторого количества данных от источника, а только затем возвращать ему ACK-сегмент. Собственно так и получается - клиент ждёт ACK от сервера, а тот ждёт следующую порцию данных от клиента.

В прошлый раз я по-быстрому попробовал включить на сокете опцию TCP_NODELAY, которая выключает Nagle's algorithm и заставляет протокол TCP отправлять пакеты "сразу как есть". Однако я сделал это только со стороны клиента, а не со стороны сервера. И это не помогло. Что в общем-то логично, поскольку подобная ситуация с ожиданием совершенно симметрична и может произойти и при отправке сервером ответа. Если же включить опцию на обеих сторонах, проблема исчезает.

Также, в ходе разбирательства я наткнулся на рекомендации по тюнингу TCP от RedHat. В нём упоминается другая опция - TCP_CORK. Если её выставить перед вызовом send, данные будут буферизоваться до тех пор, пока не заполнится системный буфер, либо не будет снята эта опция. Это позволяет "собрать" пакет с данными прямо в системном буфере (что может сэкономить вам немного процессорного времени). Ну и исследуемую проблему эта опция тоже решает. Вероятный минус - заявлено, что она поддерживается только в Linux, потому кроссплатформенность страдает.

Из интересных наблюдений - производительность (в смысле RPS) версии с переподключением и без (после починки проблем с ACK) оказалась практически идентичной. Это как-то контринтуитивно.

Как водится, тестовый проект доступен на гитхабе.

Использованные источники

  1. Описание TCP https://en.wikipedia.org/wiki/Transmission_Control_Protocol

  2. Nagle's algorithm https://en.wikipedia.org/wiki/Nagle's_algorithm

  3. TCP delayed acknowledgement https://en.wikipedia.org/wiki/TCP_delayed_acknowledgment

  4. RHEL Tuning Guide. 4.4. TCP_NODELAY and Small Buffer Writes https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux_for_real_time/7/html/tuning_guide/tcp_nodelay_and_small_buffer_writes

Комментарии (14)


  1. mbait
    00.00.0000 00:00
    -1

    Тесты - огонь! Посылаем полтора байта и ждём ответ, потому срываем покровы на хабре. Чем iperf не угодил?


    1. ftc Автор
      00.00.0000 00:00
      +2

      А чем iperf поможет для организации IPC между плюсовым и PHP-шным приложениями? Изначально-то задача именно в этом стояла. А тут я решил детально разобраться, почему вариант "обмениваться через TCP сокеты" так странно себя ведёт.


      1. AlanDrakes
        00.00.0000 00:00

        У меня когда-то была обратная задача: собрать на минималистичном железе устройство для теста скорости. И из-за ограниченного объёма памяти для буфера приёма (микроконтроллер) мало что получилось. Хотя обмен данными по кабелю между ПК и устройством таки смог выжать почти 90МБит при TCP соединении.


        1. ftc Автор
          00.00.0000 00:00

          А как получилось, что оно именно в память упиралось? Или там размер доступной памяти меньше чем MSS у TCP?


          1. AlanDrakes
            00.00.0000 00:00

            Размер доступной памяти был всего 40кБ, что куда как меньше 64кБ у окна. Приходилось изголяться. А для снижения нагрузки на процессорную часть пытался описать подобие Delayed-ACK, но выходила какая-то фигня. В итоге проект так и не заработал как надо из-за сложности и смены потребностей.


  1. rPman
    00.00.0000 00:00
    -1

    десятков тысяч rps так и не удалось достичь?


    1. ftc Автор
      00.00.0000 00:00

      Не ставил себе такую цель. Хотя интересно, можно ли к варианту с shared memory приблизиться.


  1. ryo_oh_ki
    00.00.0000 00:00
    +2

    10 мс для сетевого пакета это ненормально долго. У вас принципиально что-то не так работает как должно.


    1. ftc Автор
      00.00.0000 00:00

      Почитать бы где-нибудь на тему "а как оно правильнее всего делается".
      Без учёта многопоточности конечно (т.е. оптимизируем именно обмен между двумя процессами, без попыток сэкономить CPU).

      Или если мы тут упираемся в переключения контекста (система не отдаёт нам управление раньше, чем через 10мс) - тогда в таком варианте больше "выжать" не получится. Могу сказать, что у меня эксперимент не совсем чистый - на машине помимо этого теста много всякого крутится, потому это может влиять.


      1. ryo_oh_ki
        00.00.0000 00:00

        Или если мы тут упираемся в переключения контекста (система не отдаёт нам управление раньше, чем через 10мс)

        Это не совсем так работает, "processor time slice" эффективен только при конкуренции за ресурсы ЦП, если есть свободное ядро - всё выполняется параллельно. Более того, это настраиваемый параметр. Но, не думаю, что в вашем случае это имеет значение.


  1. me21
    00.00.0000 00:00
    +1

    То есть у вас сначала в канал уходит 4 байта длины, потом отдельным пакетом тело? Выглядит, как будто алгоритм Нагля как раз отключён и мелкие пакеты не группируются.


    1. ftc Автор
      00.00.0000 00:00

      Да, именно так. Причем получилось, что я делаю 2 раза write (длина, потом тело), а затем read. А в доке не delayed ack написано, что именно такой кейс работает плохо.


  1. Kohelet
    00.00.0000 00:00
    +3

    Вроде это основы — если вас интересует производительность, собирайте все, что нужно отправить, в одну операцию send.


    1. ftc Автор
      00.00.0000 00:00
      +4

      Почему и написал эту статью - как по мне, получился хороший пример принципа дырявых абстракций. Мораль - если воспринимать TCP-коннект просто как канал, куда можно посылать и получать байты - можно получить неожиданные и неприятные эффекты.