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

Контент v0.12.0

Процесс и его состояния

Программа и процесс

Вы собрали Go-сервис: go build -o api ./cmd/api. На диске появился файл api, несколько мегабайт байтов в формате ELF (на Linux) или Mach-O (на macOS). Пока его никто не запустил, это просто файл, как картинка или текстовый документ.

Теперь вы запускаете ./api дважды, в двух терминалах. В ps появятся две строки (вывод примерный) с разными PID (номерами процессов). Файл один, а запущенных экземпляров два, и у каждого своя память, свои открытые сокеты, своё место в коде. Вот это и есть процесс: запущенная программа вместе со всем её текущим состоянием.

$ ps -o pid,stat,rss,cmd -C api
  PID STAT   RSS CMD
 4121 Sl   18432 ./api
 4187 Sl   17920 ./api

Курс начинается с процесса, потому что почти всё дальнейшее отвечает на один из двух вопросов: как ОС делит между процессами процессор и как делит между ними память.

Сразу договоримся о словах. По-русски «ядро» означает две разные вещи:

  • ядро ОС (kernel) — программа, которая управляет железом и процессами;
  • ядро процессора (core) — вычислительный блок CPU, который исполняет инструкции.

Где может быть путаница, будем писать «ядро ОС» и «ядро CPU».

Иллюзия собственного процессора

На ноутбуке 8 ядер CPU, а в ps aux | wc -l несколько сотен процессов. Каждый пишется так, будто процессор принадлежит только ему: код идёт строчка за строчкой, никто его не прерывает. На самом деле ядро ОС быстро переключает ядра CPU между процессами: десяток миллисекунд один, потом другой, потом третий. Переключения происходят так часто, что для человека и для самой программы всё выглядит как одновременная работа.

Этот приём называется разделением времени (time sharing), а иллюзию, которую он создаёт, — виртуализацией CPU. Цена иллюзии в том, что ОС должна:

Что входит в состояние процесса

Чтобы остановить процесс и потом продолжить, нужно сохранить всё, от чего зависит его дальнейшая работа. Это три группы.

Память. Код программы, глобальные переменные, куча, стек. Каждый процесс видит собственное адресное пространство, и соседи туда не залезут. Как это устроено, мы разберём в блоке про память, начиная с главы адресное пространство.

Регистры процессора. Самые важные из них: счётчик команд (program counter, PC), то есть адрес следующей инструкции, и указатель стека (stack pointer, SP). Если при остановке не сохранить PC, процесс не узнает, где он был.

Ресурсы ОС. Открытые файлы и сокеты (по номерам-дескрипторам 0, 1, 2 и дальше), текущий каталог, права пользователя. По соглашению Unix у каждого процесса с рождения открыты три дескриптора: 0 (stdin), 1 (stdout) и 2 (stderr).

Ядро ОС хранит всё это в структуре, которую в учебниках называют PCB (process control block). В Linux это структура task_struct. Сохранённые регистры, ссылка на таблицу памяти, таблица открытых файлов, PID, состояние — всё лежит там.

Горутина в эту картину не входит. Горутины живут внутри одного процесса, и переключает их рантайм Go без участия ядра ОС. Для ядра ваш Go-сервис — один процесс с несколькими потоками. Подробно сравним процессы, потоки и горутины в главе потоки vs процессы vs горутины.

Это легко проверить:

package main
 
import (
	"fmt"
	"os"
	"sync"
)
 
func main() {
	fmt.Println("main: PID", os.Getpid())
 
	var wg sync.WaitGroup
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Printf("горутина %d: PID %d\n", i, os.Getpid())
		}()
	}
	wg.Wait()
}

Вывод будет примерно таким (число PID у вас другое, порядок строк горутин может меняться от запуска к запуску):

main: PID 48213
горутина 3: PID 48213
горутина 1: PID 48213
горутина 2: PID 48213

Все горутины печатают один и тот же PID: процесс один.

Три состояния

В каждый момент процесс находится в одном из трёх основных состояний:

  • Running (исполняется) — его инструкции прямо сейчас выполняет ядро CPU.
  • Ready (готов) — он мог бы исполняться, но все ядра CPU заняты; ждёт, когда планировщик его выберет.
  • Blocked (заблокирован) — он ждёт события: ответа диска, данных из сети, завершения дочернего процесса. Пока событие не наступило, давать ему процессор бессмысленно.
               планировщик выбрал
     READY  ─────────────────────▶  RUNNING
       ▲    ◀─────────────────────    │
       │      вытеснен (кончился      │ сделал блокирующий вызов
       │      квант времени)          │ (read с диска, ожидание сети)
       │                              ▼
       └──────────────────────────  BLOCKED
             событие произошло

