GraphLMS

ОС
Начать

ОС · ОС в проде · 14 мин

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

Зачем вообще считать «цену»

Когда джуниор пишет код, он думает в терминах вызовов функций: «вызвал read() — получил данные». Кажется, что read() — обычная функция, как и любая другая. На самом деле под капотом происходит небольшое путешествие в другой мир — в ядро операционной системы — и обратно. И это путешествие стоит денег: процессорного времени, которое могло бы тратиться на полезную работу.

Давайте сразу зафиксируем масштаб. Если обычный вызов функции стоит «копейки», то системный вызов стоит «рубль», а переключение контекста между процессами — «десять рублей», да ещё и с отложенными процентами, о которых мы поговорим. Понимание этих порядков — то, что отличает инженера, который объяснит, почему сервис тормозит под нагрузкой, от того, кто просто «добавит ещё подов».

В этой главе мы сведём воедино то, что встречали раньше: режим ядра и trap (limited-direct-execution), кэш трансляций адресов TLB (tlb) и модель «один поток крутит много соединений» (event-based-concurrency).

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

Сначала разберёмся, почему read() дороже, чем myFunc().

Режим пользователя и режим ядра. Процессор умеет работать в двух режимах. В пользовательском режиме (user mode) ваш код ограничен: он не может напрямую трогать железо, читать чужую память, командовать диском. Это как гость в отеле — можно ходить по своему номеру, но нельзя на кухню или в серверную. В режиме ядра (kernel mode) ограничений нет — это сотрудник отеля с мастер-ключом. Чтобы попросить что-то «с кухни» (прочитать файл, отправить пакет в сеть), гость зовёт сотрудника. Этот «зов» и есть системный вызов.

Технически переход в ядро называется trap («ловушка», аппаратное прерывание по команде). При обычном вызове функции процессор просто прыгает на другой адрес кода. При системном вызове происходит куда больше:

  Обычный вызов функции myFunc():
    CPU ── jump на адрес функции ──► выполнил ──► return
    (всё в user mode, регистры свои, стек свой; единицы наносекунд)
 
 
  Системный вызов read():
    user mode                          kernel mode
    ─────────                          ───────────
    1) положить номер сисколла
       и аргументы в регистры
    2) инструкция trap (syscall) ─────► 3) переключиться в kernel mode
                                        4) сохранить регистры пользователя
                                        5) переключиться на стек ядра
                                        6) проверить аргументы (валидация,
                                           права, границы памяти)
                                        7) выполнить саму работу
                                        8) восстановить регистры
    10) продолжить с результатом ◄───── 9) trap-return в user mode
 
    (сотни наносекунд: в ~100+ раз дороже обычного вызова)

Что нарисовано: слева — дешёвый прыжок обычной функции, справа — длинный путь системного вызова с переключением режима, сменой стека и обязательными проверками. Сама «полезная работа» (шаг 7) может быть крошечной — например, прочитать 8 байт, — но обвязка вокруг неё фиксированная и дорогая.

Вывод для джуна: сто маленьких read() по одному байту почти всегда хуже, чем один read() на сто байт. Платим за обвязку, а не за байты.

Переключение контекста — ещё дороже

Системный вызов остаётся внутри одного процесса. А вот переключение контекста (context switch) — это когда ОС снимает с процессора один процесс и ставит другой. Здесь к стоимости trap добавляется самое болезненное: смена адресного пространства.

Напомним из главы про tlb: чтобы превратить виртуальный адрес в физический, процессор использует таблицу страниц, а чтобы не лазить в неё каждый раз — кэширует трансляции в TLB. TLB маленький и быстрый. Но трансляции в нём принадлежат конкретному процессу: у процесса A адрес 0x1000 ведёт в один фрейм, у процесса B тот же 0x1000 — в другой.

  Context switch: процесс A ──► процесс B
 
  ┌──────────── ПРЯМАЯ стоимость (сразу, в момент switch) ───────────┐
  │  • trap в ядро (планировщик)                                     │
  │  • сохранить регистры A в его PCB                                 │
  │  • загрузить регистры B из его PCB                                │
  │  • сменить указатель таблицы страниц (CR3) → адресное простр. B   │
  │  • часто: сброс (flush) TLB, т.к. там трансляции A                │
  └──────────────────────────────────────────────────────────────────┘


  ┌──────────── КОСВЕННАЯ стоимость (потом, размазана во времени) ───┐
  │  процесс B начинает работать, но:                                 │
  │   • TLB пустой/чужой → промахи → походы в таблицу страниц         │
  │   • L1/L2/L3 кэши набиты данными A → промахи → походы в RAM       │
  │   B «разогревается» тысячи тактов, прежде чем выйдет на скорость   │
  └──────────────────────────────────────────────────────────────────┘

Что нарисовано: сверху — прямые расходы, которые видно «в моменте»; снизу — коварные косвенные расходы, которые проявляются уже после переключения, когда новый процесс натыкается на холодные кэши.

Главная мысль, которую любят на собесах: косвенные издержки часто больше прямых. Сам switch — это сотни-тысячи тактов, но «разогрев» кэшей и TLB после него может стоить ещё дороже, и эту цену сложно увидеть в профайлере, потому что она размазана по последующим обращениям к памяти.

Маленькая деталь про метки ASID/PCID (мы их упоминали в tlb): современные процессоры умеют помечать строки TLB номером процесса, чтобы не делать полный flush при каждом переключении. Это смягчает боль, но не убирает её: кэши данных всё равно остаются холодными для нового процесса.

Переключение между потоками одного процесса дешевле, чем между процессами: адресное пространство общее, CR3 менять не надо, TLB можно не сбрасывать. Поэтому потоки «легче» процессов. Но и это не бесплатно — мы всё равно идём через ядро и портим кэши.

Сколько это стоит примерно

Точные числа зависят от железа и ядра, но порядки величин стабильны и их полезно держать в голове.

Операция Порядок времени Во сколько раз дороже вызова функции
Вызов обычной функции ~1 нс
Лёгкий syscall через vDSO (напр. gettimeofday) ~5–20 нс единицы раз
Обычный системный вызов (trap в ядро) ~100–500 нс ~100–500×
Переключение контекста (потоки/процессы) ~1–10 мкс с учётом холодных кэшей тысячи раз
Page fault (страницы нет, нужен диск/swap) ~мс (HDD) / ~10–100 мкс (SSD) десятки тысяч раз и выше

Что показано: каждая строчка — на порядок-два дороже предыдущей. Page fault с обращением к диску — отдельная вселенная (см. swapping), поэтому к нему стараются не приближаться.

Как с этим борются

Раз обвязка дорогая, главная стратегия одна: делать меньше переходов в ядро и меньше переключений. Несколько приёмов.

1. Батчинг (пакетирование) сисколлов. Вместо тысячи мелких операций — одна крупная. Классика: writev/readv (один сисколл на несколько буферов), sendmmsg (отправить пачку UDP-пакетов одним вызовом), буферизованный вывод (копим в буфер в user space, сбрасываем редко). Это ровно та же идея, что «один read на 100 байт вместо ста по байту», только на уровне API.

2. vDSO (virtual dynamic shared object). Некоторые «сисколлы» на самом деле не требуют секретов ядра — например, узнать текущее время. Ядро отображает кусочек своих данных и кода прямо в адресное пространство процесса, и gettimeofday()/clock_gettime() читают время без trap, как обычную функцию. Поэтому в таблице выше vDSO-вызов стоит копейки.

  Обычный gettimeofday:        gettimeofday через vDSO:
 
  user ──trap──► kernel        user ── чтение из страницы,
       ◄──return──            отображённой ядром в наш
   (дорого, ~сотни нс)         адрес ──► результат
                               (без trap, ~единицы нс)

Что нарисовано: слева — лишний поход в ядро ради времени; справа — ядро заранее положило данные «нам в карман», и поход не нужен.

3. io_uring — асинхронная очередь команд ядру. Это самый мощный приём в современном Linux. Идея: вместо «один сисколл на одну операцию» мы создаём два кольцевых буфера (ring buffers), общих между приложением и ядром:

  • SQ (submission queue) — очередь заданий: «прочитай файл», «прими соединение», «запиши сюда».
  • CQ (completion queue) — очередь результатов: «задание №5 готово, прочитано 200 байт».
  io_uring: батчим работу через общую память
 
  Приложение (user)                      Ядро (kernel)
  ─────────────────                      ─────────────
   кладёт задания                         забирает задания
        │                                       ▲
        ▼                                       │
  ┌─────────────┐  SQ (submission ring) ┌─────────────┐
  │ задание 1   │ ───────────────────►  │ выполняет    │
  │ задание 2   │                       │ их асинхронно│
  │ задание 3   │                       └──────┬──────┘
  └─────────────┘                              │
        ▲                                      ▼
  ┌─────────────┐  CQ (completion ring) ┌─────────────┐
  │ готово 1    │ ◄───────────────────  │ кладёт       │
  │ готово 3    │                       │ результаты   │
  └─────────────┘                       └─────────────┘
 
  Один io_uring_enter() может отправить ПАЧКУ заданий и забрать
  пачку результатов. В пределе (polling-режим) сисколлов ~ноль:
  ядро само опрашивает кольцо.

