Ввод-вывод: блокирующий и асинхронный, syscalls, буферизация
Есть характерный момент в жизни инженера производительности: CPU-профиль снят, он идеален — ровный, без единого жирного кадра, суммарное время в профиле 4 секунды, — а запрос идёт 900 миллисекунд и сервис задыхается. Это не сломанный профайлер, это диагноз: программа не выполняется, она ждёт. Ждать можно только ввода-вывода в широком смысле — диск, сеть, сокет к базе, блокировку, которую держит тот, кто сам ждёт диск. Про CPU-профиль мы говорили в https://courses.digitable.life/post/performance/03-cpu-profiling/; эта статья — про вторую половину времени, которой в профиле нет. Гадание здесь особенно дорого: интуиция врёт систематически — люди винят «медленный диск» там, где виноваты триста тысяч системных вызовов в секунду, и наоборот.
Ключевая мысль: производительность ввода-вывода определяется не скоростью устройства, а тремя счётчиками — сколько раз вы пересекли границу ядра, сколько раз скопировали байты и сколько операций держите в полёте одновременно. Устройство обычно быстрее, чем то, как вы с ним разговариваете.
1. Анатомия одного read(): что происходит между вызовом и данными
Строчка data = f.read(4096) выглядит как обращение к диску. На деле это путешествие через пять-шесть слоёв,
и на большинстве из них диска нет вообще.
Разберём по шагам, потому что каждый шаг — потенциальное место оптимизации:
- Буфер библиотеки —
bufio.Reader,FILE*,BufferedReader. Если данные уже там, системного вызова не будет вообще: самый дешёвый и самый недооценённый слой. - Граница режима. Инструкция
syscall(x86-64) илиsvc(ARM64) переводит процессор в режим ядра. Прямая цена — сотни наносекунд, косвенная иногда больше прямой (раздел 2). - VFS. Дескриптор превращается в
struct file, оттуда в inode иaddress_space— отображение «смещение в файле → страница в кэше». - Page cache. Есть ли нужная страница 4 КиБ в памяти? В типичном сервисе большинство чтений
заканчивается здесь:
copy_to_user, возврат, единицы микросекунд, поток даже не засыпал. - Block layer. Промах: формируется
bio, попадает в очередь, планировщик может слить его с соседними. Поток снимают с процессора и усыпляют — это и есть off-CPU время. - Драйвер и устройство. Команда идёт в аппаратную очередь, 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. Метод здесь важнее чисел: постройте такую таблицу для своего кода, найдите точку выхода на плато и не увеличивайте буфер дальше.
Три уровня буферов, которые надо различать
bufio / BufferedWriter"} B -->|"не заполнен"| B1["возврат, 0 syscalls"] B -->|"заполнен → flush"| C{"Буфер stdio libc
full / line / none"} C -->|"строчный режим"| C1["flush на каждой строке
частая причина медленного вывода"] C --> D["syscall write()"] D --> E{"Page cache ядра"} E -->|"обычная запись"| E1["страница помечена dirty,
возврат немедленно"] E -->|"O_DIRECT"| E2["мимо кэша, прямо в DMA"] E1 --> F["Фоновый writeback
vm.dirty_ratio, pdflush"] F --> G["Устройство"] E1 --> H{"fsync / fdatasync?"} H -->|"да"| G H -->|"нет"| H1["данные могут пропасть
при пропадании питания"] E2 --> G
Три ловушки этого пайплайна:
- Строчная буферизация stdio. Когда stdout — терминал, glibc включает line buffering:
flushна каждом переводе строки; при перенаправлении в файл — полная буферизация. Отсюда классика: в терминале программа работает 40 секунд, аprog > out.txt— 3. Управляетсяsetvbuf, в Python —PYTHONUNBUFFERED. - Двойная буферизация.
bufio.Writerповерхos.Fileповерх page cache — три копии одних байт. Обычно плата оправданная, но при копировании больших файлов лишний слой стоит убрать (см. zero-copy ниже). 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) решает другую задачу: не «как не ждать», а «как не пересекать
границу ядра». Две кольцевые очереди лежат в памяти, отображённой и в процесс, и в ядро.
Отсюда практическая арифметика. Классический 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-профилирование: время в профиле сильно меньше стенных часов — значит, вы ждёте.
CPU-профиль почти пустой"] --> A{"Время в CPU-профиле намного
меньше стенных часов?"} A -->|"нет"| CPU["Это не I/O,
вернуться к CPU-профилю"] A -->|"да"| B["Снять off-CPU профиль:
offcputime-bpfcc -p PID 30"] B --> C{"Где спим?"} C -->|"futex, mutex"| LOCK["Контеншн — тема
следующей статьи"] C -->|"epoll_wait, poll"| NET["Ждём сеть или БД:
смотреть на ту сторону"] C -->|"read, write, fsync"| D["strace -c -f: сколько вызовов?"] D -->|"миллионы мелких"| BUF["Буферизация,
батчинг, sendfile"] D -->|"немного, но долгих"| E["iostat -x, biolatency"] E -->|"await высокий,
aqu-sz большой"| SAT["Устройство насыщено:
шардировать, кэшировать,
менять носитель"] E -->|"await низкий"| F{"Много ли fsync?"} F -->|"да"| FS["Групповой коммит,
WAL, батчинг записи"] F -->|"нет"| G["cachestat: hit ratio"] G -->|"низкий"| CACHE["Рабочее множество не влезает:
больше памяти или
другой доступ к данным"] G -->|"высокий"| MEM["Упёрлись в копирование:
zero-copy, крупнее буфер"]
Отдельного упоминания стоит 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. Как врут бенчмарки ввода-вывода
Отдельный список, потому что здесь самообманов больше, чем где-либо ещё в измерениях производительности.
- Второй прогон меряет page cache. Первый холодный, второй горячий, разница в 10-100 раз. Не решили заранее, какой режим моделируете, — число ни о чём.
- Датасет меньше оперативной памяти. Тест на 2 ГиБ при 64 ГиБ RAM — бенчмарк памяти с диском в роли декорации. Рабочее множество должно превышать RAM хотя бы вдвое, если вы меряете диск.
- Запись без
fsync. «100 000 транзакций в секунду», послеfsyncих окажется 800. Самая частая причина фантастических цифр у самодельных хранилищ. - Глубина очереди 1. На NVMe это меряет задержку, а не пропускную способность;
iodepth=256даёт обратное искажение — красивый throughput и чудовищный p99. Указывайте глубину явно, под реальный профиль. - Нули вместо данных.
dd if=/dev/zeroна СХД с компрессией даёт скорость, которой на реальных данных не будет никогда. Нуженfio --buffer_compress_percentageили случайные данные. - Burst-кредиты облака. gp2/gp3 и burstable-инстансы: первые 20-30 минут отличные цифры, потом обрыв в 5-10 раз. Гоняйте тест дольше времени накопления кредитов, иначе измерите рекламу.
- Первая запись на пустой SSD. Свежий или полностью TRIM-нутый накопитель пишет заметно быстрее, чем
заполненный на 80% с активной сборкой мусора. Нужен prefill и
--ramp_time. - Ошибка выжившего в таймаутах. Клиент рвёт соединение по таймауту 2 с, эти запросы не попадают в статистику — и p99 прекрасен ровно потому, что худшие случаи вырезаны из выборки. Считайте долю таймаутов и ошибок отдельно.
- Coordinated omission. Генератор с закрытой моделью притормаживает вместе с системой и не создаёт очередь, которая была бы в реальности (https://courses.digitable.life/post/performance/11-load-testing/, https://courses.digitable.life/post/performance/01-measuring/).
straceв роли профайлера. Замедляет процесс в разы и меняет соотношение стоимостей. Считайте им вызовы, а время меряйте через eBPF илиperf.
11. Что делать: приёмы в порядке отношения выгоды к риску
- Добавить буфер. Одна строчка, эффект в разы, риск нулевой. Проверить
strace -cдо и после. - Укрупнить операции. Читать по 64 КиБ вместо 4 КиБ, писать пачками, склеивать мелкие файлы.
- Батчить на уровне протокола. Один запрос к базе вместо N — тот же принцип (https://courses.digitable.life/post/performance/08-database-performance/).
- Убрать syscalls, которых не должно быть.
statперед каждымopen, чтение конфига в цикле, логирование без буфера в горячем пути. - Кэшировать результат, а не ускорять чтение. Самый быстрый ввод-вывод — тот, которого не было (https://courses.digitable.life/post/performance/09-caching/).
- Наполнить очередь устройства. Параллельные чтения,
readahead,iodepth— там, где мешает задержка. - Zero-copy для больших передач.
sendfile/splice, если профиль показывает копирование. - Групповой коммит для
fsync. Одинfsyncна пачку транзакций — так работает WAL во всех серьёзных СУБД. - Сменить модель ввода-вывода. epoll вместо потока на соединение, io_uring вместо epoll — только после того, как измерения показали, что упёрлись именно в переходы.
- Сменить железо. Дороже всего, но иногда честный ответ: HDD не станет NVMe от оптимизации кода.
Порядок не случаен: первые пункты дают больший эффект за меньший риск. Нарушать его — верный способ потратить месяц на io_uring в сервисе, который тормозит из-за небуферизованного логгера.
Мини-итог
- Ввод-вывод — это время, которого нет в CPU-профиле. Сумма времени в профиле сильно меньше стенных часов — берите off-CPU профиль, а не гадайте.
- Системный вызов стоит сотни наносекунд напрямую и примерно столько же косвенно, через испорченные кэши и TLB. Микробенчмарк syscall систематически занижает цену.
- Производительность определяют три числа: количество переходов в ядро, количество копий данных, глубина очереди. Устройство почти всегда быстрее, чем разговор с ним.
- Буферизация — самая дешёвая и недооценённая оптимизация. Кривая «размер буфера → время» выходит на плато на десятках килобайт, дальше растёт только расход памяти.
- Page cache — главный источник вранья в бенчмарках;
writeбезfsync— это запись в память, и цифры пропускной способности без него завышены в десятки раз. - epoll убирает $O(n)$ обход дескрипторов, io_uring — сами переходы. Второе имеет смысл только там, где syscalls реально видны в профиле.
- Асинхронность нужна не «чтобы не ждать», а чтобы держать очередь устройства наполненной: закон Литтла жёстко связывает IOPS, задержку и глубину очереди.
- Таблицы задержек — карта порядков величин, а не источник констант; цифрам Джеффа Дина больше десяти лет, и строки про диски и сеть устарели сильнее всего.
Источники
- Brendan Gregg. Systems Performance, 2nd ed., Addison-Wesley, 2020 — главы про файловые системы, диски и сеть; Off-CPU Analysis; BPF Performance Tools.
- W. Richard Stevens, Bill Fenner, Andrew Rudoff. UNIX Network Programming, Vol. 1, 3rd ed. — глава 6, каноническая классификация пяти моделей ввода-вывода.
- Jens Axboe. Efficient IO with io_uring; liburing; Shuveb Hussain. Lord of the io_uring.
- Livio Soares, Michael Stumm. FlexSC: Flexible System Call Scheduling with Exception-Less System Calls, OSDI 2010 — измерение косвенной стоимости системных вызовов.
- Dan Kegel. The C10K problem; man-страницы epoll(7), sendfile(2), posix_fadvise(2), io_uring_enter(2).
- Andrew Crotty, Viktor Leis, Andy Pavlo. Are You Sure You Want to Use MMAP in Your Database Management System?, CIDR 2022.
- Dan Luu. Files are hard; Pillai et al. All File Systems Are Not Created Equal, OSDI 2014.
- fio documentation, bcc tools, bpftrace, Linux kernel: Page Cache.
- Смежное на портале: https://courses.digitable.life/post/operating-systems/06-io-and-drivers/ про устройство подсистемы ввода-вывода, https://courses.digitable.life/post/operating-systems/07-syscalls-and-ipc/ про механику системных вызовов, https://courses.digitable.life/post/operating-systems/05-filesystems/ про файловые системы и https://courses.digitable.life/post/networking/03-tcp/ про то, что происходит с байтами дальше в сети.
Что дальше
Мы разобрали ожидание, вызванное устройствами. Но в списке причин off-CPU времени был ещё один частый
пункт — futex. Это уже не ввод-вывод: это потоки, которые ждут друг друга. Там действуют свои законы —
контеншн на блокировках, false sharing из-за раскладки данных по кэш-линиям и жёсткий потолок ускорения,
который ставит закон Амдала. Следующая статья — про то, почему добавление ядер часто не ускоряет, а замедляет.
Производительность конкурентного кода: контеншн, false sharing, закон Амдала