Виртуализация CPU · 10 мин

Контент v0.12.0

Прямое исполнение с ограничениями

Задача: быстро, но под контролем

Из прошлой главы мы знаем, что ОС по очереди даёт процессам поработать на ядре CPU. Как именно «дать поработать»?

Самый быстрый способ — просто прыгнуть в код процесса и пусть процессор исполняет его инструкции напрямую, без посредников. Это называется прямым исполнением (direct execution). Ваш скомпилированный Go-код так и работает: никакого интерпретатора, процессор читает машинные инструкции одну за другой.

Но если отдать процессу процессор без ограничений, сразу появляются два вопроса.

  1. Как запретить опасное? Что помешает процессу прочитать чужую память, записать напрямую на диск в обход прав доступа или выключить прерывания?
  2. Как забрать процессор обратно? Если процесс ушёл в бесконечный цикл, ОС сама по себе не работает: в этот момент процессор исполняет инструкции процесса, а не ядра ОС.

Ответ называется прямым исполнением с ограничениями (limited direct execution): код работает напрямую на железе, но железо помогает ОС поставить рамки. Для этого нужны два механизма: режимы процессора и прерывания.

Два режима процессора

Процессор умеет работать в двух режимах (на x86 их называют кольцами 3 и 0):

  • Пользовательский режим (user mode). В нём исполняется код процессов. Привилегированные инструкции запрещены: ввод-вывод на устройства, смена таблицы страниц, отключение прерываний, остановка процессора.
  • Режим ядра (kernel mode). В нём работает ядро ОС, и ему можно всё.

Если процесс в пользовательском режиме попробует выполнить привилегированную инструкцию, процессор её не выполнит, а сгенерирует исключение и передаст управление ядру ОС. В Linux такой процесс обычно получает сигнал и падает. Защита сделана в железе, программа не может её обойти.

Системный вызов: легальная дверь в ядро

Но процессу нужно читать файлы и отправлять данные в сеть. Для этого он просит ядро ОС через системный вызов (system call, syscall).

На x86-64 Linux процесс кладёт номер вызова в регистр rax (например, write — это номер 1, read — номер 0), аргументы в другие регистры и выполняет специальную инструкцию syscall. Дальше работает железо:

  1. процессор переключается в режим ядра;
  2. сохраняет адрес, куда потом вернуться, и прыгает по адресу обработчика, который ядро ОС заранее указало при загрузке;
  3. обработчик смотрит номер в rax, проверяет аргументы и права, выполняет работу;
  4. специальная инструкция возврата (её называют return-from-trap; на x86-64 это sysret или iret) переключает процессор обратно в пользовательский режим и продолжает процесс со следующей инструкции.

Переход в ядро по такой инструкции называют trap (ловушка). Главная деталь: процесс выбирает только номер вызова, а не адрес, куда прыгнуть. Адреса обработчиков ядро ОС записывает в специальные регистры и таблицы процессора один раз при загрузке (обобщённо — таблица ловушек, trap table), и делает это привилегированными инструкциями. Поэтому процесс не может прыгнуть в середину кода ядра в обход проверок.

Полная картина по шагам, от загрузки машины до первого системного вызова:

Кто Что делает
ОС при загрузке (режим ядра) заполняет таблицу ловушек, запускает таймер прерываний
ОС при запуске процесса заводит PCB, выделяет память, загружает программу, кладёт на стек argv
ОС выполняет return-from-trap «в процесс»
Железо восстанавливает регистры процесса, переходит в user mode, прыгает на точку входа
Процесс (user mode) работает, доходит до write(...), выполняет syscall
Железо сохраняет регистры процесса, переходит в kernel mode, прыгает в обработчик
ОС выполняет write, кладёт результат в регистр
ОС return-from-trap
Железо восстанавливает регистры, переходит в user mode
Процесс продолжает со следующей инструкции после syscall

Сколько это стоит

Системный вызов дороже обычного вызова функции: смена режима, сохранение регистров, проверки аргументов, плюс защитные меры от уязвимостей процессора. Порядок величины: обычный вызов функции занимает около наносекунды, системный вызов — сотни наносекунд.

Это видно на простом эксперименте. Программа ниже записывает миллион байт в /dev/null сначала по одному байту за вызов, потом через буфер bufio, который копит байты в памяти процесса и отправляет в ядро пачками по 64 КБ:

package main
 
import (
	"bufio"
	"fmt"
	"os"
	"time"
)
 
func main() {
	f, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
	if err != nil {
		panic(err)
	}
	defer f.Close()
 
	const n = 1_000_000
	b := []byte{'x'}
 
	start := time.Now()
	for i := 0; i < n; i++ {
		f.Write(b) // каждый вызов — системный вызов write
	}
	direct := time.Since(start)
 
	w := bufio.NewWriterSize(f, 64*1024)
	start = time.Now()
	for i := 0; i < n; i++ {
		w.Write(b) // копирование в буфер в памяти процесса
	}
	w.Flush() // около 16 системных вызовов на весь миллион байт
	buffered := time.Since(start)
 
	fmt.Printf("без буфера: %v (%.0f нс на write)\n", direct, float64(direct.Nanoseconds())/n)
	fmt.Printf("с bufio:    %v\n", buffered)
}

На ноутбуке с Apple M-серии под macOS вывод был такой:

без буфера: 524.071792ms (524 нс на write)
с bufio:    4.348875ms

Разница больше чем в сто раз, и это при записи в устройство, которое ничего не делает с данными. Почти всё время первого цикла уходит на переходы в ядро. Отсюда практическое правило бэкенда: мелкие Write в файл или сокет без буфера — первое, что стоит искать в профиле, если высок system CPU (sy в top). Запустите программу у себя: числа будут другими, но порядок разницы сохранится.

Частая ошибка: думать, что любой вызов, который «спрашивает что-то у системы», обязательно идёт в ядро. time.Now() в Go на Linux работает без trap: ядро отображает в память каждого процесса маленькую библиотеку vDSO со страницей данных, где лежит текущее время, и чтение часов стоит десятки наносекунд, а не сотни.

Как ОС забирает процессор

Системный вызов — это когда процесс сам отдаёт управление ядру. Если процесс ничего не вызывает (считает в цикле или завис), этого не произойдёт никогда.

Старые системы (Windows 3.x, Mac OS до версии X) полагались на добрую волю программ: процесс должен был время от времени сам отдавать процессор. Это кооперативная многозадачность. Одна зависшая программа вешала всю машину.

Современные ОС используют прерывание по таймеру. При загрузке ядро ОС программирует аппаратный таймер, и тот регулярно, например раз в несколько миллисекунд, посылает процессору прерывание. Прерывание работает как принудительная ловушка: процессор бросает текущую инструкцию процесса, сохраняет его регистры, переходит в режим ядра и прыгает в обработчик таймера. Процесс на это повлиять не может: настройка таймера и отключение прерываний — привилегированные операции. Такая многозадачность называется вытесняющей (preemptive).

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

Кто Что делает
Процесс A (user mode) считает
Железо прерывание таймера: сохраняет регистры A на стек ядра A, kernel mode, прыжок в обработчик
ОС планировщик решил переключиться на B
ОС сохраняет регистры ядра A в PCB A, загружает из PCB B, переключается на стек ядра B
ОС return-from-trap
Железо восстанавливает регистры B, user mode, прыжок туда, где B остановился
Процесс B продолжает работу

Обратите внимание: return-from-trap возвращает не в тот процесс, который прервали, а в тот, чьи регистры ядро только что загрузило. С точки зрения B всё выглядит так, будто он вернулся из своего прерывания.

Параллель в Go

Рантайм Go решает ту же задачу для горутин. До Go 1.14 горутины переключались кооперативно, в определённых точках (в основном при вызовах функций). Горутина с тугим циклом без вызовов могла надолго занять поток и задержать, например, сборку мусора, которой нужно остановить все горутины. В Go 1.14 появилось асинхронное вытеснение: служебный поток рантайма (sysmon) замечает горутину, работающую дольше примерно 10 мс, и посылает её потоку сигнал SIGURG. Обработчик сигнала останавливает горутину в безопасной точке. Это та же идея, что и прерывание по таймеру, только реализованная поверх сигналов ОС.

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

  • «Ядро ОС постоянно работает в фоне и следит за процессами». Нет: пока исполняется процесс, процессор исполняет его инструкции. Ядро ОС получает управление только через syscall, исключение или прерывание.
  • «Syscall — это просто вызов функции из libc». Функция из libc (или из пакета syscall в Go) лишь готовит регистры; дорогая часть — сама инструкция syscall и работа ядра.
  • «Вытеснение возможно только в момент системного вызова». Таймер прерывает процесс в любой точке, даже посреди count++. Это станет важно в главе про потоки и гонки.

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

  1. Почему процесс не может сам выбрать адрес, по которому прыгнет syscall?
  2. Процесс выполнил бесконечный цикл for {} без системных вызовов. Через какой механизм ОС вернёт себе управление?
  3. Какое из действий процессу разрешено в user mode: записать в регистр CR3 адрес новой таблицы страниц; прочитать собственную переменную; выключить прерывания?
  4. В эксперименте с bufio сколько примерно системных вызовов write сделал второй вариант?
Показать ответы

Ответы. (1) Адреса обработчиков задаёт ядро ОС при загрузке привилегированными инструкциями; процесс передаёт только номер вызова. (2) Прерывание по таймеру. (3) Только чтение собственной переменной; остальное привилегировано. (4) Около 16: миллион байт делённый на буфер 64 КБ (65 536 байт), плюс финальный Flush.

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

  • Чем syscall отличается от обычного вызова функции и почему он дороже.
  • Зачем процессору два режима и что будет при попытке выполнить привилегированную инструкцию в user mode.
  • Кооперативная и вытесняющая многозадачность: плюсы и риски каждой.
  • Как именно ОС отбирает процессор у зациклившегося процесса (таймер и прерывание).
  • Почему буферизация ввода-вывода ускоряет программу; что такое vDSO.
  • Как Go вытесняет горутины с версии 1.14.

Продолжить