awesome-everything EN
↑ Обратно к восхождению

Производительность

io_uring и наблюдаемость пакетирования

Суть Системный вызов стоит 1-5 мкс; при 100k оп/с это 100-500 мс/с на переходах. Кольца io_uring убирают цену каждой операции. Затем четыре метрики — размер пакета, ожидание, глубина, отброшенные — говорят, здоров батчер или тихо теряет данные.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 15 min

Флейм-граф сервиса приёма данных не имел смысла. CPU был забит на 90%, но ни одна бизнес-функция не была горячей. Самая широкая полоса, съедавшая треть каждого ядра, — это entry_SYSCALL_64, цена входа и выхода из ядра, повторённая миллионы раз в секунду. Сервис писал каждую строку лога своим write(). Он был медленным не из-за того, что делал, а из-за того, как часто пересекал границу.

Системный вызов — это стена, и ты платишь за каждое пересечение

Традиционный POSIX-ввод-вывод — это один системный вызов на операцию: read(), write(), recv(), send(). Каждый — это управляемая ловушка в ring 0: CPU сохраняет пользовательские регистры, переключает таблицу страниц и стек в контекст ядра, выполняет обработчик, а потом разворачивает всё обратно на выходе. Этот круговой путь стоит примерно 1-5 мкс, и это до того, как вызов сделает хоть какую-то реальную работу. Это чистые накладные расходы, оплачиваемые за каждый вызов.

Арифметика на масштабе беспощадна. Сервис, обрабатывающий 100k операций в/в в секунду, тратит 100,000 × 1-5 мкс = 100-500 мс каждой реальной секунды только на переходы — от десятой части до половины ядра уходит до того, как сдвинется хоть один байт. Подними до 1M оп/с, и традиционные read/write могут сжигать целую миллисекунду в секунду только на переходах, плюс загрязнение кэша от вымывания рабочего набора L1/L2 при каждом пересечении. Это и есть флейм-граф из вступления: работа была дешёвой, граница — нет.

Классическое решение — пересекать реже. Буферизуй много мелких записей и сбрасывай их одним большим writev(); один системный вызов теперь несёт тысячу записей. В этом вся суть пакетирования на уровне системного вызова: амортизировать фиксированную цену пересечения по переменной полезной нагрузке. Следующий шаг убирает большинство пересечений вовсе.

io_uring: вообще перестать пересекать границу

io_uring (Linux 5.1+) заменяет «один системный вызов на операцию» на два кольцевых буфера, mmap-нутых в память, разделяемую между пользовательским пространством и ядром:

  • Очередь подачи (SQ) — пользовательское пространство пишет дескрипторы операций (SQE: прочитать этот fd, записать этот буфер) в слот, затем продвигает указатель хвоста.
  • Очередь завершения (CQ) — ядро пишет результаты (CQE) обратно в слот и продвигает свой хвост.

В базовом режиме ты всё ещё вызываешь io_uring_enter(), чтобы сказать ядру «я поставил в очередь N операций», но это один системный вызов на весь пакет вместо N. Эффектный режим — IORING_SETUP_SQPOLL: ядро порождает поток, который непрерывно опрашивает хвост SQ. Пользовательское пространство подаёт работу, записывая память и продвигая указатель, а поток ядра сам её подхватывает — ноль системных вызовов на операцию. (Один нюанс, который стоит знать: если поток SQPOLL простаивает дольше sq_thread_idle, он засыпает и выставляет IORING_SQ_NEED_WAKEUP; тогда ты должен один io_uring_enter(), чтобы его разбудить. Так что ноль системных вызовов держится под устойчивой нагрузкой, а не на тонком ручейке.)

ПодходСистемных вызовов на 1M опЦена переходов / сНюанс
Один write() на операцию1 000 000~1-5 мс (0.1-0.5 ядра)Трэшинг кэша при каждом пересечении
Буфер + writev()~1 000 (пакет=1k)~1-5 мксДобавляет задержку ожидания до сброса
io_uring (один enter/пакет)~1 000~1-5 мксСложнее API; сбор CQE
io_uring + SQPOLL~0 (под нагрузкой)~0Жжёт ядро под поллер; нужны привилегии

Помимо устранения пересечений, io_uring открывает паттерны, которые обычные системные вызовы выразить не могут:

  • Связанные операции (IOSQE_IO_LINK) — цепляй SQE так, чтобы следующая запускалась только после завершения предыдущей, например acceptreadwrite, поданные как одна зависимая единица.
  • Предоставленные/зарегистрированные буферы — заранее зарегистрируй пул буферов один раз; ядро выбирает свободный буфер на операцию, вместо того чтобы ты регистрировал его каждый раз.
  • Фиксированные файлы — заранее зарегистрируй fd, чтобы ядро пропускало поиск в таблице дескрипторов на каждый системный вызов.

Принятие теперь массовое, а не экспериментальное. PostgreSQL 18 (вышел в сентябре 2025) принёс асинхронный в/в с тремя режимами io_methodsync, worker (по умолчанию) и io_uring, — где бэкенд io_uring снижает накладные расходы на системные вызовы при холодном кэше на последовательных и bitmap-сканах (бенчмарки сообщают о приросте пропускной способности в 2-3 раза в облачных сценариях хранения). Заметь, что по умолчанию стоит worker, а не io_uring, именно из-за зависимости и проблем безопасности ниже. На сетевой стороне io_uring снимает от единиц до десятка процентов CPU с нагрузок TLS-прокси и сокетов с высоким фан-аутом (на сокетном слое прокси на epoll тратят 70-80% циклов вне пользовательского пространства), поэтому команды малозатратных прокси за ним тянутся.

Почему это работает

Почему io_uring не стоит по умолчанию везде, если он быстрее? Безопасность. Это одна из самых эксплуатируемых подсистем ядра — CVE-2023-2598 (доступ за пределами границ) и CVE-2024-0582 (use-after-free при регистрации кольца буферов) обе являются локальным повышением привилегий с публичными эксплойтами. Google сообщил, что ~60% эксплойтов ядра, поданных в его bug bounty 2022 года, целились в io_uring, и отключил его по умолчанию в нескольких средах. Профиль seccomp по умолчанию в containerd и GKE блокируют системные вызовы io_uring напрямую. Так что в защищённом контейнере твой красивый дизайн без системных вызовов может просто вернуть EPERM. Всегда имей запасной путь на epoll/потоки.

Ты редко вызываешь io_uring напрямую — твой runtime пакетирует за тебя

Большинство сервисов никогда не трогают сырые кольца; они опираются на примитив runtime, который буферизует в пользовательском пространстве и сбрасывает одним пересечением. Формы рифмуются между языками:

  • Node.jsstream.cork() буферизует записи в памяти; uncork() (отложенный через process.nextTick) сбрасывает их одним _writev(), но только если поток реализует _writev; cork без него может навредить. Сочетай с backpressure через возвращаемое значение write().
  • Gobufio.Writer объединяет мелкие записи; скомбинируй с time.Ticker, чтобы сбрасывать по max-wait, давая классическое окно по размеру-или-времени.
  • JavaBufferedOutputStream накапливает, пока его буфер не заполнится или ты не вызовешь flush().
  • Pythonasyncio.Queue питает потребителя, который вычитывает чанками (get до пустоты или лимита счётчика).
  • Rust — каналы tokio::sync::mpsc с циклом пакетирования (recv_many / drain-and-flush по тику).

Каждый из них — один и тот же контракт: ограниченный буфер с триггером max-size, триггером max-wait и явным flush. И каждый из них — место, где данные могут тихо накапливаться или теряться, поэтому его и инструментируют.

Четыре метрики, которые говорят, что батчер здоров

Батчер — это крошечная очередь с политикой сброса, и как любая очередь он может заполниться, застрять или переполниться без выброса ошибки. Готовая к production наблюдаемость отслеживает четыре метрики на пакет; вместе они позволяют настроить окно и поймать backpressure до того, как он станет потерей данных.

МетрикаТипЧто показываетДействие
Гистограмма размера пакетаГистограмма (p50/p99, записи и байты)Заполняется до max (хорошо) или сбрасывается по таймеру (окно мало / трафик слабый)Настрой max-size / max-wait
Время ожидания пакетаГистограмма (задержка)Как долго элемент ждал отправки — твой налог на задержкуСверь с SLO; сократи окно
Датчик глубины буфераGauge (текущие элементы / % предела)Устойчивые всплески = downstream не успевает (нарастает backpressure)Алерт при > 80% предела; масштабируй/тормози продюсера
Счётчик отброшенныхCounterЭлементы, выброшенные при переполнении — должно быть 0; ненулевое = теряешь данныеПейджер при drops > 0

Режим отказа, который прячется без этих метрик, — тихий сброс. Система доставки логов Scribe от Facebook — каноническая военная история: буферизованный пакетирующий конвейер, который под давлением downstream должен выбирать между блокировкой продюсера (запереть всё приложение) и сбросом сообщений. Если ты следишь только за пропускной способностью, всплеск downstream выглядит нормально ровно до тех пор, пока буфер не переполнится и твои метрики хвостовой задержки p99 не начнут исчезать с дашборда, потому что события, которые их несли, были отброшены. Дашборд говорит «здоров», потому что выжившие выглядят здорово. Рефлекс сеньора: глубина буфера и счётчик отброшенных — опережающие индикаторы; пропускная способность — запаздывающий. Алерт на drops > 0 и depth > 80% предела, и переполнение становится пейджем, на который ты отвечаешь, а не инцидентом, который реконструируешь.

Почему это работает

«Счётчик отброшенных должен быть нулём» звучит очевидно, но более глубокая мысль — что значит ненулевой сброс. Это никогда не тонкость настройки — это последний сигнал буфера о том, что продюсер обгоняет потребителя, и ограниченная очередь выбрала сбросить нагрузку, а не расти без предела (что было бы OOM). Один сброс — это событие backpressure. Относись к нему как к сброшенной записи в базу данных.

Викторина

Гистограмма размера пакета у пакетирующего writer показывает, что почти каждый сброс намного ниже настроенного max size, а время ожидания пакета сидит ровно на значении max-wait. О чём это говорит?

Компромисс сеньора: насколько агрессивно пакетировать

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

Выбери лучший вариант

Путь приёма телеметрии делает ~200k мелких записей/с и упирается в CPU на переходах системных вызовов. SLO p99 сквозной задержки — 200 мс. Выбери подход, который защитит сеньор.

Расставь шаги по порядку

Расставь шаги диагностики, когда пакетирующий путь приёма начинает терять данные под нагрузкой:

  1. 1 Проверь счётчик отброшенных — ненулевое значит, что ограниченный буфер сбрасывает нагрузку
  2. 2 Посмотри на датчик глубины буфера — прижат к пределу подтверждает переполнение буфера
  3. 3 Изучи время ожидания / размер пакета — успевает ли политика сброса или застревает?
  4. 4 Найди узкое место downstream (потребителя, который не успевает вычитывать)
  5. 5 Примени backpressure или масштабируй потребителя; только потом перенастраивай окно
Вспомните перед уходом
  1. 01
    Как io_uring устраняет накладные расходы на системный вызов на каждый вызов и в чём нюанс режима SQPOLL?
  2. 02
    Какие четыре метрики наблюдаемости пакетирования и какие две из них — опережающие индикаторы беды?
Итог

Системный вызов стоит ~1-5 мкс чистых накладных расходов на переход, так что при 100k-1M оп/с сервис может сжечь от десятой части до целого ядра только на пересечении границы ядра. Первое решение — пересекать реже: буферизуй много мелких записей и сбрасывай одним writev(). io_uring идёт дальше с двумя mmap-нутыми кольцами (подача + завершение) — один io_uring_enter() подаёт весь пакет, а режим SQPOLL позволяет потоку ядра опрашивать очередь почти без системных вызовов под нагрузкой, ценой ядра под поллер, привилегий и блокировки seccomp в защищённых контейнерах. Бэкенд io_uring в PostgreSQL 18 и малозатратные TLS-прокси — реальные адоптеры, но история CVE у io_uring (и находка Google о 60% эксплойтов) — причина, почему режим worker стоит по умолчанию в Postgres. На практике ты пакетируешь через примитив runtime — Node cork()/uncork(), Go bufio.Writer + ticker, Java BufferedOutputStream, Python asyncio.Queue, Rust tokio mpsc — каждый из них ограниченный буфер с max-size, max-wait и flush. Инструментируй все четыре метрики: размер пакета (заполнение против таймера), время ожидания (налог на задержку), глубину буфера и счётчик отброшенных (опережающие сигналы backpressure). Пропускная способность запаздывает; глубина и отброшенные опережают. Пейджер при drops > 0 и depth > 80% предела, и тихое переполнение становится алертом, а не археологическими раскопками.

Связанные уроки
встречается в260
Продолжить восхождение ↑От Nagle до io_uring: эволюция пакетирования
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.