ОС в проде · 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-курсе: горутины, планировщик.

Проверь себя· Цена сисколла и context switch

Почему системный вызов дороже обычного вызова функции?

Почему переключение контекста между процессами дороже, чем между потоками одного процесса?

Что такое косвенная стоимость переключения контекста?

Какие приёмы уменьшают число переходов в режим ядра?

Как Go обслуживает тысячи соединений, не создавая поток ОС на каждое?

Во сколько примерно раз обычный системный вызов (trap в ядро) дороже обычного вызова функции по порядку величины?

Частые ошибки

  • Считать f.Write в Go «просто функцией». Для *os.File и сетевых соединений каждый вызов — это системный вызов; мелкие записи без буфера нагружают sy.
  • Забывать Flush у bufio.Writer или вызывать его после Close файла.
  • Ориентироваться только на прямую цену переключения контекста. Холодные кэши и TLB после переключения часто стоят больше.
  • Думать, что io_uring «включится» в Go-сервисе сам. Стандартная библиотека его не использует, а в контейнере он может быть запрещён.
  • Путать ядро ОС и ядро процессора: «уйти в ядро» — это про режим процессора и код ОС, а не про то, на каком ядре CPU выполняется поток.

Проверьте себя

  1. Сервис делает 20 вызовов write в сокет на каждый ответ, 5 000 ответов в секунду. Сколько системных вызовов в секунду уйдёт на запись и сколько останется, если собирать ответ в буфер?
  2. Почему переключение между потоками одного процесса дешевле, чем между процессами, но всё равно дороже переключения горутин?
  3. Почему time.Now() в Go на Linux не делает системного вызова?
  4. Чем 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 ждёт сразу все сокеты.

Продолжить