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
  
  1. struct sock: Базовый объект сетевого протокола. Для серверного слушателя он помечается системным флагом SOCK_LISTEN.
  2. struct listen_sock: Управляет размерами очередей: содержит поля backlog (запрошенный лимит), qlen (текущее количество соединений в очереди) и max_qlen (фактический разрешенный максимум).
  3. 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:

Go
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 и очередей:

  1. Жесткий потолок ядра: Если приложение (например, на C) запросит backlog = 65535, а параметр ядра net.core.somaxconn равен 128, ядро Linux молча усечет размер очереди до 128! (В Go тот же потолок действует тем более: пакет net сам берёт backlog из somaxconn.) Никаких предупреждений выдано не будет.
  2. Асинхронный поллер poll.FD: Стандартный пакет net оборачивает дескрипторы в структуру poll.FD. Когда вызов accept() возвращает новый сокет, он мгновенно регистрируется в подсистеме epoll (Linux) или kqueue (macOS/BSD) через netpoller. Пока входящий трафик не поступил в сокет, он не потребляет такты процессора.
  3. Философия минимализма: Go не навязывает громоздких конфигураций в сигнатурах методов: язык делегирует системный тюнинг инфраструктуре операционной системы, предоставляя низкоуровневые хуки через интерфейс syscall.RawConn.

Тонкая настройка и мониторинг в production

Для серверов, обрабатывающих десятки тысяч запросов в секунду (API Gateway, Ingress-контроллеры, брокеры сообщений), стандартные настройки дистрибутивов Linux фатально недостаточны:

Bash
#!/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

Мониторинг очередей в реальном времени:

  1. Команда ss -lnt (Socket Statistics):
    Для слушающих сокетов (State = LISTEN) колонки утилиты ss меняют свое значение:
    • Send-Q — максимальный разрешенный размер Accept Queue (значение backlog).
    • Recv-Q — текущее количество соединений, находящихся в Accept Queue прямо сейчас.
      Если вы видите, что Recv-Q приближается к Send-Q — ваш Go-сервер захлебывается и не успевает вызывать accept()!
  2. Команда netstat -s | grep -i listen (или netstat -s | grep listen):
    Показывает кумулятивные ядерные счетчики переполнений:
    • listen drops — количество сброшенных соединений.
    • listen overflows — количество переполнений Accept Queue.
      Если счетчик listen overflows > 0 постоянно растет — ваша инфраструктура теряет живых клиентов!
  3. Трассировка через perf:
    Команда perf trace -e accept,connect позволяет измерить точную задержку между моментом завершения рукопожатия в ядре и вызовом accept() в рантайме Go.

Итог

  1. SYN Queue изолирует и фильтрует полуоткрытые соединения. При переполнении спасает механизм SYN Cookies (net.ipv4.tcp_syncookies=1), защищающий ядро от SYN-flood ценой небольшого расхода тактов процессора.
  2. Accept Queue хранит полностью установленные соединения (ESTABLISHED), ожидающие вызова accept(). Переполнение этой очереди приводит к катастрофической молчаливой потере соединений клиентами.
  3. Рантайм Go интегрируется с очередями через неблокирующий сетевой поллер netpoller, скрывая рутину работы с дескрипторами, но делегируя настройку лимитов backlog операционной системе.
  4. Инженерия мониторинга: Регулярная проверка параметров Recv-Q в выводе ss -lnt и счетчика listen overflows в netstat — фундаментальный базис надежности любого высоконагруженного сервиса.
  5. Комплексный подход: Надежность складывается из согласованной цепочки: параметры ядра (somaxconn, tcp_max_syn_backlog) -> лимиты процесса (ulimit -n) -> архитектура приложения на Go (http.Server timeouts и connection pools).

Мы досконально разобрали, как соединения рождаются и как они преодолевают ядерные очереди. Но что происходит, когда соединение завершает свою работу? Почему сетевые порты застревают в таинственном статусе TIME_WAIT и чем грозит забытый сокет в состоянии CLOSE_WAIT? Ответы на эти вопросы — в следующей статье: 50. TIME_WAIT, CLOSE_WAIT и проблемы сокетов.