Как алгоритм Нейгла добавляет вашему API ровно 40 миллисекунд
Вы гоняете нагрузочный тест, и p50 вашего API упрямо держится на отметке, которая на глаз кратна какому-то круглому числу миллисекунд — и не сдвигается, сколько ни оптимизируй код обработчика. Если это число похоже на 40 мс, 100 мс или 200 мс и не зависит от размера ответа, а зависит только от того, что запрос и ответ маленькие — вы, скорее всего, столкнулись не с багом в своём коде, а с сорокалетним компромиссом внутри стека TCP. Разбираемся, откуда берётся эта задержка и как её выключить одной строчкой.
Содержание
- Зачем вообще нужен алгоритм Нейгла
- Конфликт с delayed ACK: откуда берётся фиксированная задержка
- Кто реально страдает от этой задержки
- TCP_NODELAY: как отключить буферизацию Нейгла
- Практика: где ставить TCP_NODELAY, а где не трогать
- Как проверить, что дело именно в этом
- Смежные грабли сетевого стека, которые маскируются под ту же проблему
Зачем вообще нужен алгоритм Нейгла
Алгоритм Нейгла (Nagle's algorithm) появился в 1984 году как решение конкретной проблемы: интерактивные терминальные сессии по TCP (telnet, rlogin) отправляли в сеть один пакет на каждое нажатие клавиши. Полезной нагрузки в таком пакете — один байт, а заголовков TCP/IP — уже 40 байт. При медленных и дорогих каналах того времени сеть, забитая тысячами крошечных пакетов с накладными расходами в 40 раз больше полезных данных, реально захлёбывалась.
Джон Нейгл предложил простое правило: если у соединения есть неподтверждённые (unacknowledged) данные в полёте, новые маленькие порции данных не отправляются сразу, а буферизуются на стороне отправителя и накапливаются в один пакет — либо до момента, пока не придёт ACK на предыдущие данные, либо пока накопленных данных не наберётся на полный MSS (maximum segment size). Реализация живёт в самом стеке TCP операционной системы, а не в приложении — включена она по умолчанию практически везде: Linux, Windows, BSD.
Логика работает отлично для своего исходного сценария — печати текста в интерактивной сессии, где задержка в десятки миллисекунд незаметна человеку, а экономия пакетов реальна. Проблема начинается там, где TCP-соединение используется не для потока символов, а для запрос-ответного протокола: клиент отправляет маленький запрос и ждёт ответа, прежде чем отправить следующий. Именно так работает подавляющее большинство современных API — HTTP-запросы с маленьким телом, RPC-вызовы, любые протоколы поверх TCP с паттерном "запрос — ждём — ответ".
Конфликт с delayed ACK: откуда берётся фиксированная задержка
Сам по себе алгоритм Нейгла не был бы проблемой, если бы не встречное поведение получателя — механизм отложенного подтверждения (delayed ACK). Смысл в том же духе экономии: вместо того чтобы подтверждать каждый входящий сегмент отдельным ACK-пакетом, стек TCP на стороне получателя ждёт какое-то время в надежде, что можно будет либо объединить ACK с исходящими данными (piggyback), либо подтвердить сразу несколько сегментов одним пакетом. Если за это время у получателя не появляется, что отправить в обратную сторону, и не приходит второй сегмент, ACK всё равно уходит — но только по истечении таймера отложенного подтверждения.
Вот тут и случается конфликт, который в сетевой литературе иногда называют "Nagle vs delayed ACK deadlock" — не настоящий дедлок в смысле зависания навечно, а взаимная блокировка на время таймера:
- Клиент отправляет маленький запрос. Данные уходят сразу — соединение "чистое", неподтверждённых данных в полёте не было.
- Сервер получает запрос. Он маленький, помещается в один сегмент. Стек TCP сервера не спешит слать ACK — вдруг сейчас же появится, что ответить, и ACK поедет вместе с данными. Включается таймер delayed ACK.
- Приложение сервера начинает готовить ответ — но это происходит на уровне приложения, а не TCP, и стек об этом заранее не знает. Пока приложение не отдало данные сокету, TCP ждёт.
- Если клиент после отправки запроса тоже пытается отправить что-то ещё маленькое (или это тело следующего запроса в том же соединении при пайплайнинге) — алгоритм Нейгла на клиенте не даст ему уйти, потому что есть неподтверждённые данные (тот самый первый запрос, на который ACK ещё не пришёл).
- Оба таймера — Нейгла на одной стороне и delayed ACK на другой — упираются друг в друга, и разрешает ситуацию именно истечение таймера delayed ACK на получателе.
Типичное значение таймера delayed ACK в исторических реализациях BSD-стека — 200 мс, во многих современных ядрах Linux фактическое значение меньше и зависит от реализации и версии. Число 40 миллисекунд в заголовке этой статьи — не универсальная константа протокола, а конкретный иллюстративный случай именно такой задержки, которую можно намерить на практике при определённом сочетании ОС, версии ядра и паттерна трафика. Смысл не в том, чтобы запомнить точное число, а в том, чтобы узнать сам паттерн: если ваша задержка на маленьких запрос-ответных обменах стабильна, воспроизводима и не зависит от размера полезной нагрузки — это почти наверняка не сеть и не диск, а именно эта пара таймеров.
Важный нюанс: конфликт проявляется не при каждом запросе подряд, а именно когда на TCP-соединении есть неподтверждённые данные "в полёте" в момент, когда приложение хочет отправить следующую маленькую порцию. Одиночный запрос-ответ на чистом, только что установленном соединении сработает без штрафа. Штраф ловится там, где приложение шлёт данные несколькими вызовами write()/send() подряд (сначала заголовки, потом тело) или где соединение переиспользуется для последовательности мелких сообщений.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКто реально страдает от этой задержки
Не любой сетевой код одинаково уязвим. Задержка от Нейгла проявляется там, где совпадают два условия одновременно: маленькие порции данных и паттерн запрос-ответ (request-response) с ожиданием на каждом шаге.
- REST/JSON API с частыми мелкими запросами. Особенно если клиент делает несколько последовательных вызовов
writeпередread— например, библиотека сериализации отправляет заголовок HTTP отдельно от тела. - RPC-протоколы (gRPC поверх HTTP/2, Thrift, кастомные бинарные протоколы) при небольшом размере сообщений и синхронном ожидании ответа.
- Протоколы с фрагментированной записью в сокет — когда код приложения делает
socket.send(header), а затем сразуsocket.send(body)вместо одного вызова с уже собранным буфером. Каждый отдельныйsend()— кандидат на то, чтобы застрять за предыдущим неподтверждённым сегментом. - Прокси и балансировщики, которые ретранслируют трафик по частям, а не буферизуют весь ответ перед отправкой.
- Базы данных и key-value хранилища с протоколом "команда — ответ" на одном долгоживущем TCP-соединении (в духе Redis-протокола) — если клиентская или серверная библиотека собирает пакет команды несколькими write вместо одного.
Кто НЕ страдает: потоковая передача больших объёмов данных (загрузка файлов, стриминг видео) — там пакеты и так заполняются под MSS, буферизации просто нечего копить. Также не страдают протоколы, где после отправки нет ожидания немедленного ответа (fire-and-forget), и HTTP/1.1 keep-alive с одним большим телом запроса за раз.
TCP_NODELAY: как отключить буферизацию Нейгла
Отключается поведение Нейгла на уровне сокета опцией TCP_NODELAY. Это булев флаг сокета уровня IPPROTO_TCP, который говорит стеку: отправлять данные немедленно, как только приложение вызвало write/send, не дожидаясь ни ACK на предыдущие данные, ни заполнения сегмента до MSS.
На уровне системного вызова в С это выглядит так:
#include <netinet/in.h>
#include <netinet/tcp.h>
int flag = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (char *)&flag, sizeof(int));
В прикладных языках это обычно одна опция при создании клиента или сервера, а не сырой setsockopt.
Node.js:
const server = net.createServer((socket) => {
socket.setNoDelay(true); // отключить алгоритм Нейгла
});
Go (net/http уже включает TCP_NODELAY по умолчанию через net.Dialer, но на сыром net.Conn иногда нужно выставить явно):
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true)
}
Python:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
Java: socket.setTcpNoDelay(true);
Nginx (директива tcp_nodelay, по умолчанию включена для keep-alive):
http {
tcp_nodelay on;
}
Обратная опция для полноты картины — TCP_CORK на Linux (и аналог TCP_NOPUSH на BSD/macOS): она делает противоположное, принудительно копит данные до явной "распечатки" пробки, и полезна там, где вы, наоборот, хотите гарантированно собрать несколько частей ответа в один пакет перед отправкой (например, заголовки плюс начало файла через sendfile). TCP_NODELAY и TCP_CORK — взаимоисключающие стратегии для разных сценариев, а не конкурирующие варианты одного и того же.
Практика: где ставить TCP_NODELAY, а где не трогать
Правило простое: включайте TCP_NODELAY там, где важна латентность единичного маленького сообщения, и не трогайте настройки по умолчанию там, где идёт объёмная передача данных.
Стоит включать:
- Серверная часть любого низколатентного API — HTTP-фреймворки на вашем сервере, RPC-серверы, WebSocket-серверы с частыми короткими сообщениями.
- Клиентские библиотеки, обращающиеся к базам данных и кэшам с протоколом команда-ответ, если вы пишете свой клиент или тюните существующий.
- Игровые серверы и любые real-time протоколы, где 40, 100 или 200 мс — это уже заметная деградация отклика.
- Микросервисная коммуникация внутри дата-центра, где сеть быстрая и надёжная, а каждая лишняя фиксированная задержка на hop суммируется по цепочке вызовов.
Стоит оставить по умолчанию (включённый Нейгл):
- Каналы с высоким RTT и ограниченной пропускной способностью, где реально важно не заваливать сеть мелкими пакетами (спутниковые каналы, мобильные сети с плохим покрытием) — здесь исходная логика Нейгла всё ещё работает на вас.
- Потоковая передача больших объёмов — там алгоритм почти не участвует, а трогать настройки без причины не стоит.
- Случаи, когда вы не уверены, что проблема именно в этом — сначала измерьте, а не меняйте настройки вслепую.
Более правильная альтернатива для многих случаев: вместо того чтобы гоняться за TCP_NODELAY на каждом сокете, часто продуктивнее переписать код так, чтобы приложение отправляло весь пакет данных одним вызовом write/send, собрав заголовок и тело в один буфер заранее. Если TCP видит только один вызов записи вместо двух-трёх, конфликтовать с delayed ACK банально нечему — задержка не возникает независимо от того, включён Нейгл или нет. Это решает проблему на уровне причины, а не следствия, и не требует трогать сокетные опции вообще.
Как проверить, что дело именно в этом
Прежде чем менять код или конфиги, стоит подтвердить гипотезу, а не гадать.
- Снимите дамп трафика во время проблемного запроса:
tcpdump -i any -w capture.pcap host <ip-сервера> and port <порт>
Откройте в Wireshark и посмотрите на временные метки между сегментами. Характерный паттерн: пакет с данными уходит, дальше пауза заметной длины, потом ACK, и только следом — следующий сегмент.
- Проверьте, зависит ли задержка от размера тела запроса. Растёт линейно с размером — это не Нейгл, а пропускная способность или обработка на сервере. Одинаковая что для 10, что для 200 байт — похоже на фиксированный таймер.
- Проверьте паттерн вызовов записи в коде. Ищите места, где заголовки и тело отправляются раздельными вызовами
write/sendвместо одного собранного буфера — самый частый источник проблемы именно в собственном коде.
- Временно включите TCP_NODELAY и повторите тест. Задержка исчезла — гипотеза подтверждена. Не изменилось — ищите причину в другом месте: DNS, TLS handshake, обработка на сервере, TCP-рукопожатие на новых соединениях без keep-alive.
- Профилируйте само приложение, если задержка после отключения Нейгла уменьшилась, но не исчезла полностью — пригодится профилирование приложения на проде без остановки сервиса.
Смежные грабли сетевого стека, которые маскируются под ту же проблему
Задержка на мелких запросах — симптом, у которого несколько разных причин, и Нейгл — только одна из них. Стоит уметь отличать их друг от друга, чтобы не чинить не то.
| Симптом | Похоже на Нейгла? | Реальная причина |
|---|---|---|
| Фиксированная задержка ~200 мс на каждом первом запросе после паузы | Да, но может быть и delayed ACK сам по себе | Конфликт Нейгл + delayed ACK или чистый delayed ACK при отсутствии встречных данных |
| Задержка растёт с числом одновременных соединений | Нет | Исчерпание буферов сокетов, файловых дескрипторов, backlog |
| Первые пакеты соединения медленные, потом разгоняется | Нет | TCP slow start — постепенное открытие congestion window |
| Широкий канал, но одно соединение не даёт полной скорости | Нет | Малое TCP-окно приёма при высоком RTT |
| Сообщения по сокету доходят с задержкой или теряются при высокой нагрузке | Нет, хотя выглядит похоже | Переполнение буфера отправки на стороне приложения — отдельный разбор такого случая |
| Задержка появляется только под нагрузкой, растёт нелинейно | Нет | Насыщение CPU, GC-паузы, блокировка на локах в коде обработчика |
Отличить конфликт Нейгл/delayed ACK от остальных причин проще всего именно по признаку "фиксированная задержка, не зависящая от размера данных и от нагрузки, но зависящая от паттерна записи в сокет".
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Нужно ли включать TCP_NODELAY везде по умолчанию, "на всякий случай"?
Нет. Для потоковой передачи больших объёмов или для каналов с плохим RTT это может увеличить число мелких пакетов в сети без пользы. Включайте осознанно там, где измерили задержку и подтвердили причину, а не профилактически везде.
HTTP/2 и HTTP/3 решают эту проблему сами?
HTTP/2 мультиплексирует потоки поверх одного TCP-соединения, но само TCP-соединение всё ещё подвержено логике Нейгла и delayed ACK на уровне сегментов — многие серверные реализации HTTP/2 включают TCP_NODELAY по умолчанию именно поэтому. HTTP/3 работает поверх QUIC (UDP), где алгоритма Нейгла в этом виде вообще нет — это одна из причин, почему QUIC в принципе избегает целого класса подобных задержек.
Может ли проблема быть не в Нейгле, а в delayed ACK как таковом?
Да. Даже с TCP_NODELAY на отправителе, если получатель всё равно откладывает ACK, а отправителю по какой-то причине важно дождаться его перед следующим действием на уровне приложения, задержка может сохраняться. Delayed ACK настраивается на стороне получателя отдельно от Нейгла (в Linux — не через простой sysctl, а зависит от реализации сокета и в отдельных случаях доступен через TCP_QUICKACK), и трогать его нужно куда осторожнее.
Отключение Нейгла может что-то сломать?
Прямого риска для корректности нет — это переключатель производительности, а не поведения протокола. Побочный эффект — больше мелких пакетов в сети, что при плохом или дорогом канале может увеличить не задержку, а нагрузку на сеть и, в редких случаях, частоту потерь пакетов на перегруженных участках.
Как быстро проверить состояние опции на уже работающем сокете в Linux?
Через ss -tin можно посмотреть детали TCP-соединений, включая ряд внутренних параметров, но сам флаг TCP_NODELAY напрямую в выводе ss не всегда виден явно — надёжнее проверять на уровне кода приложения, что опция реально выставлена при создании сокета.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →