MAATRIX / Блог / Путь пакета внутри сервера: от сетевой карты до вашего сокета

Путь пакета внутри сервера: от сетевой карты до вашего сокета

MAATRIX

Когда клиент открывает соединение с вашим сервером, между «пакет долетел до сетевой карты» и «приложение получило данные в переменную» происходит десяток шагов внутри ядра Linux, о которых редко думают, пока всё работает. А когда где-то в этой цепочке начинает штормить — соединения рвутся, запросы подвисают, curl иногда отвечает мгновенно, а иногда за секунды — понимание этого пути превращается из теории в единственный способ понять, где именно застряло. Разберём весь путь входящего пакета по шагам: от кадра на проводе до байтов в буфере вашего приложения.

Кадр долетел до сетевой карты

Всё начинается на физическом уровне. По кабелю приходит электрический сигнал, который сетевой контроллер (NIC) превращает обратно в кадр Ethernet — последовательность байт с MAC-адресами отправителя и получателя, полем EtherType и полезной нагрузкой внутри.

NIC не передаёт этот кадр процессору напрямую и не «стучится» в ядро по каждому байту. У современной сетевой карты есть контроллер DMA (Direct Memory Access) — она сама, без участия CPU, копирует кадр в заранее выделенную область оперативной памяти сервера. Эта область называется RX-кольцом (receive ring buffer) — кольцевым буфером дескрипторов, которые указывают на буферы памяти, куда можно писать входящие кадры.

Драйвер при инициализации интерфейса заранее выделяет пул таких буферов и передаёт их адреса карте: «вот сюда можешь писать следующие пакеты». Когда кадр приходит, NIC берёт свободный дескриптор из кольца, копирует туда данные через DMA и помечает его заполненным. Процессор в этот момент не занят вообще — вся работа идёт на уровне контроллера памяти и шины PCIe.

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

ethtool -g eth0

Вывод покажет что-то вроде RX: 512 (текущее) и RX Max: 4096 (потолок драйвера). Если кольцо маленькое, а поток пакетов резкий, при задержке обработки со стороны ядра новые кадры может быть некуда записывать — тогда карта их просто отбрасывает ещё до того, как они попали хоть в какой-то программный стек. Это первая точка потерь на всём пути, и она видна не в ping, а в статистике самой карты.

Прерывание и переход в NAPI

Кадр физически лежит в памяти, но ядро об этом пока не знает — нужен сигнал. Классический способ — аппаратное прерывание (IRQ): NIC дёргает соответствующую линию, процессор останавливает текущую работу и передаёт управление обработчику прерывания драйвера.

Проблема в том, что при высокой интенсивности трафика прерывание на каждый кадр — это катастрофа: каждое стоит процессору переключения контекста. Поэтому современные драйверы Linux работают в режиме NAPI (New API), объединяющем прерывания с polling-опросом.

Механика такая: первый кадр в серии вызывает обычное прерывание. Обработчик не разбирает пакет — он отключает дальнейшие прерывания для этой очереди карты и планирует программное прерывание (softirq) типа NET_RX_SOFTIRQ. Дальше softirq-обработчик в цикле опрашивает (poll) кольцевой буфер и забирает все накопившиеся пакеты пачкой, без нового прерывания на каждый. Только когда буфер опустел или исчерпан бюджет обработки за проход, карте снова разрешают слать прерывания.

Это удачный компромисс: при низкой нагрузке система реагирует на пакет почти сразу (через прерывание), а при высокой — не тратит ресурсы на тысячи прерываний в секунду, забирая данные пачками через polling. Мы разбирали этот механизм подробнее в статье про то, почему сетевая карта фактически ест ядро процессора.

Если softirq-обработка не успевает за потоком прерываний в контексте самого прерывания, ядро передаёт работу отдельному потоку ksoftirqd — он донабирает обработку в обычном контексте планировщика, конкурируя за CPU с остальными процессами. Когда ksoftirqd занимает заметную долю ядра — верный признак, что сеть не справляется на текущей конфигурации CPU/NIC. Мы отдельно разбирали, как выглядит ksoftirqd, забравший себе целое ядро.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Драйвер копирует данные в структуру ядра

На этапе polling драйвер должен передать данные дальше по стеку в формате, понятном остальному ядру. Для каждого кадра создаётся структура sk_buff (socket buffer) — универсальный контейнер, в котором пакет живёт весь путь внутри ядра: сначала как кадр Ethernet, потом (по мере разбора заголовков) как IP-пакет, потом как TCP- или UDP-сегмент. Заголовки не копируются заново на каждом уровне — просто сдвигается указатель на начало полезных данных внутри того же буфера.

Дальше пакет уходит в функцию верхнего уровня обработки (netif_receive_skb и её преемники), откуда начинается разбор по протокольному стеку. Если кадров много, а буферов sk_buff под них выделяется медленно (например, из-за нехватки памяти), в статистике карты растут счётчики вроде rx_dropped или rx_missed_errors. Проверить:

ethtool -S eth0 | grep -Ei 'drop|miss|error'

Если счётчики растут постоянно и пропорционально трафику — узкое место здесь, ещё до того, как пакет добрался до IP-стека.

Стек ядра разбирает уровни: Ethernet → IP → TCP/UDP

Теперь sk_buff попадает в сетевой стек ядра, и начинается последовательный разбор заголовков — сверху вниз по модели OSI, хотя технически это одна функция вызывает другую.

Сначала уровень Ethernet: ядро смотрит на MAC-адрес назначения. Если он не совпадает с адресом интерфейса (и карта не в promiscuous-режиме), кадр отбрасывается — обычно ещё в самой NIC на аппаратном фильтре, программная проверка есть как подстраховка.

Дальше — уровень IP. Ядро проверяет контрольную сумму заголовка, TTL, корректность длины и принимает решение о маршрутизации: пакет предназначен локальному процессу на этой машине, или его нужно переслать дальше (если сервер работает как роутер или через него идёт туннель)? Для этого используется таблица маршрутизации.

Если настроен iptables/nftables, здесь, на цепочке PREROUTING (и чуть позже INPUT), пакет проходит через правила netfilter — каждое добавляет свою долю обработки, и большой список сложных правил заметно увеличивает время на пакет. Это отдельная и частая причина задержек, не связанная ни с картой, ни с диском.

После netfilter пакет передаётся обработчику транспортного уровня — TCP или UDP, в зависимости от поля protocol в IP-заголовке. Проверяется контрольная сумма сегмента (современные карты считают её аппаратно, разгружая CPU — видно как rx-checksumming: on в ethtool -k eth0), из заголовка извлекаются порты источника и назначения.

Как пакет находит нужный сокет

Вот ключевой момент всего пути: у ядра на сервере может быть открыто одновременно множество сокетов — слушающих (LISTEN) и уже установленных TCP-соединений (ESTABLISHED), плюс UDP-сокеты. Как за микросекунды понять, какому именно сокету адресован конкретный пакет?

Для этого используется хеш-таблица соединений, ключом в которой служит четвёрка (4-tuple): IP-адрес источника, порт источника, IP-адрес назначения, порт назначения. Ядро хеширует эти четыре значения и ищёт совпадение среди установленных соединений. Если находит — пакет принадлежит конкретному уже открытому TCP-соединению, и дальше обрабатывается в контексте его состояния (номера последовательности, окно, флаги).

Если точного совпадения по установленному соединению нет, но есть флаг SYN и совпадение по паре (IP назначения, порт назначения) с сокетом в состоянии LISTEN — начинается процесс установления нового соединения. Такой запрос сначала попадает в SYN-очередь (иногда называют half-open queue), а после завершения трёхстороннего рукопожатия — в accept-очередь, откуда его заберёт вызов accept() в приложении. Обе очереди имеют ограниченный размер, и при их переполнении новые попытки подключения либо отбрасываются, либо (если включены SYN cookies) обрабатываются без выделения памяти под состояние до полного подтверждения. Посмотреть текущее и максимальное значение backlog:

ss -ltn
# столбец Send-Q для LISTEN-сокета — это как раз лимит очереди
cat /proc/sys/net/core/somaxconn

Мы подробно разбирали именно этот сценарий — что происходит, когда accept-очередь переполняется и клиенты упираются в таймаут, хотя формально соединение до сервера «дошло».

Для UDP всё проще: там нет состояния соединения в классическом смысле, поиск идёт по паре (IP назначения, порт назначения), и совпавших сокетов может быть несколько при использовании SO_REUSEPORT — тогда ядро распределяет пакеты между несколькими сокетами по дополнительному хешу, чтобы разные процессы или потоки могли параллельно принимать трафик на одном порту.

Буфер приёма сокета и вызов read()/recv()

Пакет нашёл свой сокет — но это ещё не значит, что данные оказались у приложения. У каждого сокета есть собственный буфер приёма (socket receive buffer), в который ядро складывает полезную нагрузку по мере поступления пакетов. Размер этого буфера регулируется параметрами net.core.rmem_max и, для TCP отдельно, диапазоном net.ipv4.tcp_rmem (минимум/по умолчанию/максимум, с автоматическим ростом буфера под нагрузкой, если включён autotuning).

Пока приложение не вызвало read(), recv() или не забрало данные через epoll/io_uring, данные просто лежат в этом буфере в пространстве ядра. Это нормально и даже полезно — небольшая буферизация сглаживает скачки, когда данные приходят пачками, а приложение обрабатывает их с небольшой задержкой. Проблема начинается, если приложение читает медленнее, чем данные приходят: буфер заполняется, и для TCP это включает встроенный механизм управления потоком — ядро сообщает отправителю уменьшенный размер окна (TCP window), вплоть до нулевого. Отправитель видит zero window и останавливает передачу, ожидая, пока получатель освободит место. Со стороны наблюдателя это выглядит как «сеть встала», хотя на самом деле встало именно приложение на приёме.

Отдельная история — сам системный вызов чтения. read()/recv() — это переход из пользовательского пространства в пространство ядра (context switch), копирование данных из буфера ядра в буфер приложения и возврат обратно. Каждый такой переход стоит какое-то время CPU — обычно исчезающе малое по сравнению с сетевой задержкой, но при очень высоких темпах мелких сообщений (например, много маленьких UDP-пакетов) накладные расходы на сами системные вызовы становятся заметны, и именно поэтому существуют механизмы вроде recvmmsg() (чтение нескольких сообщений за один вызов) или полностью асинхронный io_uring, который вообще старается избежать лишних переключений контекста.

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

Где на этом пути возникают задержки и потери

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

ЭтапЧто может пойти не такКак увидеть
NIC → RX-кольцоКольцо переполнено, CPU не успевает забиратьethtool -S eth0 (rx_fifo_errors, rx_missed_errors)
Прерывание / NAPIsoftirq не успевает, ksoftirqd ест CPUtop (поток ksoftirqd/N), /proc/net/softnet_stat
Netdev backlogОбщая очередь softirq на приём переполненаnet.core.netdev_max_backlog, второй столбец в /proc/net/softnet_stat
Netfilter / iptablesСложные цепочки правил замедляют разборiptables -L -v (счётчики), профилирование правил
Accept/SYN-очередьОчередь новых соединений переполненаss -ltn (Recv-Q у LISTEN-сокета), `nstatgrep -i listendrop`
Буфер сокетаПриложение читает медленнее, чем приходят данныеss -tn (Recv-Q установленного соединения растёт)
read()/recv()Частые мелкие вызовы, накладные расходы на syscallпрофилирование приложения, strace -c

Важно понимать: почти все эти потери и задержки невидимы для обычного ping — ICMP-эхо обрабатывается по упрощённому пути и не проходит через часть той же логики (в первую очередь не через сокет-буферы и accept-очередь TCP-приложения). Поэтому «пинг в порядке, а сайт тормозит» — это не противоречие, а прямое следствие того, что узкое место находится на одном из более поздних этапов именно TCP/UDP-пути, а не на сетевом уровне вообще. Мы разбирали похожий эффект отдельно в материале про потерю пакетов, которую не видно в ping.

Практический вывод из всего этого пути простой: если сервис «подтормаживает» при видимо свободном канале и низкой загрузке CPU в среднем, стоит смотреть не на общие метрики, а на конкретные счётчики на каждом этапе — сетевую карту, softirq, accept-очередь и буфер сокета по отдельности. В девяти случаях из десяти узкое место находится не там, где его интуитивно ищут.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему прерывания вообще нужны, если есть NAPI и polling?

NAPI не заменяет прерывания, а комбинирует их с опросом: первое прерывание по-прежнему нужно, чтобы разбудить обработку при низкой нагрузке, когда постоянный polling был бы пустой тратой CPU. При высокой нагрузке система переключается в режим опроса и временно отключает прерывания для этой очереди.

На каком CPU-ядре обрабатывается входящий пакет?

Зависит от настройки RSS (Receive Side Scaling) и affinity очередей прерываний. Многоочередные карты распределяют потоки по нескольким ядрам по хешу от 4-tuple. Привязку можно посмотреть и настроить через /proc/irq/<N>/smp_affinity и утилиту irqbalance.

Может ли пакет потеряться уже после того, как нашёл свой сокет?

Да — если буфер приёма переполнен, а приложение не успевает вычитывать, новые данные для UDP могут быть отброшены. Для TCP окно не даст отправителю передать больше, пока получатель не освободит место — теряется не сам пакет, а скорость передачи.

Помогает ли более мощный процессор, если узкое место — сетевая карта?

Не всегда напрямую. Если проблема в размере RX-кольца или в однопоточной обработке одной очереди прерываний, лишние ядра CPU помогут, только если трафик распределён по нескольким очередям карты (RSS) с привязкой прерываний к разным ядрам. Иначе один загруженный по softirq CPU так и останется узким местом.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →