---
title: "Прямое исполнение с ограничениями"
description: "Из прошлой главы мы знаем, что ОС по очереди даёт процессам поработать на ядре CPU. Как именно «дать поработать»?"
canonical: https://graphlms.ru/os/book/limited-direct-execution
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

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

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

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

Из [прошлой главы](/os/book/process) мы знаем, что ОС по очереди даёт процессам
поработать на ядре 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 КБ:

```go
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 вывод был такой:

```text
без буфера: 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++`. Это станет важно в главе про
  [потоки и гонки](/os/book/threads-intro).

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

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.
