---
title: "Цена системного вызова и переключения контекста"
description: "Сервис пишет access-лог: на каждый запрос одна строка, f.Write(line). Под нагрузкой в top видно, что заметная доля CPU уходит в колонку sy — время, проведённое в ядре ОС. Обёртка…"
canonical: https://graphlms.ru/os/book/syscall-cost
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# Цена системного вызова и переключения контекста

> Источник: https://graphlms.ru/os/book/syscall-cost — курс «Операционные системы», GraphLMS.

## Зачем считать цену

Сервис пишет access-лог: на каждый запрос одна строка, `f.Write(line)`. Под
нагрузкой в `top` видно, что заметная доля CPU уходит в колонку `sy` — время,
проведённое в ядре ОС. Обёртка строки в `bufio.Writer` убирает эту нагрузку
почти полностью. Почему одна строчка кода так меняет картину?

Потому что `Write` для файла — это **системный вызов**, а системный вызов стоит
в сотни раз дороже обычного вызова функции. Переключение контекста стоит ещё
дороже, а часть его цены вообще не видна в момент переключения. В этой главе
соберём то, что встречалось по курсу: переход в ядро
([ограниченное прямое исполнение](/os/book/limited-direct-execution)), TLB
([TLB](/os/book/tlb)), цикл событий ([epoll](/os/book/event-based-concurrency)),
— и посмотрим на это как на цифры.

В главе два разных «ядра», и путать их нельзя: **ядро ОС** (kernel) —
привилегированный код операционной системы, **ядро процессора** (core) —
вычислительный блок CPU. Где возможна путаница, будем писать полностью.

## Системный вызов — не вызов функции

Процессор работает в двух режимах. В **пользовательском режиме** (user mode)
код не может напрямую трогать устройства, чужую память и управляющие регистры.
В **режиме ядра** (kernel mode) может всё. Попросить ядро ОС что-то сделать —
прочитать файл, отправить пакет — можно только через системный вызов: особую
инструкцию (`syscall` на x86-64, `svc` на ARM64), которая переводит процессор в
режим ядра и передаёт управление на заранее известный адрес обработчика.

```text
  обычный вызов функции:
    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 КБ.

```go
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:

```text
без буфера: 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](/os/book/fsync-durability)).

На Linux число и время системных вызовов процесса показывает `strace -c -p <pid>`
(сводная таблица по вызовам за время наблюдения). Учтите, что сам `strace`
замедляет программу в разы, поэтому на проде его включают ненадолго; для
постоянного наблюдения используют `perf trace` или eBPF-инструменты.

## Переключение контекста

Системный вызов выполняется в контексте того же процесса. **Переключение
контекста** (context switch) — когда ядро ОС снимает с ядра процессора один
поток и ставит другой: по таймеру, потому что поток заблокировался на
вводе-выводе или на локе, или потому что проснулся более важный.

```text
  прямая цена (в момент переключения):
    • вход в ядро ОС, работа планировщика
    • сохранить регистры старого потока, загрузить регистры нового
    • если новый поток из другого процесса — сменить таблицу страниц (CR3 на x86)

  косвенная цена (потом, размазана по времени):
    • TLB не содержит трансляций нового процесса → промахи, обходы таблиц страниц
    • кэши L1/L2 заполнены данными старого потока → промахи, походы в RAM
    • предсказатель переходов «обучен» на чужом коде
```

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

Про TLB: старые процессоры при смене таблицы страниц сбрасывали TLB целиком.
Современные помечают записи идентификатором адресного пространства (PCID на x86,
ASID на ARM, см. [TLB](/os/book/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 — отдельный
мир (см. [своп](/os/book/swapping)), к нему стараются не приближаться.

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

Стратегия одна: меньше входов в ядро ОС и меньше переключений на единицу
работы.

**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.

```text
   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 байт».

```text
   программа                                ядро ОС
   кладёт задания в 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](/os/book/event-based-concurrency)):
горутина, которой нечего читать, паркуется, а поток ОС берёт следующую. Один
вызов `epoll_wait` возвращает сразу пачку готовых сокетов — снова батчинг.

```text
   поток на соединение                 Go
   conn1 → поток ОС 1                  100 000 горутин (стек от 2 КБ)
   conn2 → поток ОС 2                        │ планировщик Go, в user space
   ...                                       ▼
   conn100k → поток ОС 100k            ~GOMAXPROCS потоков ОС
                                             │
   переключения через ядро ОС          epoll_wait: пачка готовых сокетов
   на каждое пробуждение               за один системный вызов
```

Подробнее о планировщике Go — в Go-курсе: [горутины](/go/book/goroutines),
[планировщик](/go/book/scheduler-deep).

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/syscall-cost

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

- Считать `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 ждёт сразу все сокеты.

## Вопросы для самопроверки

**Квиз «Цена сисколла и context switch»**

1. Почему системный вызов дороже обычного вызова функции?
   - Нужен trap в режим ядра: смена режима, сохранение регистров, смена стека, валидация аргументов и возврат (верно)
   - Он всегда читает с диска
   - Компилятор не умеет его инлайнить
   - Он занимает больше места в бинарнике
   - Пояснение: Обычный вызов функции это переход по адресу внутри user mode. Системный вызов делает trap в ядро с обязательной обвязкой: смена режима, сохранение регистров, смена стека, проверка аргументов и возврат. Поэтому он на порядки дороже.
2. Почему переключение контекста между процессами дороже, чем между потоками одного процесса?
   - Процессы хранятся на диске, а потоки в памяти
   - Потоки вообще не требуют участия ядра
   - У процессов разные адресные пространства: смена CR3 и часто сброс TLB, плюс холодные кэши (верно)
   - Это миф, стоимость одинаковая
   - Пояснение: У потоков общее адресное пространство, поэтому CR3 не меняется и TLB можно не сбрасывать. У процессов адресное пространство разное — отсюда смена CR3, сброс TLB и последующие промахи.
3. Что такое косвенная стоимость переключения контекста?
   - Время компиляции планировщика
   - Время сохранения регистров
   - Деньги за лицензию ОС
   - Промахи TLB и кэшей данных уже после switch, пока новый процесс разогревается (верно)
   - Пояснение: Прямая стоимость — сам switch (регистры, планировщик). Косвенная — последующие промахи TLB и L1/L2/L3, когда новый процесс работает с холодными кэшами. Косвенная часто больше и хуже видна.
4. Какие приёмы уменьшают число переходов в режим ядра?
   - vDSO для лёгких вызовов вроде gettimeofday (верно)
   - Батчинг сисколлов (writev, sendmmsg, буферизация) (верно)
   - io_uring с кольцами SQ/CQ (верно)
   - Поток ОС на каждое соединение
   - Пояснение: Батчинг, vDSO и io_uring все снижают число trap'ов. А вот поток-на-соединение, наоборот, плодит дорогие переключения потоков ОС.
5. Как Go обслуживает тысячи соединений, не создавая поток ОС на каждое?
   - Мультиплексирует горутины на немного потоков ОС, а netpoller (epoll/kqueue) одним вызовом следит за всеми сокетами (верно)
   - Опрашивает каждый сокет в бесконечном цикле busy-wait
   - Использует один глобальный мьютекс на все соединения
   - Создаёт по одному потоку ОС на соединение, но делает их лёгкими
   - Пояснение: Go держит мало потоков ОС (≈ числу ядер) и переключает горутины в user space без trap. Netpoller через epoll/kqueue одним сисколлом следит за тысячами сокетов — это батчинг вместо сисколла на соединение.
6. Во сколько примерно раз обычный системный вызов (trap в ядро) дороже обычного вызова функции по порядку величины?
   - Ответ: 100
   - Пояснение: Вызов функции — порядка 1 нс, обычный syscall — порядка 100–500 нс, то есть примерно в 100 раз и больше. Точное число зависит от железа и ядра, но порядок именно такой.
