Производительность систем Ввод-вывод: блокирующий и асинхронный, syscalls, буферизация
0%

Ввод-вывод: блокирующий и асинхронный, syscalls, буферизация

Ввод-вывод: блокирующий и асинхронный, syscalls, буферизация

Есть характерный момент в жизни инженера производительности: CPU-профиль снят, он идеален — ровный, без единого жирного кадра, суммарное время в профиле 4 секунды, — а запрос идёт 900 миллисекунд и сервис задыхается. Это не сломанный профайлер, это диагноз: программа не выполняется, она ждёт. Ждать можно только ввода-вывода в широком смысле — диск, сеть, сокет к базе, блокировку, которую держит тот, кто сам ждёт диск. Про CPU-профиль мы говорили в https://courses.digitable.life/post/performance/03-cpu-profiling/; эта статья — про вторую половину времени, которой в профиле нет. Гадание здесь особенно дорого: интуиция врёт систематически — люди винят «медленный диск» там, где виноваты триста тысяч системных вызовов в секунду, и наоборот.

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

1. Анатомия одного read(): что происходит между вызовом и данными

Строчка data = f.read(4096) выглядит как обращение к диску. На деле это путешествие через пять-шесть слоёв, и на большинстве из них диска нет вообще.

Путь одного read через слои ядра

Разберём по шагам, потому что каждый шаг — потенциальное место оптимизации:

  1. Буфер библиотекиbufio.Reader, FILE*, BufferedReader. Если данные уже там, системного вызова не будет вообще: самый дешёвый и самый недооценённый слой.
  2. Граница режима. Инструкция syscall (x86-64) или svc (ARM64) переводит процессор в режим ядра. Прямая цена — сотни наносекунд, косвенная иногда больше прямой (раздел 2).
  3. VFS. Дескриптор превращается в struct file, оттуда в inode и address_space — отображение «смещение в файле → страница в кэше».
  4. Page cache. Есть ли нужная страница 4 КиБ в памяти? В типичном сервисе большинство чтений заканчивается здесь: copy_to_user, возврат, единицы микросекунд, поток даже не засыпал.
  5. Block layer. Промах: формируется bio, попадает в очередь, планировщик может слить его с соседними. Поток снимают с процессора и усыпляют — это и есть off-CPU время.
  6. Драйвер и устройство. Команда идёт в аппаратную очередь, DMA пишет данные прямо в page cache, завершение приходит прерыванием (или поллингом на быстрых NVMe).

Отсюда три вывода. Первый: если данные горячие, «скорость диска» вообще не участвует в уравнении — вы меряете memcpy и стоимость syscall. Второй: холодное чтение дороже горячего на три-четыре порядка, поэтому среднее по такой смеси бессмысленно, нужны перцентили (https://courses.digitable.life/post/performance/01-measuring/). Третий: ожидание устройства не видно CPU-профайлеру, для него нужны отдельные инструменты.

2. Сколько на самом деле стоит системный вызов

Классическая цифра «syscall — это примерно 100 наносекунд» устарела дважды: сначала вверх из-за защит от Spectre/Meltdown, потом частично вниз из-за оптимизаций ядра и новых процессоров. Единственный честный ответ — измерить на своём железе.

Самый дешёвый syscall для замера — тот, что почти ничего не делает. getpid() не годится, если glibc его кэширует; берём заведомо не кэшируемый:

// Стоимость минимального системного вызова: getppid() ядро не кэширует.
// Компилировать: gcc -O2 syscost.c -o syscost
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>

int main(void) {
    const long N = 5000000;
    struct timespec a, b;
    clock_gettime(CLOCK_MONOTONIC, &a);
    for (long i = 0; i < N; i++)
        syscall(SYS_getppid);          // напрямую, минуя обёртки libc
    clock_gettime(CLOCK_MONOTONIC, &b);
    double ns = ((b.tv_sec - a.tv_sec) * 1e9 + (b.tv_nsec - a.tv_nsec)) / (double)N;
    printf("%.1f нс на системный вызов\n", ns);
    return 0;
}

Типичный результат на современном x86-64 сервере — от 60 до 500 наносекунд, и разброс здесь не шум, а конфигурация защит. Проверьте, за что вы платите:

$ grep . /sys/devices/system/cpu/vulnerabilities/*
.../meltdown:Mitigation: PTI
.../spectre_v2:Mitigation: Retpolines; IBPB: conditional; STIBP: disabled

PTI (page table isolation) означает, что на каждом входе в ядро и выходе из него меняется корень таблиц страниц, а значит частично сбрасывается TLB. Это и есть основная часть подорожания. Отсюда практическое следствие: цифры стоимости syscall из чужих статей 2015 года к вашей машине отношения не имеют.

Косвенная цена: то, чего нет в замере

Прямое время — только половина. Пока исполняется код ядра, он вытесняет из L1i, L1d, предсказателя ветвлений и TLB рабочие данные приложения; после возврата приложение работает медленнее, чем до вызова. В микробенчмарке getppid в цикле эта деградация не видна — там нет полезного рабочего множества, которое можно испортить. Работа Livio Soares и Michael Stumm FlexSC: Flexible System Call Scheduling with Exception-Less System Calls (OSDI 2010) измерила это прямо: косвенная стоимость сопоставима с прямой или превышает её, а батчинг вызовов на серверных нагрузках давал прирост в разы. Это тот же аргумент, который через десять лет привёл к io_uring. Практический вывод: микробенчмарк syscall систематически занижает его настоящую цену — тот же класс самообмана, что в https://courses.digitable.life/post/performance/02-benchmarking/.

Как посчитать syscalls в реальном коде

# Профиль системных вызовов процесса: сколько, сколько времени, где ошибки.
$ strace -c -f -p 12345
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 41.02    2.187934           1   1912004           write
 23.55    1.256221           3    418003           futex
 18.90    1.008112           2    417996           read
  9.11    0.485904          11     44182           epoll_wait
------ ----------- ----------- --------- --------- ----------------
100.00    5.334114               2792185      1204 total

Полтора миллиона write за время замера — это диагноз ещё до того, как вы посмотрели на диск. Обратите внимание: strace замедляет процесс в разы (каждый вызов перехватывается через ptrace), поэтому цифру usecs/call из него нельзя использовать как оценку задержки. Считайте по нему количество, а время меряйте менее инвазивными инструментами:

# Частота системных вызовов без ptrace-оверхеда
$ perf stat -e 'syscalls:sys_enter_*' -p 12345 sleep 10

# То же с распределением задержек, bpftrace
$ bpftrace -e '
  tracepoint:syscalls:sys_enter_write /pid == 12345/ { @start[tid] = nsecs; }
  tracepoint:syscalls:sys_exit_write  /@start[tid]/  {
      @us = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'

vDSO: syscall, которого нет

clock_gettime, gettimeofday, getcpu реализованы в vDSO — странице кода, которую ядро отображает в каждый процесс; время читается из общей страницы данных без смены режима, за 15-25 наносекунд вместо сотен. Для замеров это критично: инструментация с clock_gettime вокруг каждой операции почти бесплатна — но только пока источник времени tsc. Если в /sys/devices/system/clocksource/clocksource0/current_clocksource стоит xen или hpet (старые виртуалки), vDSO не работает, каждый замер становится настоящим системным вызовом и инструментация начинает стоить дороже измеряемого кода.

3. Четыре модели ввода-вывода: две независимые оси

Слова «блокирующий» и «асинхронный» постоянно путают, потому что за ними стоят две разные оси, а не одна. Классификация из «UNIX Network Programming» Стивенса до сих пор лучшая:

  • Блокирующий или неблокирующий — свойство файлового дескриптора (O_NONBLOCK). Отвечает на вопрос: что делает вызов, если данных нет — усыпляет поток или возвращает EAGAIN.
  • Синхронный или асинхронный — свойство API. Отвечает на вопрос: копирование данных выполняет вызывающий поток или ядро делает это само и потом сообщает о готовом результате.

По Стивенсу, всё, кроме POSIX AIO и io_uring, — синхронный ввод-вывод: epoll только сообщает, что данные готовы, а копировать их вы всё равно будете сами в вызове read.

Модель Кто ждёт Стоимость на соединение Где применяется
Блокирующий, поток на соединение ядро усыпляет поток стек 0,5-8 МиБ, переключения контекста классические серверы, JDBC-пулы, простые демоны
Неблокирующий с поллингом приложение крутит цикл сжигает CPU почти нигде, кроме спец-случаев с низкой задержкой
Мультиплексирование (select/poll/epoll/kqueue) один поток ждёт много fd ~КиБы состояния nginx, Node.js, Netty, Go runtime внутри
Настоящий асинхронный (io_uring, IOCP) ядро делает всё, сообщает результат минимальная новые сетевые движки, БД, прокси

select, poll, epoll: почему это про сложность

select и poll при каждом вызове передают в ядро весь список дескрипторов и ядро проходит по нему целиком: $O(n)$ на вызов и $O(n)$ копирования из пользовательской памяти. При 10 000 соединений, из которых активны 20, вы 500 раз впустую трогаете память ради каждого полезного события. Это и есть «проблема C10K», сформулированная Дэном Кегелем в The C10K problem.

epoll (Linux) и kqueue (BSD/macOS) хранят множество наблюдаемых дескрипторов в ядре: регистрация — отдельный вызов epoll_ctl, ожидание возвращает только готовые. Сложность ожидания — $O(k)$, где $k$ — число готовых событий, независимо от общего числа соединений.

Практический нюанс — уровневый (LT) и краевой (ET) режимы. При LT epoll_wait сообщает о готовности, пока в буфере есть хоть байт: безопасно, но даёт лишние пробуждения. При ET событие приходит один раз на переход «не было данных → появились», и вы обязаны читать в цикле до EAGAIN, иначе остаток зависнет навсегда. Классическая ошибка — включить ET ради производительности и получить редко воспроизводимые залипания.

Куда делся блокирующий код в Go и Java

В Go conn.Read(buf) выглядит блокирующим, но рантайм переводит сетевые дескрипторы в неблокирующий режим и обслуживает их через netpoller (epoll/kqueue/IOCP): при EAGAIN горутина паркуется, поток уходит на другую работу (https://courses.digitable.life/post/golang/04-concurrency/). Java с виртуальными потоками (Project Loom) пришла к тому же.

Оговорка для профилирования: netpoller работает для сокетов и пайпов, но не для обычных файлов. Файловый read на Linux блокирует поток по-настоящему, и Go отдаёт под него отдельный поток ОС. Сервис, который «асинхронно» читает тысячи мелких файлов, на деле создаёт сотни потоков — видно в /proc/PID/status, поле Threads.

4. Буферизация: самая дешёвая оптимизация ввода-вывода

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

Арифметика

Пусть надо записать $N$ байт буфером размера $B$. Время складывается из двух слагаемых: переходы в ядро и собственно копирование байт.

$$T(B) = \left\lceil \frac{N}{B} \right\rceil \cdot C_{sys} + N \cdot c_{copy}$$

Здесь $C_{sys}$ — полная стоимость вызова (сотни наносекунд), $c_{copy}$ — стоимость копирования байта (единицы пикосекунд при пропускной способности памяти в десятки ГиБ/с). Первое слагаемое падает как $1/B$, второе от $B$ не зависит — отсюда форма кривой: резкое улучшение, пока доминируют syscalls, затем плато. Переход с 1 байта на 4 КиБ даёт трёхзначный процент, с 4 КиБ на 64 КиБ — десятки процентов, с 64 КиБ на 8 МиБ — почти ничего, зато память тратится. По времени $O(N/B + N)$, по памяти $O(B)$.

Замер, а не вера

import os, time, subprocess

PATH, TOTAL = "/tmp/io-demo.bin", 64 * 1024 * 1024

def write_with(chunk_size: int) -> tuple[float, int]:
    """Пишет TOTAL байт кусками chunk_size, возвращает (секунды, число write)."""
    chunk = b"x" * chunk_size
    calls = TOTAL // chunk_size
    fd = os.open(PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    t0 = time.perf_counter()
    for _ in range(calls):
        os.write(fd, chunk)          # os.write — ровно один syscall, без буфера Python
    os.fsync(fd)                     # ОБЯЗАТЕЛЬНО: иначе меряем скорость записи в память
    dt = time.perf_counter() - t0
    os.close(fd)
    return dt, calls

for size in (1, 64, 512, 4096, 65536, 1 << 20):
    dt, calls = write_with(size)
    print(f"{size:>8} Б: {dt:6.3f} с, {calls:>9} вызовов write, "
          f"{TOTAL / dt / 2**20:8.1f} МиБ/с")

Характерная форма результата (конкретные числа зависят от машины — воспроизведите у себя):

       1 Б: 24.180 с,  67108864 вызовов write,     2.6 МиБ/с
      64 Б:  0.412 с,   1048576 вызовов write,   155.3 МиБ/с
     512 Б:  0.089 с,    131072 вызовов write,   719.1 МиБ/с
    4096 Б:  0.041 с,     16384 вызовов write,  1560.9 МиБ/с
   65536 Б:  0.033 с,      1024 вызовов write,  1939.4 МиБ/с
 1048576 Б:  0.032 с,        64 вызовов write,  2000.0 МиБ/с

Разница между первой и последней строкой — почти три порядка, и ни одна из них не про скорость диска: с 4 КиБ и выше упираемся в пропускную способность памяти и writeback. Метод здесь важнее чисел: постройте такую таблицу для своего кода, найдите точку выхода на плато и не увеличивайте буфер дальше.

Три уровня буферов, которые надо различать

Три ловушки этого пайплайна:

  1. Строчная буферизация stdio. Когда stdout — терминал, glibc включает line buffering: flush на каждом переводе строки; при перенаправлении в файл — полная буферизация. Отсюда классика: в терминале программа работает 40 секунд, а prog > out.txt — 3. Управляется setvbuf, в Python — PYTHONUNBUFFERED.
  2. Двойная буферизация. bufio.Writer поверх os.File поверх page cache — три копии одних байт. Обычно плата оправданная, но при копировании больших файлов лишний слой стоит убрать (см. zero-copy ниже).
  3. write() вернул успех — данных на диске нет. Он лишь пометил страницы грязными; долговечность даёт только fsync/fdatasync, и он на порядки дороже. Бенчмарк записи без fsync меряет скорость оперативной памяти. Как это ломает реальные системы — Files are hard.

5. Page cache: главный буфер, о котором забывают в бенчмарках

Page cache — это вся свободная память машины, отданная под кэш файлов. free -h в колонке buff/cache показывает его размер. Именно он объясняет, почему второй прогон бенчмарка быстрее первого в десятки раз.

Как измерять попадания, а не гадать:

# Что из файла сейчас в кэше (vmtouch, пакет vmtouch)
$ vmtouch -v /var/lib/app/data.db
           Files: 1
     Directories: 0
  Resident Pages: 214512/524288  838M/2G  40.9%

# Частота попаданий page cache в реальном времени (bcc-tools)
$ cachestat 1
    HITS   MISSES  DIRTIES HITRATIO   BUFFERS_MB  CACHED_MB
   84213      291     1204   99.66%          142       7418
   79855    12043      998   86.90%          142       7402

# Кто и сколько читает с реального устройства
$ biolatency-bpfcc -m 10 1

Прогрев и сброс кэша. Для честного холодного замера кэш сбрасывают:

$ sync && echo 3 | sudo tee /proc/sys/vm/drop_caches

Но какой сценарий вы меряете? В проде кэш почти никогда не пустой и почти никогда не полностью горячий. Правильный подход — мерить оба режима отдельно и знать долю попаданий в проде, а не усреднять неизвестную смесь; это продолжение разговора о прогреве из https://courses.digitable.life/post/performance/02-benchmarking/.

Подсказки ядру. posix_fadvise(POSIX_FADV_SEQUENTIAL) усиливает readahead, POSIX_FADV_DONTNEED выбрасывает страницы после использования — это спасает потоковую обработку больших файлов, которая иначе вытесняет из кэша по-настоящему горячие данные базы. madvise делает то же для отображённых областей.

6. Асинхронный ввод-вывод: от POSIX AIO до io_uring

Почему Linux AIO не взлетел

io_submit/io_getevents (Linux AIO, 2003) обещали настоящую асинхронность, но с оговоркой, которая убила идею: асинхронно они работают только с O_DIRECT. На обычном файле через page cache вызов молча становится блокирующим. Плюс на каждую пачку нужны два системных вызова, и набор поддерживаемых операций крошечный. POSIX AIO в glibc ещё хуже — это эмуляция через пул потоков.

io_uring: убрать не ожидание, а переходы

io_uring (Йенс Аксбо, ядро 5.1, 2019) решает другую задачу: не «как не ждать», а «как не пересекать границу ядра». Две кольцевые очереди лежат в памяти, отображённой и в процесс, и в ядро.

Устройство колец io_uring

Отсюда практическая арифметика. Классический epoll-цикл тратит на одно готовое соединение 2 системных вызова (read + write) плюс один epoll_wait на итерацию: $1 + 2k$ переходов на $k$ событий. io_uring публикует и забирает те же $2k$ операций за один io_uring_enter, а в режиме SQPOLL (ядро само крутит поток опроса кольца) — за ноль. При 200 000 запросов в секунду и $C_{sys} = 300$ нс экономия порядка 0,1-0,3 секунды процессорного времени в секунду — до трети ядра, вернувшейся приложению.

Ради чего io_uring берут, кроме батчинга: цепочки (IOSQE_IO_LINK — «прочитай, затем отправь» без возврата в приложение между шагами), регистрация буферов и файлов (io_uring_register заранее пиннит страницы и кэширует struct file), и то, что он работает и для файлов, и для сокетов — в отличие от epoll, бесполезного для обычных файлов.

Трезвая оценка: io_uring — не «быстрее в десять раз для всех». Он выигрывает там, где syscalls составляют заметную долю профиля: прокси, key-value хранилища, движки БД, файловые серверы. Для сервиса на 500 запросов в секунду, который 90% времени ждёт базу, он не изменит ничего, и проверяется это одной командой: perf stat -e 'syscalls:sys_enter_*'. Учтите и эксплуатацию: io_uring не раз становился источником уязвимостей ядра, и часть облачных платформ и seccomp-профилей его блокирует. Практический слой — в C liburing, в Rust tokio-uring, в Go iceber/iouring-go.

7. Zero-copy: перестать возить байты туда-сюда

Наивная отдача файла в сокет:

buf := make([]byte, 32*1024)
for {
    n, err := file.Read(buf)   // копия: page cache → буфер приложения
    if n > 0 {
        conn.Write(buf[:n])    // копия: буфер приложения → буфер сокета
    }
    if err != nil { break }
}

Считаем: DMA пишет в page cache (копия 1, делает железо), read копирует в пользовательский буфер (копия 2), write копирует обратно в память ядра (копия 3), сетевая карта забирает по DMA (копия 4). Плюс четыре пересечения границы режима на каждые 32 КиБ.

sendfile(out_fd, in_fd, offset, count) убирает две средних копии и два перехода: данные вообще не появляются в адресном пространстве процесса. На сетевых картах со scatter-gather DMA остаётся фактически одна копия. В Go это происходит автоматически:

// io.Copy распознаёт связку *os.File → *net.TCPConn и уходит в sendfile/splice.
// Проверить, что это сработало: strace -f -e trace=sendfile,splice,write ./server
n, err := io.Copy(conn, file)

Смежные механизмы: splice (между пайпом и дескриптором, не требует сокета на приёме), MSG_ZEROCOPY (для send, полезен от нескольких десятков КиБ, добавляет асинхронное подтверждение через MSG_ERRQUEUE), mmap + write (одна копия вместо двух, но платите page faults и TLB-шутдаунами при munmap). У mmap репутация «быстрого чтения файлов», но это верно лишь для повторного случайного доступа к горячим данным: для однократного последовательного прохода обычный read с крупным буфером не хуже и предсказуемее — разбор в Are You Sure You Want to Use MMAP in Your Database Management System? (CIDR 2022). Где zero-copy уже работает за вас: Kafka отдаёт сегменты лога через sendfile, nginx — статику (sendfile on;), Netty — FileRegion.

8. Устройства, очереди и закон Литтла

Байты в конце концов доходят до железа, и здесь работает не «скорость», а очередь.

Носитель Задержка одной операции IOPS при глубине очереди 1 При высокой глубине
HDD 7200 об/мин 5-10 мс (seek + оборот) ~100-200 ~200 (упирается в механику)
SATA SSD 100-300 мкс 3-10 тыс. 60-100 тыс.
NVMe SSD 20-100 мкс 10-30 тыс. сотни тысяч - миллионы
Сетевой диск (EBS gp3 и аналоги) 0,3-1 мс ~1-3 тыс. по купленному лимиту

Оговорка обязательна: это порядки величин на середину 2020-х, а не константы. Знаменитый список Latency Numbers Every Programmer Should Know Джеффа Дина составлен около 2012 года, и именно строки про диски и сеть устарели сильнее всего: «SSD random read 150 мкс» — это про SATA-накопитель эпохи, NVMe быстрее в разы. Пользуйтесь такими таблицами как картой порядков («диск на три порядка дороже памяти»), а конкретные числа берите из своего fio.

Связь задержки, пропускной способности и очереди даёт закон Литтла — тот же, что в https://courses.digitable.life/post/performance/01-measuring/:

$$L = \lambda \times W \quad\Rightarrow\quad \text{глубина очереди} = \text{IOPS} \times \text{задержка}$$

NVMe с задержкой 80 мкс при глубине очереди 1 даёт максимум 12 500 IOPS — сколько бы миллионов ни было написано в спецификации. Чтобы получить 500 000 IOPS, нужно держать в полёте примерно 40 операций одновременно. Отсюда главный вывод про асинхронный ввод-вывод: он нужен не чтобы «не ждать», а чтобы наполнить очередь устройства. Однопоточный синхронный код физически не может выжать из NVMe больше, чем позволяет одна операция в полёте.

fio как эталонный измеритель

# Случайное чтение 4 КиБ, глубина очереди 32, прямой ввод-вывод мимо page cache
$ fio --name=randread --filename=/dev/nvme0n1 --rw=randread --bs=4k \
      --iodepth=32 --ioengine=io_uring --direct=1 --time_based --runtime=60 \
      --ramp_time=10 --group_reporting

read: IOPS=412k, BW=1610MiB/s (1688MB/s)
  clat percentiles (usec):
   |  1.00th=[   41],  50.00th=[   71], 90.00th=[  118],
   | 99.00th=[  184], 99.90th=[  318], 99.99th=[ 1074]

Что здесь принципиально: --direct=1 (иначе меряете page cache), --ramp_time (отбрасывает разогрев, как в бенчмарках CPU), --time_based --runtime=60 (минута — минимум, чтобы SSD вышел из псевдо-стационарного режима), перцентили вместо среднего. Разрыв p90 = 118 мкс против p99.99 = 1074 мкс — это сборка мусора внутри контроллера SSD; она никуда не денется, её надо закладывать в бюджет.

# Что происходит с устройством прямо сейчас
$ iostat -x 1
Device   r/s     w/s   rkB/s   wkB/s  r_await  w_await  aqu-sz  %util
nvme0n1  8412   1203  33648   19248     0.09     0.31    0.94  62.3

Читать так: r_await/w_await — задержка, включая время в очереди; aqu-sz — средняя глубина очереди (та самая $L$ из закона Литтла). А вот %util на SSD не значит ничего: метрика придумана для устройств, обслуживающих одну операцию за раз. NVMe с 64 параллельными очередями показывает 100% при десятой части своей реальной ёмкости. Смотреть надо на await в сравнении с базовой задержкой устройства и на насыщение по IOPS.

9. Как это измерять: инструменты и порядок действий

Порядок действий начинается ровно там, где заканчивается CPU-профилирование: время в профиле сильно меньше стенных часов — значит, вы ждёте.

Отдельного упоминания стоит off-CPU flame graph — та же картинка, что обычный flame graph, но построенная по времени сна, а не по времени на процессоре:

# Стеки, где процесс спал, с суммарным временем в микросекундах
$ offcputime-bpfcc -df -p $(pgrep myservice) 30 > out.stacks
$ flamegraph.pl --title="Off-CPU time" --countname=us out.stacks > offcpu.svg

Она отвечает ровно на вопрос «где мы ждали» и читается за минуту: fsync под коммитом транзакции, read конфига в горячем цикле, epoll_wait (норма — так выглядит простой), futex под спорной блокировкой. Методология — у Брендана Грегга, Off-CPU Analysis.

10. Как врут бенчмарки ввода-вывода

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

  1. Второй прогон меряет page cache. Первый холодный, второй горячий, разница в 10-100 раз. Не решили заранее, какой режим моделируете, — число ни о чём.
  2. Датасет меньше оперативной памяти. Тест на 2 ГиБ при 64 ГиБ RAM — бенчмарк памяти с диском в роли декорации. Рабочее множество должно превышать RAM хотя бы вдвое, если вы меряете диск.
  3. Запись без fsync. «100 000 транзакций в секунду», после fsync их окажется 800. Самая частая причина фантастических цифр у самодельных хранилищ.
  4. Глубина очереди 1. На NVMe это меряет задержку, а не пропускную способность; iodepth=256 даёт обратное искажение — красивый throughput и чудовищный p99. Указывайте глубину явно, под реальный профиль.
  5. Нули вместо данных. dd if=/dev/zero на СХД с компрессией даёт скорость, которой на реальных данных не будет никогда. Нужен fio --buffer_compress_percentage или случайные данные.
  6. Burst-кредиты облака. gp2/gp3 и burstable-инстансы: первые 20-30 минут отличные цифры, потом обрыв в 5-10 раз. Гоняйте тест дольше времени накопления кредитов, иначе измерите рекламу.
  7. Первая запись на пустой SSD. Свежий или полностью TRIM-нутый накопитель пишет заметно быстрее, чем заполненный на 80% с активной сборкой мусора. Нужен prefill и --ramp_time.
  8. Ошибка выжившего в таймаутах. Клиент рвёт соединение по таймауту 2 с, эти запросы не попадают в статистику — и p99 прекрасен ровно потому, что худшие случаи вырезаны из выборки. Считайте долю таймаутов и ошибок отдельно.
  9. Coordinated omission. Генератор с закрытой моделью притормаживает вместе с системой и не создаёт очередь, которая была бы в реальности (https://courses.digitable.life/post/performance/11-load-testing/, https://courses.digitable.life/post/performance/01-measuring/).
  10. strace в роли профайлера. Замедляет процесс в разы и меняет соотношение стоимостей. Считайте им вызовы, а время меряйте через eBPF или perf.

11. Что делать: приёмы в порядке отношения выгоды к риску

  1. Добавить буфер. Одна строчка, эффект в разы, риск нулевой. Проверить strace -c до и после.
  2. Укрупнить операции. Читать по 64 КиБ вместо 4 КиБ, писать пачками, склеивать мелкие файлы.
  3. Батчить на уровне протокола. Один запрос к базе вместо N — тот же принцип (https://courses.digitable.life/post/performance/08-database-performance/).
  4. Убрать syscalls, которых не должно быть. stat перед каждым open, чтение конфига в цикле, логирование без буфера в горячем пути.
  5. Кэшировать результат, а не ускорять чтение. Самый быстрый ввод-вывод — тот, которого не было (https://courses.digitable.life/post/performance/09-caching/).
  6. Наполнить очередь устройства. Параллельные чтения, readahead, iodepth — там, где мешает задержка.
  7. Zero-copy для больших передач. sendfile/splice, если профиль показывает копирование.
  8. Групповой коммит для fsync. Один fsync на пачку транзакций — так работает WAL во всех серьёзных СУБД.
  9. Сменить модель ввода-вывода. epoll вместо потока на соединение, io_uring вместо epoll — только после того, как измерения показали, что упёрлись именно в переходы.
  10. Сменить железо. Дороже всего, но иногда честный ответ: HDD не станет NVMe от оптимизации кода.

Порядок не случаен: первые пункты дают больший эффект за меньший риск. Нарушать его — верный способ потратить месяц на io_uring в сервисе, который тормозит из-за небуферизованного логгера.

Мини-итог

  • Ввод-вывод — это время, которого нет в CPU-профиле. Сумма времени в профиле сильно меньше стенных часов — берите off-CPU профиль, а не гадайте.
  • Системный вызов стоит сотни наносекунд напрямую и примерно столько же косвенно, через испорченные кэши и TLB. Микробенчмарк syscall систематически занижает цену.
  • Производительность определяют три числа: количество переходов в ядро, количество копий данных, глубина очереди. Устройство почти всегда быстрее, чем разговор с ним.
  • Буферизация — самая дешёвая и недооценённая оптимизация. Кривая «размер буфера → время» выходит на плато на десятках килобайт, дальше растёт только расход памяти.
  • Page cache — главный источник вранья в бенчмарках; write без fsync — это запись в память, и цифры пропускной способности без него завышены в десятки раз.
  • epoll убирает $O(n)$ обход дескрипторов, io_uring — сами переходы. Второе имеет смысл только там, где syscalls реально видны в профиле.
  • Асинхронность нужна не «чтобы не ждать», а чтобы держать очередь устройства наполненной: закон Литтла жёстко связывает IOPS, задержку и глубину очереди.
  • Таблицы задержек — карта порядков величин, а не источник констант; цифрам Джеффа Дина больше десяти лет, и строки про диски и сеть устарели сильнее всего.

Источники

Что дальше

Мы разобрали ожидание, вызванное устройствами. Но в списке причин off-CPU времени был ещё один частый пункт — futex. Это уже не ввод-вывод: это потоки, которые ждут друг друга. Там действуют свои законы — контеншн на блокировках, false sharing из-за раскладки данных по кэш-линиям и жёсткий потолок ускорения, который ставит закон Амдала. Следующая статья — про то, почему добавление ядер часто не ускоряет, а замедляет.

Производительность конкурентного кода: контеншн, false sharing, закон Амдала

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов