Вы собрали 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 mainimport ( "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 у вас другое, порядок строк горутин может
меняться от запуска к запуску):
В каждый момент процесс находится в одном из трёх основных состояний:
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
зомби: завершился, родитель не забрал код возврата
Частая ошибка: считать, что R в ps значит «прямо сейчас считает». Linux не
различает running и ready в этой колонке, R означает «хочет процессор». Если у вас
4 ядра CPU, а в R стабильно 20 процессов, то 16 из них стоят в очереди. Именно это
число (вместе с процессами в D) усредняет load average в uptime.
A считает 3 мс, затем читает с диска (чтение длится 5 мс), затем считает
ещё 2 мс;
B просто считает 6 мс.
Сначала плохой вариант: ОС держит A на CPU, пока тот ждёт диск.
время, мс 0 3 8 10 16CPU [ A ][ A ждёт ][ A ][ B ] диск, CPU простаивает
Всё закончится к 16 мс, причём 5 мс процессор ничего не делал.
Теперь как на самом деле: при чтении с диска A уходит в BLOCKED, и ОС сразу
запускает B.
время, мс 0 3 9 11CPU [ 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 ядрами (по одному потоку на ядро, без гиперпоточности)?
Вы запустили один и тот же бинарник три раза. Сколько процессов, сколько
программ?
Процесс читает файл, данных в памяти нет, ОС пошла на диск. В каком он
состоянии? Куда он перейдёт, когда диск ответит?
В ps у Go-сервиса один PID, хотя в нём 10 000 горутин. Почему?
В примере с 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 процессов.