Обратите внимание на стрелки. Из BLOCKED процесс попадает не сразу в RUNNING, а в READY: событие пришло, но ядро CPU может быть занято кем-то другим.

В Linux состояние видно в колонке STAT у ps:

Буква Что значит В модели выше
R running или runnable RUNNING или READY
S спит, ждёт события, можно прервать сигналом BLOCKED
D спит без прерывания, обычно ждёт диск BLOCKED
Z зомби: завершился, родитель не забрал код возврата см. API процессов
T остановлен (например, Ctrl+Z) отдельное состояние

Частая ошибка: считать, что R в ps значит «прямо сейчас считает». Linux не различает running и ready в этой колонке, R означает «хочет процессор». Если у вас 4 ядра CPU, а в R стабильно 20 процессов, то 16 из них стоят в очереди. Именно это число (вместе с процессами в D) усредняет load average в uptime.

Зачем нужно состояние BLOCKED: пример с числами

Возьмём одно ядро CPU и два процесса:

  • A считает 3 мс, затем читает с диска (чтение длится 5 мс), затем считает ещё 2 мс;
  • B просто считает 6 мс.

Сначала плохой вариант: ОС держит A на CPU, пока тот ждёт диск.

время, мс  0    3         8    10        16
CPU        [ A ][ A ждёт ][ A ][   B    ]
                 диск, CPU
                 простаивает

Всё закончится к 16 мс, причём 5 мс процессор ничего не делал.

Теперь как на самом деле: при чтении с диска A уходит в BLOCKED, и ОС сразу запускает B.

время, мс  0    3              9    11
CPU        [ A ][      B      ][ A ]
диск            [ чтение A ]
                3          8

В момент 8 мс диск закончил, A перешёл в READY, но процессор занят B. Когда B закончил в 9 мс, A получил CPU и досчитал к 11 мс. Работа та же, а закончили на 5 мс раньше: ожидание диска спряталось за вычислениями B. На серверах этот эффект главный: пока один запрос ждёт базу, процессор обслуживает другие.

В Go то же самое происходит на уровне горутин. Когда горутина читает из сокета, а данных ещё нет, рантайм не держит поток ОС в ожидании: он паркует горутину и отдаёт поток другой горутине, а сокет отдаёт на слежение механизму netpoller. Код при этом выглядит синхронным. Подробнее в главе событийная модель и epoll.

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

Переход процессора от одного процесса к другому называется переключением контекста (context switch). Ядро ОС сохраняет регистры текущего процесса в его PCB, загружает регистры следующего из его PCB и возвращает управление уже в следующий процесс.

Прямая цена переключения — порядка микросекунды. Есть и косвенная, обычно больше прямой: новый процесс работает с другими данными, поэтому кэши процессора и TLB (кэш трансляции адресов, глава TLB) оказываются «холодными», и первые микросекунды после переключения он работает медленнее. Сводка цифр — в главе цена syscall и переключения контекста.

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

Проверь себя· Процесс и его состояния

Чем процесс принципиально отличается от горутины?

В какое состояние уходит процесс, сделавший блокирующее чтение с диска?

Что делает ОС, когда процесс в состоянии running исчерпал свой квант времени?

Какое из состояний процесса означает «готов работать, но CPU сейчас занят другим»?

Сколько процессов могут одновременно находиться в состоянии running на машине с 4 ядрами (по одному потоку на ядро, без гиперпоточности)?

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

  1. Вы запустили один и тот же бинарник три раза. Сколько процессов, сколько программ?
  2. Процесс читает файл, данных в памяти нет, ОС пошла на диск. В каком он состоянии? Куда он перейдёт, когда диск ответит?
  3. В ps у Go-сервиса один PID, хотя в нём 10 000 горутин. Почему?
  4. В примере с A и B поменяйте длительность чтения с диска на 10 мс. Когда закончится вся работа при правильном поведении ОС?
Показать ответы

Ответы. (1) Три процесса, одна программа. (2) BLOCKED, а после ответа диска READY, не сразу RUNNING. (3) Горутины переключает рантайм внутри процесса; ядро ОС видит один процесс и несколько его потоков. (4) A считает 0–3, B считает 3–9, диск закончит в 13, A досчитает 13–15. Всё закончится к 15 мс; процессор простаивал с 9 до 13.

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

  • Чем процесс отличается от программы и от потока? Процесс — запущенный экземпляр со своим адресным пространством; потоки одного процесса делят адресное пространство, у каждого свой стек и регистры.
  • Чем процесс отличается от горутины, почему горутина дешевле.
  • Какие состояния бывают и какие переходы между ними возможны; почему из BLOCKED попадают в READY, а не в RUNNING.
  • Почему context switch дорогой и при чём тут кэши и TLB.
  • Что происходит при fork() и почему он дешёвый (copy-on-write) — разбирается в главе API процессов.

Продолжить