ОС · Виртуализация CPU · 10 мин
Прямое исполнение с ограничениями
Дилемма: быстро, но под контролем
Чтобы программа работала быстро, её нужно исполнять прямо на железе — никаких интерпретаторов между кодом и процессором. Это называется direct execution. Но если просто отдать процессу ядро, возникают две проблемы:
- Контроль. Как ОС вернёт себе управление, если процесс зациклился или просто не хочет уступать? Как запретить ему лезть в чужую память и диск?
- Безопасность. Что мешает процессу выполнить инструкцию «прочитай весь диск» или «обратись к памяти ядра»?
Решение — limited direct execution: исполняем напрямую, но с аппаратными ограничениями. Два механизма делают всю работу: режимы процессора и прерывания.
User mode и kernel mode
Процессор умеет работать в двух режимах:
- User mode — в нём бежит ваш код. Привилегированные инструкции (прямой доступ к железу, к памяти ядра) запрещены: попытка → аппаратное исключение.
- Kernel mode — в нём бежит ядро. Можно всё.
Как тогда процессу прочитать файл, если прямой доступ к диску запрещён? Через
системный вызов (syscall). Процесс выполняет специальную инструкцию (syscall
на x86-64), которая аккуратно, через заранее заданную точку входа, переключает
процессор в kernel mode и прыгает в код ядра. Ядро проверяет аргументы, делает
работу и возвращает управление обратно в user mode.
user mode kernel mode
────────── ───────────
read(fd, buf, n)
│ syscall (trap)
└──────────────────────────▶ обработчик read:
проверить права,
запустить I/O,
...
◀──────────────────────────┘ return-from-trap
продолжаем с результатомЭтот «прыжок» называется trap, а адреса обработчиков ядро задаёт один раз при загрузке (trap table). Процесс не может выбрать, куда прыгнуть, — только сказать «вызов номер N». Поэтому syscall — единственная легальная дверь из user-кода в ядро, и именно поэтому она дороже обычного вызова функции: смена режима, сохранение контекста, проверки.
В Go каждый сетевой/файловый вызов под капотом — это syscall. Понимание их цены
объясняет, почему батчинг (один большой write вместо тысячи мелких) так заметно
ускоряет код: вы платите за переход в ядро один раз, а не тысячу.
Как ОС возвращает себе управление
Syscall — это когда процесс сам позвал ядро. Но что если он завис в бесконечном цикле и ничего не зовёт? Если бы ОС ждала доброй воли процесса (кооперативный подход), один баг вешал бы всю систему.
Поэтому используется прерывание по таймеру. ОС перед запуском процесса программирует таймер: «дёрни меня через 10 мс». Когда время вышло, железо принудительно прерывает процесс, переключается в kernel mode и отдаёт управление обработчику. Это вытесняющая (preemptive) многозадачность — ОС всегда может отобрать ядро.
Дальше планировщик решает: продолжить тот же процесс или переключиться на другой. Если переключиться — сделать context switch (сохранить регистры в PCB одного, загрузить из PCB другого) и через return-from-trap вернуться уже в другой процесс.
Интересная параллель: до Go 1.14 горутины переключались в основном кооперативно (в точках вызова функций), и «горячий» цикл без вызовов мог застопорить планировщик. В 1.14 завезли асинхронное вытеснение через сигналы — ровно та же идея «принудительно отобрать управление», что и таймер в ОС.
Что спрашивают на собесе
- Чем syscall отличается от обычного вызова функции и почему он дороже.
- Кооперативная vs вытесняющая многозадачность — плюсы и риски каждой.
- Как именно ОS отбирает CPU у зациклившегося процесса (таймер + прерывание).
- Зачем нужны два режима процессора.