Очередь accept: соединение установлено, а сервер о нём не знает
Клиент подключился, отправил запрос и ждёт. Сервер при этом не вызвал `accept` ни разу и о клиенте не подозревает. Между ними стоит очередь ядра — и почти всё, что выглядит как «сеть подтормаживает», на самом деле происходит в ней.
Полное техническое изложение
TL;DR
Между «клиент подключился» и «сервер узнал о клиенте» стоит очередь.
Соединение устанавливает ядро; приложение потом забирает готовое вызовом
accept. Пока оно не позвало — клиент уже подключён, а сервер о нём не знает.
Отсюда главное следствие: занятый сервер выглядит для клиента работающим.
Измерено: сервер не вызвал accept ни разу, а клиент подключился за 0,05 мс
и отправил 18 байт. И при переполнении очереди клиент получает не отказ, а
ожидание: пакет молча отброшен, TCP его повторяет, в логах сервера — ничего.
Дальше — то, что отличает знающего от читавшего. Очередей две, и
переполняются они по разным причинам. Очередь видна снаружи: у слушающего
сокета ss -lnt показывает Send-Q как вместимость и Recv-Q как длину.
Число из listen(backlog=…) молча урезается до somaxconn — измерено: при
somaxconn=2 очередь получилась на 2, хотя в коде стояло 100. А на этом ядре
в очередь помещается на одно соединение больше, чем указано в backlog.
- клиент отправляет запрос серверу, сервер отвечает;
- TCP-соединение устанавливается до того, как по нему пойдут данные;
- сервер открывает слушающий сокет и как-то получает из него входящие соединения.
- как устроено рукопожатие TCP по пакетам, что такое SYN и полуоткрытое соединение;
backlog,somaxconn,Recv-Q,tcp_abort_on_overflow.
Что здесь на самом деле спрашивают
Лестница обычно такая:
- «Что делает
listen?» — разминка про сокет, который переходит в пассивное состояние. - «Что такое backlog?» — здесь начинается содержание: длина очереди чего именно.
- «Клиент подключился, а сервер занят. Где находится запрос?» — вопрос, на котором становится видно, знает ли собеседник про очередь ядра.
- «Что увидит клиент, когда очередь переполнится?» — вопрос-ловушка: он увидит не отказ.
- «Почему в логах сервера пусто, а клиенты жалуются на таймауты?» — вопрос про то, что очередь не оставляет следов в приложении.
- «Мы поставили backlog в тысячу — почему не помогло?» — вопрос про
somaxconn.
Числа получены прогоном bench/acceptq/backlog.py и
bench/acceptq/practice.py на loopback. Времени здесь почти нет: считаются
соединения и читаются счётчики ядра: миллисекунды у вас будут своими, а
счёт соединений и исходы — те же.
База: что делает сервер, пока никого нет
Прежде чем говорить об очереди, стоит назвать обычными словами, что вообще происходит, — потому что очередь появляется ровно между двумя шагами этого списка.
Сервер, который ждёт клиентов, делает четыре вещи по порядку:
- заводит сокет — точку, через которую можно говорить по сети;
- привязывает его к адресу и порту — теперь понятно, куда стучаться;
- объявляет его слушающим — сокет переходит в пассивное состояние и больше не будет никуда подключаться сам, он будет принимать;
- принимает соединения — по одному, в цикле, каждый раз получая отдельный сокет для разговора именно с этим клиентом.
Ключевое здесь — четвёртый шаг. Слушающий сокет не превращается в соединение: он остаётся на месте и служит источником, из которого сервер достаёт готовые соединения, каждое со своим отдельным сокетом.
И вот главный вопрос урока: что происходит между третьим и четвёртым шагом? Клиент ведь может постучаться в любой момент — в том числе тогда, когда сервер занят предыдущим клиентом и до четвёртого шага ещё не дошёл.
Ответ такой: договорённость о соединении — работа ядра, а не приложения. Ядро проводит её само, кладёт готовое соединение в очередь и ждёт, когда приложение придёт за ним. Приложение узнаёт о клиенте не в тот момент, когда тот подключился, а в тот, когда само дошло до четвёртого шага.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Всё дальнейшее — про то, что эта очередь конечна, что их на самом деле две и что происходит, когда очередь кончается.
Механизм 1: соединение устанавливает ядро
Теперь то же самое, но словами документации. Начнём с того, что делает
accept — и чего он не делает:
It extracts the first connection request on the queue of pending connections for
the listening socket, sockfd, creates a new connected socket, and returns a new
file descriptor referring to that socket.
Он извлекает первый запрос на соединение из очереди ожидающих соединений для слушающего сокета sockfd, создаёт новый подключённый сокет и возвращает новый файловый дескриптор, ссылающийся на этот сокет.
Извлекает из очереди. Значит, к моменту вызова соединение уже есть: рукопожатие провело ядро, пока приложение занималось чем-то другим. Приложение не устанавливает соединение — оно забирает установленное.
Проверяем крайним случаем: сервер, который не вызывает accept ни разу.
1. THE SERVER NEVER CALLS ACCEPT, AND THE CLIENT CONNECTS ANYWAY
----------------------------------------------------------------
server called accept no, not once
client's connect() returned success in 0.05 ms
bytes the client managed to send 18
listening socket in ss Recv-Q=1 Send-Q=5
Строка ss относится к сокету, открытому в этом блоке с listen(backlog=5):
Send-Q — это вместимость очереди, то есть сам backlog, а Recv-Q=1 — то
единственное соединение, которое в ней стоит.
Клиент подключился. Клиент отправил восемнадцать байт, и они приняты. Клиент
уверен, что разговаривает с сервером, — а сервер о нём не знает и не узнает,
пока не позовёт accept.
Отсюда главное следствие урока: со стороны клиента «сервер занят» и «сервер работает» выглядят одинаково ровно до момента, когда истечёт его собственный таймаут ожидания ответа. Всё это время запрос стоит в очереди, и ни в одном логе приложения его нет.
Механизм 2: очередей две, и переполняются они по-разному
Вторая вещь, которую спрашивают: сколько там на самом деле очередей.
Очередь неподтверждённых держит соединения, у которых рукопожатие ещё не
закончено: пришёл первый пакет, ответ отправлен, подтверждения от клиента нет.
Её размер задаётся отдельно, и tcp(7) описывает его так:
The maximum number of queued connection requests which have still not received
an acknowledgement from the connecting client. If this number is exceeded, the
kernel will begin dropping requests.
Максимальное число запросов на соединение, ожидающих в очереди и ещё не получивших подтверждения от подключающегося клиента. При превышении этого числа ядро начинает отбрасывать запросы.
Очередь готовых — та самая, из которой берёт accept. Её размер задаёт
backlog:
The backlog argument defines the maximum length to which the queue of pending
connections for sockfd may grow.
Аргумент backlog задаёт максимальную длину, до которой может вырасти очередь ожидающих соединений для sockfd.
Разница практическая. Первая очередь переполняется, когда клиентов слишком
много или когда до вас идёт поток полуоткрытых соединений. Вторая
переполняется, когда приложение не успевает звать accept — то есть от
собственной медлительности, а не от сети.
Сколько вмещает вторая, проверяется счётом:
2. HOW MANY FIT: BACKLOG 1 AGAINST BACKLOG 8
--------------------------------------------
listen(backlog=1): connections accepted by the kernel 2
queue as ss sees it Recv-Q=2 Send-Q=1
what stopped the loop timeout: the client waits, nobody refused it
listen(backlog=8): connections accepted by the kernel 9
queue as ss sees it Recv-Q=9 Send-Q=8
what stopped the loop timeout: the client waits, nobody refused it
Плюс один. При backlog=1 помещается два соединения, при backlog=8 —
девять. Это не ошибка замера: на этом ядре в очередь проходит на одно
соединение больше, чем указано в backlog.
И вот здесь важно не превратить наблюдение в правило. listen(2) называет
backlog максимальной длиной, до которой очередь может вырасти, и не
обещает точного соответствия числу; POSIX и вовсе трактует его как пожелание,
которое реализация вправе истолковать по-своему. «Плюс один» — свойство этой
версии Linux, а не контракт: на другом ядре арифметика может оказаться другой,
и код, рассчитанный на ровно backlog + 1, сломается молча.
Правильный вывод отсюда обратный по знаку: backlog — это порядок величины,
а не точная вместимость. Если вам нужно знать фактическую длину очереди, её
не вычисляют из кода, а смотрят — чем и займёмся дальше.
И обратите внимание на Recv-Q/Send-Q в выводе ss: у слушающего сокета
они означают не то, что у обычного. Send-Q — вместимость очереди, Recv-Q —
её текущая длина. Это и есть способ увидеть очередь снаружи, не трогая
приложение.
Механизм 3: переполнение выглядит как таймаут, а не как отказ
Теперь ловушка, ради которой стоит весь урок. Что видит клиент, когда очередь заполнена?
If a connection request arrives when the queue is full, the client may receive an
error with an indication of ECONNREFUSED or, if the underlying protocol supports
retransmission, the request may be ignored so that a later reattempt at
connection succeeds.
Если запрос на соединение приходит при полной очереди, клиент может получить ошибку с указанием ECONNREFUSED или, если нижележащий протокол поддерживает повторную передачу, запрос может быть проигнорирован, чтобы более поздняя повторная попытка соединения удалась.
TCP повторную передачу поддерживает — значит, работает второй вариант:
3. WHAT OVERFLOW LOOKS LIKE FROM THE CLIENT
-------------------------------------------
connections before the loop stopped 2
how it ended timeout: the client waits, nobody refused it
tcp_abort_on_overflow 0
Ни одного ECONNREFUSED. Клиент ждёт, потому что его пакет молча
отброшен, и по правилам TCP он повторит попытку. Для наблюдателя это
выглядит так:
- клиент сообщает таймаут соединения и винит сеть;
- в логах сервера — ничего: приложение об этой попытке не узнало;
- метрики сервера показывают, что он жив и обслуживает то, что успел забрать.
Поведение можно поменять — tcp(7) описывает tcp_abort_on_overflow как
переключатель, который заставляет ядро сбрасывать такие соединения вместо
молчания. Тогда клиент получит явный отказ вместо ожидания. Документация тут же
предупреждает, что включать это стоит, только если вы уверены в причине: явный
отказ ломает клиентов, которые повторили бы попытку и дождались.
Глубже: backlog из кода — ещё не очередь
Последняя ступень объясняет, почему «мы поставили тысячу» не работает:
If the backlog argument is greater than the value in
/proc/sys/net/core/somaxconn, then it is silently capped to that value. Since
Linux 5.4, the default in this file is 4096; in earlier kernels, the default
value is 128.
Если аргумент backlog больше значения в /proc/sys/net/core/somaxconn, он молча урезается до этого значения. Начиная с Linux 5.4 значение по умолчанию в этом файле — 4096, в более ранних ядрах — 128.
Слово silently здесь ключевое: ошибки нет, предупреждения нет, listen
возвращает успех. Проверяем, понизив somaxconn до двух:
4. THE BACKLOG IN YOUR CODE IS NOT THE QUEUE YOU GET
----------------------------------------------------
somaxconn on this machine 4096
listen(backlog=100) with that somaxconn Recv-Q=0 Send-Q=100
same listen(backlog=100), somaxconn=2 Recv-Q=0 Send-Q=2
connections the kernel took 3
somaxconn restored 4096
Один и тот же код, две машины — очередь на сто и очередь на два. Отсюда
практическое правило: проверять надо не код, а ss. Send-Q слушающего
сокета показывает то, что получилось, а не то, что просили.
Второе следствие — про порядок при выкатке в новое окружение. Настройка приложения и настройка ядра здесь связаны, и связь односторонняя: ядро ограничивает приложение молча. Значит, в проверку после развёртывания входит не только «сервис отвечает», но и «очередь такой длины, какую мы просили».
Как отвечать на собеседовании
Короткий ответ: между connect клиента и accept сервера стоит очередь
ядра, и соединение в ней уже установлено — договорённость провело ядро, а не
приложение. Поэтому занятый сервер выглядит для клиента работающим, а при
переполнении очереди клиент получает не отказ, а таймаут: пакет молча
отбрасывается, и TCP его повторяет.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Хороший ответ отличают три вещи. Первая — вы говорите про две очереди и
различаете их причины: неподтверждённые соединения переполняются от сети,
готовые — от медленного приложения. Вторая — вы называете наблюдаемое:
Recv-Q и Send-Q у слушающего сокета, и что они значат именно у него.
Третья — вы помните про somaxconn и то, что урезание происходит молча:
число в коде надо проверять по факту, а не по коду.
И одна вещь, на которой легко переусердствовать. Сказать «очередь вмещает
backlog + 1» — значит выдать наблюдение за контракт. listen(2) обещает
только максимум, до которого очередь может вырасти; «плюс один» получился на
конкретном ядре. Точная формулировка звучит так: фактическую вместимость не
выводят из кода, её смотрят в ss — и это заодно правильный ответ на
вопрос, как проверить очередь в production.
Дальше спросят
Почему клиент получил таймаут, если сервер жив и отвечает?
Потому что «сервер отвечает» и «сервер забирает соединения из очереди» — разные
вещи. Пока приложение занято, соединение стоит в очереди готовых; клиент уже
подключён и уже отправил запрос, а ответа не будет, пока не позовут accept.
Измерено: сервер, не вызвавший accept ни разу, принял соединение за 0,05 мс и
восемнадцать байт данных. Значит, метрика «сервер отвечает на проверку» такое
состояние не поймает — нужна метрика длины очереди.
Поможет ли увеличить backlog?
Поможет пережить всплеск: очередь затем и нужна, чтобы короткий пик не превращался в отказы. От постоянной перегрузки она не спасает — если приложение в среднем забирает соединения медленнее, чем они приходят, длинная очередь лишь увеличит время ожидания, а потом всё равно переполнится.
Симптом различения простой: очередь то заполняется, то пустеет — запас имеет смысл; очередь стабильно полная — проблема не в ней. Второй случай лечится скоростью обработки или числом обработчиков.
Как увидеть очередь, не меняя приложение?
У слушающего сокета ss -lnt показывает Recv-Q — сколько соединений ждёт
accept, и Send-Q — сколько их вмещается. Измерено: Recv-Q=9 Send-Q=8 при
listen(backlog=8).
Это самая дешёвая диагностика в теме: она не требует ни изменений в коде, ни
доступа внутрь процесса. Растущий Recv-Q у слушающего сокета — прямое
доказательство того, что дело не в сети.
Что даёт tcp_abort_on_overflow?
Меняет молчание на явный отказ: вместо отбрасывания пакета ядро сбрасывает соединение, и клиент получает ошибку немедленно. Диагностика становится проще — отказ виден обеим сторонам, — но клиенты, которые пережили бы всплеск повтором, теперь падают.
Документация формулирует это как условие: включать, только если вы уверены, что служба действительно не справляется, а не переживает короткий пик.
Частые заблуждения
пока сервер не вызвал accept, соединения нет
Соединение есть: рукопожатие проводит ядро, а accept лишь извлекает готовое из очереди: It extracts the first connection request on the queue of pending connections
(он извлекает первый запрос на соединение из очереди ожидающих соединений). Измерено: сервер не звал accept ни разу, клиент подключился за 0,05 мс и отправил 18 байт.
при переполнении очереди клиент получит ECONNREFUSED
Обычно нет. TCP поддерживает повторную передачу, поэтому ядро молча отбрасывает пакет: измеренный цикл подключений кончился таймаутом, а не отказом. Клиент винит сеть, в логах сервера пусто. Явный отказ появляется, только если включён tcp_abort_on_overflow.
backlog=1 означает одно соединение в очереди
Измерено: два. При backlog=8 — девять: на этом ядре в очередь проходит на одно соединение больше, чем указано в backlog. Но и «плюс один» правилом делать нельзя — listen(2) обещает только максимум, до которого очередь может вырасти. Практический вывод: backlog — порядок величины, а фактическую вместимость смотрят в ss.
очередь одна
Их две. Первая держит соединения, у которых рукопожатие не завершено (её размер — tcp_max_syn_backlog), вторая — завершённые, ждущие accept (её размер — backlog). Первая переполняется от потока запросов, вторая — от медленного приложения, и лечатся они разным.
мы поставили backlog=1000, значит очередь на тысячу
Только если somaxconn не меньше: If the backlog argument is greater than the value in /proc/sys/net/core/somaxconn, then it is silently capped to that value
(если аргумент backlog больше значения в /proc/sys/net/core/somaxconn, он молча урезается до этого значения). Измерено: при somaxconn=2 тот же listen(backlog=100) дал очередь на 2. Ошибки при этом нет — listen вернул успех.
если клиенты жалуются на таймауты, а сервер отвечает на проверку, значит дело в сети
Проверка отвечает потому, что её соединение успели забрать. Соединения, стоящие в очереди, до приложения не дошли, и в его логах их нет вовсе. Прямое доказательство — растущий Recv-Q у слушающего сокета: очередь ведёт ядро, и видно её снаружи.
длинная очередь всегда лучше короткой
Длинная очередь хорошо переживает всплеск и плохо — постоянную перегрузку: если приложение в среднем забирает соединения медленнее, чем они приходят, очередь превращается в накопитель ожидания. Клиент при этом ждёт дольше, а исход тот же. Различать помогает поведение Recv-Q: колеблется — запас работает, стабильно полон — дело не в очереди.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
srv, port = server(backlog=1) outcome, sent = connect_result(port) print(outcome) print(sent) srv.close() srv, port = server(backlog=1) filled, ending = fill(port) print(ending)
Практика · оцените
Проверка знаний
Сервер вызвал listen, но ни разу не вызвал accept. Клиент подключается. Что произойдёт?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Между «клиент подключился» и «сервер узнал о клиенте» стоит очередь. Соединение устанавливает ядро; приложение потом забирает готовое вызовом
accept. Пока оно не позвало — клиент уже подключён, а сервер о нём не знает. - Отсюда главное следствие: занятый сервер выглядит для клиента работающим. Измерено: сервер не вызвал
acceptни разу, а клиент подключился за 0,05 мс и отправил 18 байт. И при переполнении очереди клиент получает не отказ, а ожидание: пакет молча отброшен, TCP его повторяет, в логах сервера — ничего. - Дальше — то, что отличает знающего от читавшего. Очередей две, и переполняются они по разным причинам. Очередь видна снаружи: у слушающего сокета
ss -lntпоказываетSend-Qкак вместимость иRecv-Qкак длину. Число изlisten(backlog=…)молча урезается доsomaxconn— измерено: приsomaxconn=2очередь получилась на 2, хотя в коде стояло 100. А на этом ядре в очередь помещается на одно соединение больше, чем указано вbacklog.
На самом деле
- Соединение есть: рукопожатие проводит ядро, а
acceptлишь извлекает готовое из очереди: It extracts the first connection request on the queue of pending connections. Измерено: сервер не звалacceptни разу, клиент подключился за 0,05 мс и отправил 18 байт. - Обычно нет. TCP поддерживает повторную передачу, поэтому ядро молча отбрасывает пакет: измеренный цикл подключений кончился таймаутом, а не отказом. Клиент винит сеть, в логах сервера пусто. Явный отказ появляется, только если включён
tcp_abort_on_overflow. - Измерено: два. При
backlog=8— девять: на этом ядре в очередь проходит на одно соединение больше, чем указано вbacklog. Но и «плюс один» правилом делать нельзя —listen(2)обещает только максимум, до которого очередь может вырасти. Практический вывод:backlog— порядок величины, а фактическую вместимость смотрят вss. - Их две. Первая держит соединения, у которых рукопожатие не завершено (её размер —
tcp_max_syn_backlog), вторая — завершённые, ждущиеaccept(её размер —backlog). Первая переполняется от потока запросов, вторая — от медленного приложения, и лечатся они разным. - Только если
somaxconnне меньше: If the backlog argument is greater than the value in /proc/sys/net/core/somaxconn, then it is silently capped to that value. Измерено: приsomaxconn=2тот жеlisten(backlog=100)дал очередь на 2. Ошибки при этом нет —listenвернул успех. - Проверка отвечает потому, что её соединение успели забрать. Соединения, стоящие в очереди, до приложения не дошли, и в его логах их нет вовсе. Прямое доказательство — растущий
Recv-Qу слушающего сокета: очередь ведёт ядро, и видно её снаружи. - Длинная очередь хорошо переживает всплеск и плохо — постоянную перегрузку: если приложение в среднем забирает соединения медленнее, чем они приходят, очередь превращается в накопитель ожидания. Клиент при этом ждёт дольше, а исход тот же. Различать помогает поведение
Recv-Q: колеблется — запас работает, стабильно полон — дело не в очереди.
Что разобрано
- Что здесь на самом деле спрашивают
- База: что делает сервер, пока никого нет
- Механизм 1: соединение устанавливает ядро
- Механизм 2: очередей две, и переполняются они по-разному
- Механизм 3: переполнение выглядит как таймаут, а не как отказ
- Глубже: `backlog` из кода — ещё не очередь
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
3 ИСТОЧНИКА
- listen(2), Linux man-pages 6.7Официальная документация. Что задаёт backlog: «The backlog argument defines the maximum length to which the queue of pending connections for sockfd may grow» (Аргумент backlog задаёт максимальную длину, до которой может вырасти очередь ожидающих соединений для sockfd). Что происходит при переполнении — и почему клиент видит не отказ: «If a connection request arrives when the queue is full, the client may receive an error with an indication of ECONNREFUSED or, if the underlying protocol supports retransmission, the request may be ignored so that a later reattempt at connection succeeds» (Если запрос на соединение приходит при полной очереди, клиент может получить ошибку с указанием ECONNREFUSED или, если нижележащий протокол поддерживает повторную передачу, запрос может быть проигнорирован, чтобы более поздняя повторная попытка соединения удалась). И почему число в коде может ничего не значить: «If the backlog argument is greater than the value in /proc/sys/net/core/somaxconn, then it is silently capped to that value. Since Linux 5.4, the default in this file is 4096; in earlier kernels, the default value is 128» (Если аргумент backlog больше значения в /proc/sys/net/core/somaxconn, он молча урезается до этого значения. Начиная с Linux 5.4 значение по умолчанию в этом файле — 4096, в более ранних ядрах — 128).https://man7.org/linux/man-pages/man2/listen.2.html
- tcp(7), Linux man-pages 6.7Официальная документация. Про вторую очередь — ту, где соединения ещё не установлены: `tcp_max_syn_backlog` — «The maximum number of queued connection requests which have still not received an acknowledgement from the connecting client. If this number is exceeded, the kernel will begin dropping requests» (Максимальное число запросов на соединение, ожидающих в очереди и ещё не получивших подтверждения от подключающегося клиента. При превышении этого числа ядро начинает отбрасывать запросы). И про настройку, которая меняет вид отказа: `tcp_abort_on_overflow` — «Enable resetting connections if the listening service is too slow and unable to keep up and accept them» (Включает сброс соединений, если слушающая служба слишком медленна и не успевает их принимать), с прямым предупреждением включать её, только если вы уверены в причине.https://man7.org/linux/man-pages/man7/tcp.7.html
- accept(2), Linux man-pages 6.7Официальная документация. Что именно делает приложение, когда «принимает» соединение: «It extracts the first connection request on the queue of pending connections for the listening socket, sockfd, creates a new connected socket, and returns a new file descriptor referring to that socket» (Он извлекает первый запрос на соединение из очереди ожидающих соединений для слушающего сокета sockfd, создаёт новый подключённый сокет и возвращает новый файловый дескриптор, ссылающийся на этот сокет). То есть соединение к этому моменту уже существует — приложение забирает его из очереди, а не создаёт.https://man7.org/linux/man-pages/man2/accept.2.html