ОС в проде · 12 мин
Контент v0.12.0Цена системного вызова и переключения контекста
Зачем считать цену
Сервис пишет access-лог: на каждый запрос одна строка, f.Write(line). Под
нагрузкой в top видно, что заметная доля CPU уходит в колонку sy — время,
проведённое в ядре ОС. Обёртка строки в bufio.Writer убирает эту нагрузку
почти полностью. Почему одна строчка кода так меняет картину?
Потому что Write для файла — это системный вызов, а системный вызов стоит
в сотни раз дороже обычного вызова функции. Переключение контекста стоит ещё
дороже, а часть его цены вообще не видна в момент переключения. В этой главе
соберём то, что встречалось по курсу: переход в ядро
(ограниченное прямое исполнение), TLB
(TLB), цикл событий (epoll),
— и посмотрим на это как на цифры.
В главе два разных «ядра», и путать их нельзя: ядро ОС (kernel) — привилегированный код операционной системы, ядро процессора (core) — вычислительный блок CPU. Где возможна путаница, будем писать полностью.
Системный вызов — не вызов функции
Процессор работает в двух режимах. В пользовательском режиме (user mode)
код не может напрямую трогать устройства, чужую память и управляющие регистры.
В режиме ядра (kernel mode) может всё. Попросить ядро ОС что-то сделать —
прочитать файл, отправить пакет — можно только через системный вызов: особую
инструкцию (syscall на x86-64, svc на ARM64), которая переводит процессор в
режим ядра и передаёт управление на заранее известный адрес обработчика.
обычный вызов функции:
call myFunc → выполнить → ret всё в user mode, ~1 нс
системный вызов read(fd, buf, n):
user mode kernel mode
1. номер вызова и аргументы
в регистры
2. инструкция syscall ───────────► 3. переключиться на стек ядра,
сохранить регистры программы
4. проверить аргументы: fd открыт?
buf лежит в памяти процесса?
5. выполнить работу (тут может быть
и копирование данных)
6. восстановить регистры
8. продолжить с результатом ◄───── 7. вернуться в user mode (sysret)
сам вход и выход — порядка 100 нс, плюс работа шага 5Сама полезная работа может быть крошечной (прочитать 8 байт), но обвязка вокруг неё фиксированная. После уязвимостей Meltdown и Spectre (2018) она стала дороже: на многих процессорах при каждом входе в ядро ОС теперь переключаются таблицы страниц (KPTI) и сбрасываются некоторые буферы предсказателя переходов. Поэтому на одном и том же железе цена системного вызова зависит от версии ядра и включённых защит.
Вывод: тысяча вызовов write по 24 байта почти всегда хуже одного вызова на
24 КБ. Платим за переходы, а не за байты.
Проверим
Программа пишет в файл 100 000 строк: сначала каждой строкой отдельный
Write, потом через буфер bufio.Writer на 64 КБ.
package main
import (
"bufio"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
const lines = 100_000
func main() {
dir, err := os.MkdirTemp("", "sc")
if err != nil {
log.Fatal(err)
}
defer os.RemoveAll(dir)
line := []byte("GET /api/orders 200 3ms\n")
// 1. Каждая строка — отдельный write в ядро.
f1, err := os.Create(filepath.Join(dir, "direct.log"))
if err != nil {
log.Fatal(err)
}
start := time.Now()
for i := 0; i < lines; i++ {
f1.Write(line) // ошибки в примере опущены для краткости
}
direct := time.Since(start)
f1.Close()
// 2. Строки копятся в буфере 64 КБ, write — когда буфер заполнен.
f2, err := os.Create(filepath.Join(dir, "buffered.log"))
if err != nil {
log.Fatal(err)
}
start = time.Now()
w := bufio.NewWriterSize(f2, 64*1024)
for i := 0; i < lines; i++ {
w.Write(line)
}
w.Flush()
buffered := time.Since(start)
f2.Close()
fmt.Printf("без буфера: %v (%v на строку)\n", direct, direct/lines)
fmt.Printf("bufio: %v (%v на строку)\n", buffered, buffered/lines)
fmt.Printf("системных вызовов write: %d против %d\n", lines, lines*len(line)/(64*1024)+1)
}На MacBook с M2 Pro:
без буфера: 139.276833ms (1.392µs на строку)
bufio: 703.042µs (7ns на строку)
системных вызовов write: 100000 против 37Данные те же, байты те же, а разница в 200 раз: 100 000 переходов в ядро ОС
против 37. Частая ошибка при использовании bufio.Writer — забыть Flush в
конце: последний неполный буфер так и не попадёт в файл. И помните, что буфер
живёт в памяти процесса: при панике или kill -9 его содержимое пропадёт, в
отличие от данных, уже отданных ядру через write (см.
fsync и durability).
На Linux число и время системных вызовов процесса показывает strace -c -p <pid>
(сводная таблица по вызовам за время наблюдения). Учтите, что сам strace
замедляет программу в разы, поэтому на проде его включают ненадолго; для
постоянного наблюдения используют perf trace или eBPF-инструменты.
Переключение контекста
Системный вызов выполняется в контексте того же процесса. Переключение контекста (context switch) — когда ядро ОС снимает с ядра процессора один поток и ставит другой: по таймеру, потому что поток заблокировался на вводе-выводе или на локе, или потому что проснулся более важный.
прямая цена (в момент переключения):
• вход в ядро ОС, работа планировщика
• сохранить регистры старого потока, загрузить регистры нового
• если новый поток из другого процесса — сменить таблицу страниц (CR3 на x86)
косвенная цена (потом, размазана по времени):
• TLB не содержит трансляций нового процесса → промахи, обходы таблиц страниц
• кэши L1/L2 заполнены данными старого потока → промахи, походы в RAM
• предсказатель переходов «обучен» на чужом кодеКосвенная цена часто больше прямой, и её трудно увидеть в профайлере: она размазана по тысячам последующих обращений к памяти, которые просто выполняются медленнее обычного.
Про TLB: старые процессоры при смене таблицы страниц сбрасывали TLB целиком. Современные помечают записи идентификатором адресного пространства (PCID на x86, ASID на ARM, см. TLB) и могут не сбрасывать всё. Это смягчает проблему, но кэши данных всё равно остаются «холодными» для нового процесса.
Переключение между потоками одного процесса дешевле: адресное пространство общее, таблицу страниц менять не нужно. Но это всё равно вход в ядро ОС, планировщик и испорченные кэши. А переключение между горутинами на одном потоке ОС не трогает ядро ОС вовсе: рантайм Go сохраняет несколько регистров и переключает указатель стека, это десятки–сотни наносекунд.
Порядки величин
Точные числа зависят от процессора, ядра ОС и нагрузки, но порядки полезно держать в голове.
| Операция | Порядок времени |
|---|---|
| вызов обычной функции | ~1 нс |
вызов через vDSO (clock_gettime) |
~20 нс |
простой системный вызов (getpid) |
~100 нс, до ~0,5 мкс с защитами от Spectre |
| переключение горутин | десятки–сотни нс |
| переключение контекста потоков ОС | ~1–5 мкс прямой цены, плюс холодные кэши |
| minor page fault (страница в памяти, нужно только обновить таблицу) | ~0,5–1 мкс |
| major page fault с чтением с SSD | ~100 мкс |
| major page fault с чтением с HDD | ~10 мс |
Каждая строка дороже предыдущей на порядок-два. Major page fault — отдельный мир (см. своп), к нему стараются не приближаться.
Как уменьшают число переходов в ядро
Стратегия одна: меньше входов в ядро ОС и меньше переключений на единицу работы.
1. Батчинг. Одна крупная операция вместо многих мелких. bufio.Writer и
bufio.Reader; writev/readv — один вызов на несколько буферов (Go
использует writev в net.Buffers); sendmmsg — пачка UDP-пакетов за раз;
io.Copy между файлом и сокетом на Linux использует sendfile/splice, и
данные вообще не копируются в память процесса.
2. vDSO (virtual dynamic shared object). Некоторые вызовы не требуют
привилегий: например, узнать текущее время. Ядро ОС отображает в адресное
пространство каждого процесса маленькую библиотеку и страницу с данными
(текущее время), и clock_gettime читает её как обычную память, без перехода в
режим ядра. Поэтому time.Now() в Go на Linux стоит десятки наносекунд, а не
сотни: рантайм вызывает vDSO.
clock_gettime без vDSO: user → syscall → kernel → sysret → user
через vDSO: user → чтение из страницы, которую ядро ОС
заранее отобразило в наш процесс → результат3. io_uring — асинхронный интерфейс ввода-вывода Linux (с ядра 5.1, 2019). Программа и ядро ОС делят два кольцевых буфера в памяти:
- SQ (submission queue) — очередь заданий: «прочитай файл с такого-то смещения», «прими соединение», «отправь данные»;
- CQ (completion queue) — очередь результатов: «задание 5 выполнено, прочитано 200 байт».
программа ядро ОС
кладёт задания в SQ ─────────────────► забирает, выполняет асинхронно
забирает результаты из CQ ◄─────────── кладёт результаты в CQ
один вызов io_uring_enter() отправляет пачку заданий и может дождаться
пачки результатов; в режиме SQPOLL поток ядра ОС сам следит за SQ,
и в устоявшемся режиме системных вызовов почти нетГлавные отличия от epoll. epoll сообщает «сокет готов, можно читать», а читать всё равно нужно отдельным системным вызовом; io_uring выполняет саму операцию. И io_uring работает с обычными файлами, для которых epoll бесполезен.
Практические оговорки:
- io_uring — большая и сложная поверхность ядра ОС, через неё нашли много
уязвимостей. Google в 2023 году сообщил, что отключил io_uring в ChromeOS и на своих
продакшен-серверах, а приложениям Android закрыл к нему доступ фильтром
seccomp; профиль seccomp Docker по умолчанию блокирует его системные вызовы
(с Docker Engine 25.0); в новых ядрах есть sysctl
kernel.io_uring_disabled. В контейнерной инфраструктуре он может быть просто недоступен. - Рантайм Go io_uring не использует: сеть работает через netpoller на epoll, файлы — через обычные блокирующие вызовы. Есть сторонние библиотеки, но это отдельная модель программирования.
Почему Go выбрал горутины и netpoller
Модель «поток ОС на соединение» при 100 000 соединений означает 100 000 потоков. Каждое пробуждение по приходу данных — переключение контекста с прямой и косвенной ценой из раздела выше. Сервер тратит заметную долю CPU не на работу, а на переключения.
Go держит потоков ОС примерно по числу ядер процессора (GOMAXPROCS) и
переключает горутины внутри них в пространстве пользователя. Ожидание сети
устроено через netpoller (глава про epoll):
горутина, которой нечего читать, паркуется, а поток ОС берёт следующую. Один
вызов epoll_wait возвращает сразу пачку готовых сокетов — снова батчинг.
поток на соединение Go
conn1 → поток ОС 1 100 000 горутин (стек от 2 КБ)
conn2 → поток ОС 2 │ планировщик Go, в user space
... ▼
conn100k → поток ОС 100k ~GOMAXPROCS потоков ОС
│
переключения через ядро ОС epoll_wait: пачка готовых сокетов
на каждое пробуждение за один системный вызовПодробнее о планировщике Go — в Go-курсе: горутины, планировщик.
Почему системный вызов дороже обычного вызова функции?
Почему переключение контекста между процессами дороже, чем между потоками одного процесса?
Что такое косвенная стоимость переключения контекста?
Какие приёмы уменьшают число переходов в режим ядра?
Как Go обслуживает тысячи соединений, не создавая поток ОС на каждое?
Во сколько примерно раз обычный системный вызов (trap в ядро) дороже обычного вызова функции по порядку величины?
Частые ошибки
- Считать
f.Writeв Go «просто функцией». Для*os.Fileи сетевых соединений каждый вызов — это системный вызов; мелкие записи без буфера нагружаютsy. - Забывать
Flushуbufio.Writerили вызывать его послеCloseфайла. - Ориентироваться только на прямую цену переключения контекста. Холодные кэши и TLB после переключения часто стоят больше.
- Думать, что io_uring «включится» в Go-сервисе сам. Стандартная библиотека его не использует, а в контейнере он может быть запрещён.
- Путать ядро ОС и ядро процессора: «уйти в ядро» — это про режим процессора и код ОС, а не про то, на каком ядре CPU выполняется поток.
Проверьте себя
- Сервис делает 20 вызовов
writeв сокет на каждый ответ, 5 000 ответов в секунду. Сколько системных вызовов в секунду уйдёт на запись и сколько останется, если собирать ответ в буфер? - Почему переключение между потоками одного процесса дешевле, чем между процессами, но всё равно дороже переключения горутин?
- Почему
time.Now()в Go на Linux не делает системного вызова? - Чем io_uring принципиально отличается от epoll с точки зрения того, кто выполняет чтение?
Показать ответы
Ответы. (1) 100 000 в секунду; с буфером — 5 000, по одному на ответ. (2) Таблицу страниц менять не нужно, TLB остаётся полезным, но переход через ядро ОС, работа планировщика и холодные кэши остаются; горутины переключаются рантаймом без входа в ядро ОС. (3) Время читается через vDSO из страницы, которую ядро ОС отображает в процесс. (4) epoll только сообщает о готовности, читать нужно отдельным вызовом; io_uring выполняет саму операцию и кладёт результат в очередь завершений.
Что спрашивают на собесе
- Чем системный вызов отличается от вызова функции? Переход в режим ядра, смена стека, сохранение регистров, проверка аргументов, возврат. Фиксированная цена порядка 100 нс и выше, поэтому важен батчинг.
- Почему переключение между процессами дороже, чем между потоками? Нужно сменить таблицу страниц, TLB теряет полезность (без PCID/ASID сбрасывается), плюс холодные кэши.
- Прямая и косвенная цена переключения? Прямая — регистры и планировщик. Косвенная — промахи TLB и кэшей после переключения; часто больше прямой.
- Как уменьшают число входов в ядро? Буферизация и батчинг (
bufio,writev,sendmmsg), vDSO,sendfile, io_uring. - Что такое vDSO? Код и данные ядра ОС, отображённые в процесс, чтобы некоторые вызовы (время) выполнялись без перехода в режим ядра.
- Почему Go не создаёт поток ОС на соединение? Чтобы не платить за переключения потоков ОС: горутины мультиплексируются на несколько потоков, netpoller ждёт сразу все сокеты.