Что нарисовано: приложение и ядро общаются через две общие очереди в памяти. Сотни операций ввода-вывода проходят почти без trap’ов — обвязка платится один раз на пачку, а не на каждую операцию. Это прямое лекарство от «дорогого сисколла».

Почему Go выбрал горутины и netpoller

Теперь свяжем всё с Go — это любимый сюжет на собесах по бэкенду.

Наивный способ написать сетевой сервер — поток ОС на соединение (thread per connection). Просто, но при 100k соединений у нас 100k потоков ОС. Планировщик ОС вынужден постоянно переключать контексты между ними, а каждое переключение — это та самая дорогая операция с порчей кэшей из раздела выше. Сервер захлёбывается не на полезной работе, а на switch’ах.

Go идёт другим путём (это развитие идей из event-based-concurrency):

  Поток-на-соединение vs модель Go
 
  ┌── thread per connection ──┐     ┌──────── Go ────────┐
  │ conn1 → OS-поток 1        │     │ goroutine'ы (лёгкие,│
  │ conn2 → OS-поток 2        │     │  ~КБ стек) ─┐        │
  │ conn3 → OS-поток 3        │     │             ▼        │
  │  ...   →  ...             │     │   планировщик Go     │
  │ conn100k → OS-поток 100k  │     │   мультиплексирует   │
  │                           │     │   их на немного      │
  │ ОС переключает 100k       │     │   OS-потоков (≈ числу │
  │ потоков → шторм context   │     │   ядер CPU)          │
  │ switch'ей                 │     │                      │
  └───────────────────────────┘     │  netpoller (epoll/   │
                                     │  kqueue) ждёт сразу  │
                                     │  все сокеты одним    │
                                     │  механизмом          │
                                     └──────────────────────┘

Что нарисовано: слева переключений столько же, сколько соединений, и все они — дорогие switch’и потоков ОС. Справа Go держит мало потоков ОС (примерно по числу ядер) и внутри них переключает горутины — это переключение в user space, без trap и без смены адресного пространства, то есть на порядки дешевле.

Ключевую роль играет netpoller: когда горутина делает «блокирующий» сетевой вызов, Go не блокирует поток ОС. Сокет регистрируется в epoll (Linux) / kqueue (BSD/macOS), горутина паркуется, а поток ОС берёт другую готовую горутину. Когда данные пришли, netpoller будит нужную горутину. Так одним-двумя сисколлами (epoll_wait) мы обслуживаем тысячи соединений — снова батчинг вместо «сисколл на соединение».

Подробнее про устройство планировщика Go — в главах scheduler-deep и goroutines.

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

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

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

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

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

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

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

Что спрашивают на собесе

  • Чем системный вызов отличается от обычного вызова функции? Trap в режим ядра, сохранение регистров, смена стека, обязательная валидация аргументов и возврат — фиксированная дорогая обвязка, из-за которой важен батчинг.
  • Почему переключение контекста между процессами дороже, чем между потоками? У процессов разные адресные пространства: надо менять CR3 и (без ASID/PCID) сбрасывать TLB. Плюс холодные кэши данных — косвенная стоимость.
  • Что такое прямая и косвенная стоимость context switch и какая обычно больше? Прямая — сам switch (регистры, планировщик); косвенная — последующие промахи TLB и кэшей. Косвенная часто больше и плохо видна в профайлере.
  • Как уменьшают число переходов в ядро? Батчинг (writev, sendmmsg, буферизация), vDSO для «лёгких» вызовов вроде gettimeofday, io_uring с кольцами SQ/CQ для асинхронного ввода-вывода почти без сисколлов на операцию.
  • Почему Go не делает поток ОС на соединение? Чтобы избежать шторма дорогих переключений потоков ОС: горутины мультиплексируются на немного потоков, а netpoller (epoll/kqueue) одним вызовом следит за тысячами сокетов.
  • Что такое vDSO и зачем он нужен? Механизм, при котором ядро отображает свои данные/код в адресное пространство процесса, чтобы часть «сисколлов» (время) выполнялась без trap, как обычная функция.
Log-structured FS и мост к LSM-деревьямКонтейнеры: namespaces и cgroups