Буферы сокета: почему данные ушли, но приложение их ещё не получило
Вызов send() вернул положительное число, ошибки нет — и разработчик считает, что данные дошли до собеседника. На практике успешный send() говорит только о том, что байты скопированы в буфер ядра на вашей стороне. Дальше начинается путь, который из прикладного кода не виден, и на этом пути данные могут застрять надолго — а приложение об этом не узнает, если не спросит явно.
Содержание
Что на самом деле происходит внутри send()
Когда приложение вызывает send() или write() на TCP-сокете, ядро не отправляет данные в сеть синхронно. Оно копирует байты из пользовательского буфера в буфер отправки сокета — структуру в памяти ядра, принадлежащую этому соединению, — и сразу возвращает управление с числом скопированных байт. Дальше ядро само решает, когда и какими порциями отправлять их по сети. Отсюда следствие: send() — это не «отправь», а «прими у меня эти байты и отправь, когда сможешь». Разница объясняет половину багов вида «клиент отправил, а сервер получил через секунду».
Буфер отправки не бесконечен — размер ограничен SO_SNDBUF (в Linux — через net.core.wmem_max и net.ipv4.tcp_wmem). Пока в буфере есть место, send() копирует данные быстро. Как только он заполняется — например, сеть не успевает вычитывать данные так же быстро, как их производит приложение, — в блокирующем режиме send() ждёт освобождения места, в неблокирующем возвращает EWOULDBLOCK/EAGAIN, и приложение повторяет попытку по событию готовности к записи (epoll/select/kqueue). Ни один из сценариев не говорит о судьбе данных у получателя — они пока даже не покинули вашу машину.
Два независимых буфера, которые не видят друг друга
У TCP-соединения не один буфер, а два, и они физически принадлежат разным машинам:
- Буфер отправки (send buffer) — в ядре отправителя. В него копирует данные ваш
send(). Из него стек TCP формирует сегменты и отправляет их, ожидая подтверждений (ACK). - Буфер приёма (receive buffer) — в ядре получателя. В него попадают байты, пришедшие по сети и подтверждённые на уровне TCP. Из этого буфера приложение забирает данные вызовом
recv()/read().
Единственная связь между ними — сам протокол: сегменты, ACK и окно приёма (receive window), которым получатель сообщает отправителю, сколько места у него осталось. TCP гарантирует, что байты, дошедшие до приложения-получателя, дойдут по порядку и без потерь (либо соединение разорвётся с ошибкой) — но ничего не гарантирует про то, *когда* и *забрало ли их вообще* приложение из своего буфера приёма.
Между «скопировано в буфер отправки» и «обработано кодом получателя» лежит четыре события, каждое из которых может задержаться: данные уходят в сеть сегментами → сегменты доходят до стека получателя и подтверждаются (ACK) → подтверждённые байты попадают в буфер приёма → приложение вызывает recv()/read() и забирает байты в свою память. Успешный send() в лучшем случае гарантирует первый шаг, и то не мгновенно при перегруженной сети. Остальные шаги из вашего кода не видны, если вы не строите отдельный механизм подтверждения на уровне приложения.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему это не автоматика
Причины, почему это не так, вполне приземлённые: приложение-получатель занято предыдущим сообщением и ещё не дошло в цикле событий до recv(); процесс завис, ушёл в своп или блокируется на медленном диске — данные тем временем лежат в буфере приёма, а TCP-уровень выглядит здоровым; в однопоточном event-loop с большим числом соединений обработка конкретного сокета стоит в очереди за десятками других; наконец, приложение могло упасть или быть убитым OOM-killer'ом уже после того, как TCP подтвердил приём данных ядром, но до того, как код успел их прочитать.
Во всех этих случаях данные лежат в буфере приёма, соединение живо, ACK отправлен — а бизнес-логика их ещё не видела. Если отправитель не видит ошибок send(), вывод «соединение живое — значит, всё обработано» звучит логично, но неверен. Это и есть суть ложного чувства завершённости: каждый уровень API сокета выглядит как «успех», хотя ни один не говорит о завершении работы, ради которой данные вообще отправлялись — об их фактической обработке приложением на другом конце.
Как переполнение буфера маскируется под зависание
Оба буфера конечны, и это создаёт обратную связь, которую легко принять за баг. Если получатель не вычитывает данные достаточно быстро, его буфер приёма заполняется. TCP реагирует честно: получатель уменьшает объявляемое окно приёма, сообщая отправителю «больше не присылай, места нет» — это часть встроенного в протокол управления потоком (flow control), подробнее о влиянии размера окна на скорость — в разборе TCP-окна и задержек на длинных каналах.
Если буфер приёма получателя стоит заполненным долго, у отправителя постепенно заполняется и его собственный буфер отправки — ядро не может вытолкнуть данные в сеть быстрее, чем позволяет окно. Когда заполняется и он, блокирующий send() начинает реально «висеть», а неблокирующий — сыпать EAGAIN. Внешне это путают с «сеть медленная», хотя первопричина — приложение-получатель, не успевающее читать сокет. Похожий сценарий с обратной стороны — переполнение буфера отправки без единой ошибки от ядра — разобран в статье про молчаливое переполнение буфера отправки на живом сокете.
Посмотреть состояние буферов конкретного соединения:
ss -tn '( dport = :443 or sport = :443 )' # Send-Q / Recv-Q
ss -tin # окно, rtt, буферы подробнее
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem # системные лимиты: min, default, max
Send-Q на стороне отправителя — то, что скопировано в буфер отправки, но ещё не подтверждено. Recv-Q на стороне получателя — то, что уже пришло по сети, но приложение ещё не забрало вызовом read(). Устойчиво растущий Recv-Q на сервере — прямой признак того, что процесс не успевает вычитывать сокет, а не проблема сети. Увеличение буферов через SO_SNDBUF/SO_RCVBUF сглаживает кратковременные всплески, но не решает проблему в принципе — буфер любого размера конечен, а гарантии обработки данных приложением он не даёт никогда.
Пример: почему лог «отправлено» ничего не доказывает
Сервис A отправляет сервису B сообщение по TCP-сокету и логирует факт отправки:
# Сервис A (отправитель)
sock.sendall(payload)
logger.info("message sent") # событие о буфере отправки ядра A, не о B
# Сервис B (получатель), event loop перегружен
data = sock.recv(4096) # выполнится через миллисекунды, а может — через секунды под нагрузкой
process(data)
logger.info("message processed")
Если между «message sent» на A и «message processed» на B проходит заметное время — это не аномалия сети, а нормальная работа двух независимых буферов и процессов, синхронизированных только байтовым потоком TCP, а не моментом обработки. Проблема начинается, когда код A трактует «message sent» как доказательство того, что B обработал сообщение, — и, например, отвечает пользователю «готово», хотя B мог упасть через миллисекунду после того, как ядро подтвердило приём байтов.
Что делать вместо надежды на успешный send()
TCP и send() дают гарантии на уровне байтового потока (доставлен по порядку, без потерь, либо соединение разорвано с ошибкой), а не на уровне «приложение обработало запрос». Единственный надёжный способ узнать о реальной обработке — явное подтверждение на уровне протокола приложения:
- Запрос-ответ: отправитель не считает операцию завершённой, пока не получит явный прикладной ответ («получил и обработал»), а не просто TCP ACK — так устроены HTTP и большинство RPC-протоколов.
- ID сообщений и прикладные подтверждения: каждое сообщение получает уникальный ID, получатель подтверждает обработку именно этого ID — отправитель повторяет отправку, если подтверждение не пришло за разумное время.
- Таймауты и ретраи на уровне приложения, а не только TCP: TCP переотправляет потерянные сегменты, но не знает, обработало ли приложение уже доставленные байты. Ретраи требуют идемпотентности операций, чтобы повторная отправка не приводила к двойной обработке.
- Для очередей сообщений и брокеров — встроенный acknowledgement, который и существует, чтобы закрыть разрыв между «байты попали в сокет» и «сообщение обработано бизнес-логикой».
При диагностике задержек полагайтесь не на отсутствие ошибок в send(), а на метрики уровня приложения: время до прикладного ответа, глубину Recv-Q, логи получателя. TCP-уровень тут необходим, но недостаточен: он честно доставляет байты, но ничего не знает про вашу бизнес-логику. Если же проблема не в буферах, а в накопившихся долгоживущих соединениях — например, после коротких запросов на сервере скапливаются тысячи сокетов в TIME_WAIT, — это отдельная механика, разобранная в статье про то, зачем нужен TIME_WAIT и откуда берутся тысячи таких соединений.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Если send() вернул ошибку, значит данные точно не дошли?
Не совсем: ошибка send() (ECONNRESET, EPIPE) говорит о проблеме с соединением на момент вызова, но часть ранее отправленных и подтверждённых байт могла быть доставлена и даже обработана до разрыва. Обратное тоже не гарантия: соединение может оборваться уже после того, как send() скопировал данные в буфер отправки, но до того, как они реально ушли в сеть.
TCP же гарантирует доставку — зачем ещё что-то подтверждать?
TCP гарантирует доставку байтов в буфер приёма получателя по порядку и без потерь. Он не знает, прочитало ли эти байты приложение и не упало ли посреди обработки — закрыть этот разрыв может только протокол приложения.
Можно ли одним системным вызовом узнать, что данные точно долетели и обработаны?
Нет: ядро отправителя не имеет доступа к состоянию процесса-получателя. Максимум на уровне сокета — статистика через TCP_INFO (ss -i), которая покажет, подтверждены ли байты на уровне TCP. Про обработку скажет только само приложение — явным ответом.
Помогает ли увеличение SO_SNDBUF/SO_RCVBUF?
Сглаживает кратковременные всплески, но не решает проблему в принципе — буфер конечен при любом размере, а гарантии обработки не даёт никогда. Это оптимизация пропускной способности, а не механизм подтверждения.
Как понять, что именно тормозит — сеть или приложение-получатель?
Посмотрите ss -tn на принимающей стороне: устойчиво растущий Recv-Q означает, что данные пришли, но приложение не успевает их вычитывать. Если Recv-Q в норме, а задержки всё равно есть — смотрите дальше по стеку: очередь событий, блокировки, диск, паузы сборщика мусора.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →