Что такое системный вызов и почему их количество важнее их скорости
Профиль показывает, что процесс почти не грузит CPU полезной работой, диск не забит, сеть свободна — а запросы всё равно тормозят. Если запустить strace -c на таком процессе, часто выясняется одна и та же картина: тысячи вызовов read() по несколько байт, тысячи write() по одной строке, десятки stat() на один и тот же файл за секунду. Каждый такой вызов сам по себе стоит недорого — но их количество и есть узкое место. В этой статье разберём, что такое системный вызов, почему цена перехода в ядро не зависит от того, что конкретно вызов делает, и как буферизация и батчинг превращают тысячу мелких операций в одну без потери данных.
Содержание
Что такое системный вызов и переход в режим ядра
Программа, которую вы запускаете, работает в пользовательском режиме (user mode) — ограниченном окружении, где ей нельзя напрямую трогать железо, память других процессов или диск. Всё, что требует реального доступа к ресурсам — прочитать файл, отправить пакет по сети, выделить память, создать процесс, — программа не делает сама. Она просит об этом ядро операционной системы через системный вызов (system call, syscall).
Технически это выглядит так: процесс кладёт номер нужной операции и аргументы в регистры процессора и выполняет специальную инструкцию (на x86-64 это syscall). Процессор переключается из пользовательского режима в режим ядра (kernel mode), выполняет операцию силами ядра — у которого есть полный доступ к железу — и возвращает управление обратно в пользовательский код вместе с результатом.
Список системных вызовов конечен и документирован — на Linux это read, write, open, close, mmap, fork, socket, epoll_wait и ещё несколько сотен других (man 2 syscalls). Любая библиотечная функция, которая реально трогает файлы, сеть или память, в итоге сводится к одному или нескольким из этих вызовов: fopen()/fread() из стандартной библиотеки C — это обёртки поверх open()/read() с добавленной буферизацией, о которой пойдёт речь дальше.
Увидеть syscall'ы конкретной программы можно напрямую:
# Посчитать, сколько раз и каких вызовов сделала программа
strace -c ./myapp
# Только вызовы чтения и записи для уже запущенного процесса
strace -tt -e trace=read,write -p $(pgrep -f myapp)
Вывод strace -c — первое, на что стоит смотреть при подозрении на проблему с производительностью: он показывает не только какие вызовы происходят, но и сколько раз каждый сделан. Если в колонке calls для read или write стоит число с пятью-шестью нулями за секунду работы — повод разбираться дальше, даже если время на каждый отдельный вызов кажется небольшим.
Почему переход в ядро стоит дорого сам по себе
Ключевая мысль, которую легко упустить: стоимость системного вызова — это не стоимость операции, которую он выполняет, а стоимость самого перехода между режимами. Она возникает независимо от того, читаете вы один байт или мегабайт, потому что при каждом вызове происходит одно и то же:
- процессор сохраняет состояние регистров пользовательского процесса;
- срабатывают защитные проверки уровня процессора и ядра;
- ядро проверяет права, валидирует аргументы, может обратиться к своим внутренним структурам данных (дескрипторам файлов, таблицам страниц памяти, буферам сокетов);
- после выполнения операции происходит обратное переключение в пользовательский режим.
Этот набор шагов — фиксированные накладные расходы (overhead), которые платятся при каждом вызове одинаково, что бы вы ни просили сделать. Прочитать один байт через read() и прочитать 64 килобайта через тот же read() — переход в ядро стоит примерно одинаково в обоих случаях, а разница в полезной работе на фоне этого перехода почти не заметна. Поэтому сто вызовов read() по 640 байт обходятся системе заметно дороже, чем один вызов на 64 КБ, при одинаковом суммарном объёме данных. Это справедливо и для более "тяжёлых" по смыслу вызовов вроде open(), stat(), mmap(), fork() — они делают разную работу внутри ядра, но платёж за сам факт перехода присутствует всегда как часть общей стоимости. Отсюда практическое правило: при разборе профиля смотреть в первую очередь не на "сложность" отдельного вызова, а на их количество в горячем пути программы.
Полезно отличать системный вызов от переключения контекста между процессами (context switch) — это смежные, но разные вещи: syscall не обязательно приводит к полной смене процесса на CPU, а переключение контекста может происходить и без единого syscall, просто по истечении кванта времени планировщика. Подробнее — в статье что такое context switch и сколько он стоит.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПример: один большой read() против тысячи маленьких
Самый наглядный случай — чтение файла или сокета. Возьмём наивную построчную реализацию без буферизации:
// Плохо: один read() на каждый прочитанный байт
char c;
while (read(fd, &c, 1) == 1) {
process(c);
}
// Лучше: один read() на буфер, разбор — в пользовательском пространстве
char buf[65536];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf))) > 0) {
for (ssize_t i = 0; i < n; i++) process(buf[i]);
}
Если файл размером в один мегабайт, первый вариант сделает около миллиона системных вызовов — по одному на байт. Даже если сам переход в ядро стоит немного, умноженный на миллион он превращается в заметную долю времени работы программы, целиком уходящую не на полезную обработку данных, а на накладные расходы переключения режимов.
Та же логика воспроизводится на любом уровне стека. Стандартные open()/readlines() в Python, fopen() в C, BufferedReader в Java по умолчанию используют внутренний буфер именно для того, чтобы спрятать эту проблему от прикладного кода — программист пишет for line in file, а библиотека под капотом делает read() крупными блоками и режет их на строки уже в памяти процесса.
То же самое на сетевом уровне: приложение, которое читает TCP-сокет по одному байту в цикле recv(fd, buf, 1, 0), генерирует ровно столько syscall'ов, сколько байт получено. Чтение крупными блоками (recv(fd, buf, 65536, 0)) убирает эту избыточность, оставляя число вызовов пропорциональным не количеству байт, а количеству реально пришедших пакетов. Похожую роль играет векторный ввод-вывод readv()/writev(), который одним вызовом читает или пишет данные сразу в несколько несмежных буферов памяти, если они логически разбиты на части (заголовок + тело), но их нужно передать вместе.
Тот же эффект на записи: write() россыпью
С записью проблема зеркальная и встречается едва ли не чаще — логирование в коде почти всегда выглядит как последовательность отдельных вызовов print, console.log, logger.info на каждое событие. Если каждый такой вызов транслируется в отдельный write(), а приложение генерирует тысячи записей в секунду, накладные расходы на переходы в ядро растут линейно с числом записей, независимо от того, что каждая запись сама по себе крошечная.
Наглядный тест — сравнить запись файла маленькими и крупными блоками через dd:
# Запись мелкими блоками — больше syscall'ов на тот же объём данных
dd if=/dev/zero of=/tmp/test1.img bs=512 count=200000
# Запись крупными блоками — тот же объём, но на порядки меньше вызовов write()
dd if=/dev/zero of=/tmp/test2.img bs=1M count=100
Параметр bs в dd — прямое управление размером блока для каждого write(). Первая команда делает 200 000 отдельных вызовов записи по 512 байт (в сумме ровно столько же данных, сколько во второй команде), вторая — 100 вызовов по мегабайту. Конкретную разницу во времени лучше замерить на своём железе через time dd ... — она зависит от диска, файловой системы и текущей нагрузки, и приводить здесь придуманные цифры было бы нечестно. Но сам факт, что число syscall'ов различается в тысячи раз при одинаковом объёме данных, от железа не зависит.
Та же логика — при работе с базами данных. Вставка миллиона строк по одной командой INSERT с автокоммитом на каждую строку означает миллион отдельных транзакций, а на нижнем уровне — множество вызовов write()/fsync() на журнал предзаписи (WAL), плюс сетевые round-trip'ы между приложением и СУБД. Одна транзакция на пачку строк или пакетная вставка (INSERT ... VALUES (...), (...), (...), либо COPY в PostgreSQL) сокращает и число сетевых обращений, и число операций записи на диск — тот же батчинг, что и с read()/write(), только уровнем выше.
Если тема упирается конкретно в диск и в то, почему операции записи так чувствительны к размеру блока и к моменту фиксации на носителе, полезно отдельно разобраться, что реально происходит между вызовом записи и попаданием данных на физический диск — это раскрыто в статье что делает sync и почему файл ещё не на диске.
Буферизация и батчинг как способ сократить число вызовов
Общее решение для всех описанных случаев — не убрать системные вызовы совсем (это невозможно, рано или поздно данные должны попасть в файл, сокет или на диск), а сократить их число, накапливая данные в памяти процесса и сбрасывая их одним крупным вызовом. Это и есть буферизация.
Стандартная библиотека C (stdio.h) делает это автоматически для fprintf, fwrite, fputs: они пишут не сразу в файл, а во внутренний буфер, и настоящий write() происходит только когда буфер заполнен, явно вызван fflush(), либо файл закрывается. Режим буфера задаётся через setvbuf(): _IOFBF — полная буферизация (сброс раз в N байт), _IOLBF — построчная (сброс по символу новой строки), _IONBF — без буферизации, write() на каждый вызов.
Здесь кроется частая ловушка: поведение stdout по умолчанию зависит от того, куда он направлен. В интерактивном терминале включается построчная буферизация — удобно для логов в реальном времени. Но при перенаправлении в файл или pipe (myapp > out.log) библиотека переключается на полную буферизацию крупными блоками — строки появляются в файле не сразу, а пачками, что иногда сбивает с толку при отладке. Решение — явно сбрасывать буфер (fflush(stdout)) после важных сообщений либо сознательно отключать буферизацию для критичного вывода.
Батчинг — та же идея на уровне логики приложения: вместо того чтобы отправлять сетевой запрос, писать в БД или обращаться к диску на каждое событие, приложение копит события в очереди в памяти и сбрасывает их пачкой — по достижении размера N или по истечении таймаута T, что наступит раньше. Такой подход одновременно снижает число syscall'ов и держит задержку под контролем: данные не залёживаются в памяти, дожидаясь полной пачки. Именно так устроены клиентские библиотеки для отправки метрик, логов и событий в большинстве современных систем.
Отдельно стоит знать про sendfile() — вызов, который копирует данные из файла напрямую в сокет силами ядра, без цикла "прочитать в буфер пользователя — записать из буфера в сокет" (zero-copy). В похожем направлении развивается io_uring — интерфейс Linux, который передаёт и получает результаты сразу многих операций ввода-вывода через общую с ядром кольцевую очередь, вместо отдельного syscall на операцию. Он заметно снижает число переходов в ядро при интенсивном вводе-выводе, но требует другой модели программирования — для типичного веб-приложения обычная буферизация и батчинг дают основной выигрыш при меньшей сложности.
Практические следствия для серверных приложений
Из сказанного следует несколько правил, которые стоит проверять в первую очередь при разборе тормозящего сервера, ещё до того как менять железо или тарифный план:
- Логирование. Буферизованные логгеры вместо построчной записи без буферизации, асинхронная запись через очередь,
fsync()только там, где это реально нужно для надёжности. - Работа с файлами и сетью. Проверяйте размер буфера в коде чтения/записи: слишком маленький даёт много syscall'ов, слишком большой впустую держит память и задержку. Разумная отправная точка — от 8 КБ до 64 КБ, дальше — замер на конкретной нагрузке.
- Базы данных. Собирайте вставки и обновления в транзакции и пакетные запросы. Проверяйте, не делает ли ORM скрытый N+1 — один запрос вместо N экономит и сетевые round-trip'ы, и syscall'ы на обеих сторонах.
- HTTP и прокси. Держите соединения (keep-alive) вместо пересоздания TCP-сокета на каждый запрос.
keepalive_requests/keepalive_timeoutв Nginx управляют тем, сколько запросов проходит через один уже открытый сокет. - Профилирование прежде правок. Смотрите
strace -cна реальной нагрузке. Если топ занимают неread/write, а, например,futex(ожидание блокировок) — проблема в другом месте, и батчинг ввода-вывода её не решит. - Мелкие файлы на диске. Множество маленьких файлов вместо одного крупного индекса — это
open(),stat(),read()/write(),close()на каждый, там где могла бы быть одна операция над уже открытым файлом. Разбор — в статье почему миллион мелких файлов убивает диск. - Диск и СУБД. Если число syscall'ов уже минимально, а узкое место упирается в скорость записи на носитель — проверьте характеристики самого диска под конкретную СУБД: почему важна скорость диска для баз данных.
На арендованном сервере эти правила работают так же, как на своём железе — разница в том, что при аренде проще заранее выбрать конфигурацию с запасом по CPU и диску под профиль нагрузки, если заранее известно, что приложение делает много мелких операций ввода-вывода.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Означает ли это, что нужно всегда читать и писать максимально крупными блоками?
Нет. У буферизации есть цена — память под буфер и задержка до момента, когда данные реально попадут получателю, а слишком крупный буфер увеличивает риск потерять накопленные данные при аварийном завершении процесса до их сброса. Размер буфера — компромисс между числом syscall'ов и задержкой/надёжностью, который стоит подбирать под задачу, а не устанавливать один раз "на максимум".
Как понять, что именно число системных вызовов, а не что-то другое, тормозит приложение?
Запустите strace -c на процессе под реальной нагрузкой и посмотрите на колонку с числом вызовов и суммарным временем по каждому типу syscall. Если read/write идут с шести-семизначными числами вызовов за короткий промежуток и суммарно занимают заметную долю времени — сигнал разбираться с буферизацией. Если наверху списка что-то другое, например futex или epoll_wait, — причина в другом месте.
Правда ли, что асинхронный ввод-вывод (async/await, event loop) сам по себе снижает число системных вызовов?
Нет напрямую. Асинхронная модель меняет то, как поток ждёт результата операции — не блокируясь, а занимаясь другими задачами, — но количество вызовов read()/write() на единицу переданных данных при этом не уменьшается автоматически. Снижение числа syscall'ов даёт именно буферизация и батчинг, и их стоит применять вместе с асинхронной моделью, а не вместо неё.
Есть ли системные вызовы, которые стоят заметно больше остальных?
Да — вызовы, требующие больше работы внутри ядра (например fork() или mmap() с изменением таблиц страниц памяти), закономерно обходятся дороже совсем лёгких вроде getpid(). Но у любого, даже самого лёгкого syscall есть неснижаемая база расходов на переход между режимами, и при достаточно большом количестве вызовов именно она, помноженная на количество, начинает доминировать над разницей в "тяжести" конкретной операции.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →