Дело всегда в TCP_NODELAY

от автора


Занимаясь отладкой проблем в легаси-системах, я первым делом проверяю, включён ли режим TCP_NODELAY. И так делаю не только я. Все знакомые мне разработчики распределённых систем потратили немало часов на решение проблем с задержкой, которые быстро исправлялись простым включением этой опции сокета, указывая на ошибочность базовых настроек или использование устаревшей концепции.

Но для начала давайте проясним, о чём вообще речь. Лучше всего нам в этом поможет документ RFC896, изданный Джоном Нейглом в 19841 году. Вот описание задачи:

Обработка малых пакетов сопряжена с одной специфичной проблемой. Когда для передачи односимвольных сообщений с клавиатуры используется TCP, обычно для отправки одного байта полезных данных задействуется пакет размером 41 байт (один байт данных и 40 байт заголовка). Такие издержки в 4 000%, конечно, раздражают, но в слабо нагруженных сетях ещё терпимы.

Если коротко, то Нейгл хотел сократить затраты на отправку TCP-заголовков, чтобы повысить пропускную способность сети — вплоть до 40х! Эти крохотные пакеты поступали из двух основных источников: интерактивных приложений вроде оболочек, где пользователь вводил по одному байту за раз, и плохо реализованных программ, которые передавали сообщения ядру через множество вызовов write(). Предложенное Нейглом исправление оказалось простым и продуманным.

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

Когда люди говорят об алгоритме Нейгла, то зачастую имеют ввиду таймеры, но в RFC896 не используются никакие таймеры, кроме оценки времени приёма-передачи пакета (Round-Trip Time, RTT).

▍ Алгоритм Нейгла и отложенное подтверждение

Чёткое и лаконичное предложение Нейгла плохо сочеталось с ещё одной особенностью TCP: отложенным ACK. Суть этого механизма в откладывании отправки подтверждения о получении пакета до момента, пока не вернутся какие-нибудь данные (например, ответ сеанса telnet на ввод пользователя), или не истечёт таймер. Впервые отложенные ACK были предложены в 1982 году в документе RFC813:

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

Впоследствии этот принцип был доработан в документе RFC1122 от 1989 года. В итоге взаимодействие между двумя описанными механизмами создаёт проблему: алгоритм Нейгла блокирует отправку очередной порции данных, пока не будет получен ACK, но в то же время этот ACK откладывается, пока не будет готов ответ. Отличное решение для сохранения целостности пакетов, но плохое для чувствительных к задержке приложений, работающих в составе пайплайна.

И на это неоднократно указывал сам Нейгл, например, в своём комментарии на Hacker News:

Это до сих пор меня тревожит. И реальная проблема не в блокировке отправки мелких пакетов. Оба этих механизма были внедрены в TCP одновременно, но независимо. Я реализовал блокировку отправки небольших пакетов (алгоритм Нейгла) в начале 1980 года, и Беркли создал свой механизм отложенных ACK тогда же. Но комбинация двух этих решений получилась ужасная.

Проектировщикам систем подобная ситуация должна быть знакома: две разумных функции, совместно создающие нежелательное поведение. Такой вид взаимодействия реально усложняет проектирование протоколов.

▍ Виновен ли Нейгл?

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

Чтобы лучше прояснить ситуацию, давайте вернёмся к обоснованию алгоритма Нейгла: сокращение затрат на заголовки и избежание 40х издержек, связанных с отправкой пакетов размером 1 байт.

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

Фундаментальная проблема отсутствия отправки крохотных сообщений по-прежнему актуальна, но мы весьма эффективно сместили её на прикладной уровень. Передача данных по одному байту, обёрнутому в JSON, не обеспечит особой эффективности, что бы там алгоритм Нейгла ни делал.

▍ Нужен ли этот алгоритм?

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

Есть и неоднозначный момент. Учитывая смесь траффика и приложений, а также нынешние аппаратные возможности, я считаю, что алгоритм Нейгла в современных системах просто не нужен. Иными словами, TCP_NODELAY должен использоваться по умолчанию. Это немного замедлит код, который «пишет байты по очереди», но если нас волнует эффективность, то такие приложения всё равно нужно исправлять.

▍ Примечания

  1. Здесь я не стану углубляться в эту тему, но RFC896 также является одним из первых документов, в котором описывается метастабильное поведение компьютерных сетей. В нём Нейгл говорит: «Это состояние стабильно. Когда достигается точка насыщения, и алгоритм выбора пакетов для отбрасывания справедлив, сеть продолжит работать с пониженной эффективностью». ↩
  2. После публикации этой статьи некоторые читатели начали спрашивать о TCP_QUICKACK. Я не использую этот механизм по ряду причин, включая отсутствие портируемости и странную семантику (серьёзно, почитайте мануал). Основная же проблема в том, что
    TCP_QUICKACK не исправляет фундаментальную проблему удерживания ядром данных дольше, чем нужно моей программе. Когда я запрашиваю write(), то ожидаю выполнения write(). ↩

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻


ссылка на оригинал статьи https://habr.com/ru/articles/890874/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *