49. Backlog, SYN Queue и Accept Queue
Как это устроено в Linux и Go
«Крепость с неприступными стенами бесполезна, если ее ворота слишком узки, чтобы пропустить собственную армию».
— Инженерный закон очередей и пропускной способности
Введение: Почему Go не спасет от падения ядра
Когда вы проектируете высокопроизводительный HTTP- или gRPC-сервер на Go, легко поддаться чувству абсолютного контроля. Мы запускаем http.Server.ListenAndServe() (или http.Server), получаем пулы горутин, оперируем контекстами и наивно полагаем, что судьба каждого входящего клиентского запроса находится в руках нашего прикладного кода.
Но вот наступает день распродажи, релиз вирусной фичи или распределенная DDoS-атака. Нагрузка подскакивает в десятки раз. И внезапно клиенты начинают жаловаться на глухие таймауты при попытке установить соединение. При этом в логах вашего Go-сервиса — пугающая тишина: нет ни привычных паник, ни ошибок context deadline exceeded, ни сообщений accept: too many open files. Процессор загружен всего на 20%, памяти предостаточно, но клиенты не могут достучаться до сервиса.
Причина почти всегда кроется не в коде на Go, а в том, что входящие соединения погибают на самой границе между пользовательским пространством (User Space) и ядром Linux (Kernel Space). Они разбиваются о переполненные очереди сокета: SYN Queue и Accept Queue.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Client["Входящие клиенты (SYN)"]:::warning --> PreFilter{"Проверка SYN Queue"}
PreFilter -->|"Очередь свободна"| SYNQ["SYN Queue (request_sock)
Состояние: SYN_RECV"]
PreFilter -->|"Переполнение!"| SYNDrop["SYN Cookies или отбрасывание пакета"]
SYNQ -->|"3-Way Handshake завершен (ACK)"| PostFilter{"Проверка Accept Queue"}
PostFilter -->|"Место есть"| AcceptQ["Accept Queue (struct sock)
Состояние: ESTABLISHED"]
PostFilter -->|"Переполнение!"| AcceptDrop["Дроп ACK / Silent Timeout"]
AcceptQ -->|"syscall accept4()"| GoNetpoll["Go netpoller & Goroutines
(http.Server)"]
Понимание внутренней механики этих двух очередей, их системных лимитов и поведения при перегрузке — обязательный фундаментальный навык для любого Senior Go-инженера и SRE-архитектора.
Два уровня очередей в TCP-стеке
TCP-соединение не материализуется в памяти мгновенно. Процесс трехэтапного рукопожатия (3-Way Handshake) занимает от десятков миллисекунд до секунд в распределенной глобальной сети. Чтобы операционная система не тратила ценные ресурсы на соединения, которые могут никогда не завершиться, ядро Linux разделяет входящий поток на два изолированных уровня очередей.
sequenceDiagram
participant C as Клиент
participant K as Ядро Linux
participant App as Go Приложение
Note over K: Сокет слушает: listen(fd, backlog)
C->>K: 1. [SYN]
Note over K: Помещение в SYN Queue (request_sock)<br/>Лимит: tcp_max_syn_backlog
K-->>C: 2. [SYN-ACK]
C->>K: 3. [ACK]
Note over K: Извлечение из SYN Queue,<br/>аллокация struct sock,<br/>помещение в Accept Queue.<br/>Лимит: somaxconn & backlog
App->>K: 4. ln.Accept() / syscall accept4()
K-->>App: Возврат готового дескриптора клиенту
SYN Queue: Фильтр на входе
Когда клиент отправляет серверу первый пакет с флагом SYN, ядро Linux не создает полноценный сетевой сокет. Создание полноразмерного сокета — тяжелая операция: она требует выделения структур struct sock, struct tcp_sock, дескриптора struct file и привязки к inode VFS.
Вместо этого ядро аллоцирует крошечную легковесную структуру request_sock и помещает её в SYN Queue (очередь полуоткрытых соединений). Здесь сокет переходит в состояние SYN_RECV, а сервер отправляет клиенту ответный SYN-ACK.
- Зачем это нужно: Защитить операционную систему от исчерпания памяти при атаках типа SYN-flood. Злоумышленник может рассылать миллионы фальшивых
SYN-пакетов с поддельных IP-адресов, никогда не присылая финальныйACK. - Лимит: Контролируется параметром ядра
net.ipv4.tcp_max_syn_backlog(в современных ядрах Linux значение по умолчанию составляет от 1024 до 4096). - Поведение при переполнении: Если очередь полуоткрытых соединений заполняется до предела, ядро начинает отбрасывать входящие
SYN.
Если в ядре включен механизм SYN Cookies (net.ipv4.tcp_syncookies=1), ядро вообще перестает сохранять состояние в SYN Queue! Вместо аллокации памяти ядро вычисляет криптографический хеш из IP-адресов, портов и секретного ключа ядра, упаковывает его в Initial Sequence Number (ISN) пакетаSYN-ACKи отправляет клиенту. Когда добросовестный клиент присылает финальныйACK, ядро извлекает этот хеш, валидирует его и только в этот момент создает сокет. Это спасает систему от падения при DDoS, но создает ощутимый оверхед на CPU хоста.
Accept Queue: Воронка для Go-рантайма
Когда трехэтапное рукопожатие успешно завершается (сервер получил от клиента финальный ACK), ядро переводит соединение в статус ESTABLISHED. Теперь это полноценный канал связи. Ядро удаляет request_sock из SYN Queue, аллоцирует полноценную структуру struct sock и перемещает соединение в Accept Queue (очередь полностью установленных соединений).
Здесь готовое соединение ожидает, пока ваше Go-приложение вызовет метод Accept() (на системном уровне — системный вызов accept() или accept4).
- Лимит: Определяется минимумом из двух величин: параметра
backlog, переданного при системном вызовеlisten(), и глобального системного потолка ядраnet.core.somaxconn(в старых дистрибутивах Linux по умолчанию былоsomaxconn=128, в современных — 4096). - Поведение при переполнении: Если очередь Accept Queue переполнена (ваше Go-приложение не успевает забирать соединения из-за перегрузки CPU или паузы GC), ядро Linux по умолчанию молча отбрасывает входящие финальные ACK-пакеты (при
tcp_abort_on_overflow=0).
Клиент не получает ошибкиConnection refused(ECONNREFUSED) сразу — он считает, что соединение установлено, и зависает в ожидании ответа до истечения TCP-таймаута. Для бэкенда на Go это выглядит как внезапное загадочное «исчезновение» новых пользователей под нагрузкой!
Под капотом: Ядерные структуры и аллокации
В исходном коде ядра Linux сетевая подсистема использует элегантную иерархию структур для диспетчеризации этих очередей:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph ListenSocket["Слушающий сокет (struct sock)"]
Flags["Флаги: SOCK_LISTEN"]:::process
ListenSock["struct listen_sock (backlog, qlen, max_qlen)"]
ReqQueue["struct request_sock_queue"]:::process
end
subgraph QueuesMemory["Очереди в оперативной памяти"]
SynTable["syn_table (Хеш-таблица / Список request_sock)"]
AcceptList["rskq_accept_head -> rskq_accept_tail (Связный список struct sock)"]
end
ReqQueue --> SynTable
ReqQueue --> AcceptList
struct sock: Базовый объект сетевого протокола. Для серверного слушателя он помечается системным флагомSOCK_LISTEN.struct listen_sock: Управляет размерами очередей: содержит поляbacklog(запрошенный лимит),qlen(текущее количество соединений в очереди) иmax_qlen(фактический разрешенный максимум).struct request_sock_queue: Диспетчер очередей. Управляет связным списком полуоткрытых соединенийrequest_sock. Каждый такой элемент инкапсулируетtcp_request_sockсо счетчиком таймера повторной отправкиtcp_synack_retries(по умолчанию 5 попыток). Если клиент не прислалACKвовремя, ядро удаляет элемент, освобождая слот.
Mechanical Sympathy и блокировка listen_lock
При экстремальном наплыве новых соединений на один порт в ядре Linux возникает жесткая конкуренция за блокировку очереди — listen_lock.
Эта спинлок-блокировка ядра защищает структуры request_sock_queue. Если несколько системных потоков ОС пытаются одновременно вызвать accept() на одном и том же слушающем сокете, они начинают сжигать такты CPU в ожидании listen_lock.
В Go это означает, что горутины, ожидающие вызова Accept(), не просто стоят в очереди рантайма: системный тред (M), исполняющий сисколл, упирается в спинлок ядра, порождая паразитные задержки переключения контекста.
Взаимодействие Go runtime с очередями
Многие разработчики на Go удивляются: почему стандартная функция net.Listen("tcp", ":8080") не принимает параметр backlog, как это принято в C (listen(fd, backlog)) или в Java (ServerSocket.accept() с параметром backlog)?
Это осознанное проектное решение авторов Go: стандартная библиотека берет значение backlog напрямую из системного лимита ядра somaxconn. Поэтому из Go глубину очереди не изменить: у net.ListenConfig нет поля Backlog, а listen() вызывает сам пакет net. Единственный рычаг — системный параметр net.core.somaxconn. Зато другие опции сокета (например, SO_REUSEPORT) можно выставить через хук Control с помощью golang.org/x/sys/unix.
Ниже приведен пример создания сервера с настройкой опции сокета SO_REUSEPORT:
package main
import (
"context"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"golang.org/x/sys/unix"
)
func main() {
addr := "0.0.0.0:8080"
// 1. Создаем сетевой слушатель с контролем системных опций
lc := net.ListenConfig{
Control: func(network, address string, c syscall.RawConn) error {
var opErr error
err := c.Control(func(fd uintptr) {
// Включаем SO_REUSEPORT. Размер очереди (backlog) так не настроить: опции SO_MAX_CONN
// не существует, а backlog передается ядру только аргументом listen(fd, backlog),
// и этот вызов делает сам пакет net (с лимитом somaxconn)
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil {
opErr = fmt.Errorf("ошибка setsockopt SO_REUSEPORT: %w", err)
}
})
if err != nil {
return err
}
return opErr
},
}
ln, err := lc.Listen(context.Background(), "tcp", addr)
if err != nil {
log.Fatalf("Не удалось открыть сокет: %v", err)
}
defer ln.Close()
// 2. Предупреждение: не стоит «доставать» дескриптор через tcpListener.File() и file.Fd().
// Fd() переводит общий дескриптор слушающего сокета в блокирующий режим, и после этого
// ln.Close() и server.Shutdown() могут зависнуть при висящем Accept. Для низкоуровневой работы
// используйте ListenConfig.Control (как выше) или tcpListener.SyscallConn().
// 3. Запускаем HTTP-сервер
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Соединение успешно обработано из Accept Queue!\n")
})
server := &http.Server{
Handler: mux,
}
go func() {
log.Printf("HTTP-сервер запущен на %s (backlog синхронизирован с somaxconn)", addr)
if err := server.Serve(ln); err != nil && err != http.ErrServerClosed {
log.Fatalf("Ошибка HTTP-сервера: %v", err)
}
}()
// Graceful shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Println("Останавливаем сервер...")
_ = server.Shutdown(context.Background())
}
Важные нюансы интеграции Go и очередей:
- Жесткий потолок ядра: Если приложение (например, на C) запросит
backlog = 65535, а параметр ядраnet.core.somaxconnравен128, ядро Linux молча усечет размер очереди до128! (В Go тот же потолок действует тем более: пакетnetсам берётbacklogизsomaxconn.) Никаких предупреждений выдано не будет. - Асинхронный поллер
poll.FD: Стандартный пакетnetоборачивает дескрипторы в структуруpoll.FD. Когда вызовaccept()возвращает новый сокет, он мгновенно регистрируется в подсистемеepoll(Linux) илиkqueue(macOS/BSD) черезnetpoller. Пока входящий трафик не поступил в сокет, он не потребляет такты процессора. - Философия минимализма: Go не навязывает громоздких конфигураций в сигнатурах методов: язык делегирует системный тюнинг инфраструктуре операционной системы, предоставляя низкоуровневые хуки через интерфейс
syscall.RawConn.
Тонкая настройка и мониторинг в production
Для серверов, обрабатывающих десятки тысяч запросов в секунду (API Gateway, Ingress-контроллеры, брокеры сообщений), стандартные настройки дистрибутивов Linux фатально недостаточны:
#!/usr/bin/env bash
# Оптимальная конфигурация сетевых очередей ядра для high-load Go-серверов
# 1. Расширение глобального лимита Accept Queue (по умолчанию 128 или 4096)
sysctl -w net.core.somaxconn=65535
# 2. Расширение лимита SYN Queue для защиты от всплесков полуподключений
sysctl -w net.ipv4.tcp_max_syn_backlog=65535
# 3. Активация криптографических SYN Cookies при переполнении SYN Queue
sysctl -w net.ipv4.tcp_syncookies=1
# 4. Поведение при переполнении Accept Queue (0 = молча дропать пакеты, давая шанс клиенту достучаться;
# 1 = отправлять RST-пакет клиенту для немедленного разрыва)
sysctl -w net.ipv4.tcp_abort_on_overflow=0
Мониторинг очередей в реальном времени:
- Команда
ss -lnt(Socket Statistics):
Для слушающих сокетов (State = LISTEN) колонки утилитыssменяют свое значение:Send-Q— максимальный разрешенный размер Accept Queue (значениеbacklog).Recv-Q— текущее количество соединений, находящихся в Accept Queue прямо сейчас.
Если вы видите, чтоRecv-Qприближается кSend-Q— ваш Go-сервер захлебывается и не успевает вызыватьaccept()!
- Команда
netstat -s | grep -i listen(илиnetstat -s | grep listen):
Показывает кумулятивные ядерные счетчики переполнений:listen drops— количество сброшенных соединений.listen overflows— количество переполнений Accept Queue.
Если счетчикlisten overflows > 0постоянно растет — ваша инфраструктура теряет живых клиентов!
- Трассировка через
perf:
Командаperf trace -e accept,connectпозволяет измерить точную задержку между моментом завершения рукопожатия в ядре и вызовомaccept()в рантайме Go.
Итог
- SYN Queue изолирует и фильтрует полуоткрытые соединения. При переполнении спасает механизм SYN Cookies (
net.ipv4.tcp_syncookies=1), защищающий ядро от SYN-flood ценой небольшого расхода тактов процессора. - Accept Queue хранит полностью установленные соединения (
ESTABLISHED), ожидающие вызоваaccept(). Переполнение этой очереди приводит к катастрофической молчаливой потере соединений клиентами. - Рантайм Go интегрируется с очередями через неблокирующий сетевой поллер
netpoller, скрывая рутину работы с дескрипторами, но делегируя настройку лимитовbacklogоперационной системе. - Инженерия мониторинга: Регулярная проверка параметров
Recv-Qв выводеss -lntи счетчикаlisten overflowsвnetstat— фундаментальный базис надежности любого высоконагруженного сервиса. - Комплексный подход: Надежность складывается из согласованной цепочки: параметры ядра (
somaxconn,tcp_max_syn_backlog) -> лимиты процесса (ulimit -n) -> архитектура приложения на Go (http.Servertimeouts и connection pools).
Мы досконально разобрали, как соединения рождаются и как они преодолевают ядерные очереди. Но что происходит, когда соединение завершает свою работу? Почему сетевые порты застревают в таинственном статусе TIME_WAIT и чем грозит забытый сокет в состоянии CLOSE_WAIT? Ответы на эти вопросы — в следующей статье: 50. TIME_WAIT, CLOSE_WAIT и проблемы сокетов.