Deep Engineering
Средний·Опубликовано·20 МИН

Очередь 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.

Что здесь на самом деле спрашивают

Лестница обычно такая:

  1. «Что делает listen — разминка про сокет, который переходит в пассивное состояние.
  2. «Что такое backlog?» — здесь начинается содержание: длина очереди чего именно.
  3. «Клиент подключился, а сервер занят. Где находится запрос?» — вопрос, на котором становится видно, знает ли собеседник про очередь ядра.
  4. «Что увидит клиент, когда очередь переполнится?» — вопрос-ловушка: он увидит не отказ.
  5. «Почему в логах сервера пусто, а клиенты жалуются на таймауты?» — вопрос про то, что очередь не оставляет следов в приложении.
  6. «Мы поставили backlog в тысячу — почему не помогло?» — вопрос про somaxconn.

Числа получены прогоном bench/acceptq/backlog.py и bench/acceptq/practice.py на loopback. Времени здесь почти нет: считаются соединения и читаются счётчики ядра: миллисекунды у вас будут своими, а счёт соединений и исходы — те же.

База: что делает сервер, пока никого нет

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

Сервер, который ждёт клиентов, делает четыре вещи по порядку:

  1. заводит сокет — точку, через которую можно говорить по сети;
  2. привязывает его к адресу и порту — теперь понятно, куда стучаться;
  3. объявляет его слушающим — сокет переходит в пассивное состояние и больше не будет никуда подключаться сам, он будет принимать;
  4. принимает соединения — по одному, в цикле, каждый раз получая отдельный сокет для разговора именно с этим клиентом.

Ключевое здесь — четвёртый шаг. Слушающий сокет не превращается в соединение: он остаётся на месте и служит источником, из которого сервер достаёт готовые соединения, каждое со своим отдельным сокетом.

И вот главный вопрос урока: что происходит между третьим и четвёртым шагом? Клиент ведь может постучаться в любой момент — в том числе тогда, когда сервер занят предыдущим клиентом и до четвёртого шага ещё не дошёл.

Ответ такой: договорённость о соединении — работа ядра, а не приложения. Ядро проводит её само, кладёт готовое соединение в очередь и ждёт, когда приложение придёт за ним. Приложение узнаёт о клиенте не в тот момент, когда тот подключился, а в тот, когда само дошло до четвёртого шага.

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

Механизм 1: соединение устанавливает ядро

контракт языкаПоведение, описанное в listen(2), accept(2) и tcp(7). От языка и фреймворка не зависит: очередь ведёт ядро.

Теперь то же самое, но словами документации. Начнём с того, что делает 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(2)

Извлекает из очереди. Значит, к моменту вызова соединение уже есть: рукопожатие провело ядро, пока приложение занималось чем-то другим. Приложение не устанавливает соединение — оно забирает установленное.

Проверяем крайним случаем: сервер, который не вызывает 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
наблюдение замераbench/acceptq/backlog.py, Linux 6.18.44, loopback. Миллисекунды у вас будут другими; исход — тем же: соединение установлено, данные приняты, приложение ничего не знает.

Строка 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.

перевод

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

tcp(7), tcp_max_syn_backlog

Очередь готовых — та самая, из которой берёт accept. Её размер задаёт backlog:

The backlog argument defines the maximum length to which the queue of pending connections for sockfd may grow.

перевод

Аргумент backlog задаёт максимальную длину, до которой может вырасти очередь ожидающих соединений для sockfd.

listen(2)

Разница практическая. Первая очередь переполняется, когда клиентов слишком много или когда до вас идёт поток полуоткрытых соединений. Вторая переполняется, когда приложение не успевает звать 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
наблюдение замераbench/acceptq/backlog.py. Строки ss читаются так: Send-Q у слушающего сокета — вместимость очереди, Recv-Q — сколько в ней сейчас стоит.

Плюс один. При 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 или, если нижележащий протокол поддерживает повторную передачу, запрос может быть проигнорирован, чтобы более поздняя повторная попытка соединения удалась.

listen(2)

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
наблюдение замераbench/acceptq/backlog.py при backlog=1 и умолчательном tcp_abort_on_overflow. Клиент ждёт своего таймаута; отказа он не получает.

Ни одного 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.

listen(2)

Слово 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
наблюдение замераbench/acceptq/backlog.py. Одна и та же строка listen(backlog=100), две настройки машины — и разная вместимость очереди.

Один и тот же код, две машины — очередь на сто и очередь на два. Отсюда практическое правило: проверять надо не код, а 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: колеблется — запас работает, стабильно полон — дело не в очереди.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Сервер вызвал listen(backlog=1) и больше не делает ничего — accept не вызывается ни разу. Печатаются три вещи: чем кончился connect клиента, сколько байт он успел отправить и чем кончился цикл подключений, когда очередь заполнилась. Что напечатает этот код?
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(backlog=1) и не забирает соединения. Сколько подключений примет ядро, прежде чем следующее начнёт ждать?
соединения

Проверка знаний

Вопрос 1 из 6

Сервер вызвал listen, но ни разу не вызвал accept. Клиент подключается. Что произойдёт?

Источники и что читать дальше

3 ИСТОЧНИКА

  1. 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
  2. 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
  3. 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