ОС · Виртуализация CPU · 9 мин
Процесс и его состояния
Что вообще такое «процесс»
Процесс — это запущенная программа. Программа на диске мертва: это просто байты в ELF/Mach-O файле. Как только ОС загрузила её в память, выдала кусок адресного пространства, открыла дескрипторы и поставила в очередь на исполнение — появился процесс, живая сущность со своим состоянием.
Главная иллюзия, которую создаёт операционная система, — виртуализация CPU. У вас одно (ну, несколько) физических ядер, а процессов — сотни. ОС делает вид, что каждому достался свой собственный процессор, быстро переключаясь между ними. Этот фокус называется time sharing, и весь раздел про планирование — о том, как ОС решает, кого пустить на ядро следующим.
Если вы пишете на Go, держите в голове важное различие: процесс — это не горутина. Горутины живут внутри одного процесса, их переключает рантайм Go в user-space. Процессы переключает ядро, и это на порядок дороже. Когда на собесе спрашивают «почему горутина дешевле потока ОС» — корень ответа здесь.
Из чего состоит процесс
Состояние процесса — это всё, что нужно сохранить, чтобы потом продолжить с того же места:
- Адресное пространство — код, данные, куча, стек. Виртуальная память (про неё отдельный раздел) даёт каждому процессу иллюзию, что вся память его.
- Регистры — в том числе program counter (где мы в коде) и stack pointer. При переключении их сохраняют и восстанавливают.
- Открытые ресурсы — файловые дескрипторы, сокеты. В Unix у каждого процесса по умолчанию есть 0/1/2 (stdin/stdout/stderr).
Всё это ядро хранит в структуре — Process Control Block (в Linux это
task_struct). По сути это и есть «паспорт» процесса.
Состояния процесса
В любой момент процесс находится в одном из состояний. Упрощённая, но рабочая модель — три состояния:
- Running — прямо сейчас исполняется на ядре.
- Ready — готов исполняться, ждёт, когда планировщик его пустит.
- Blocked — ждёт события (чтение с диска, ответ по сети) и не претендует на CPU, пока событие не произошло.
запланирован
READY ───────────────▶ RUNNING
▲ │
│ снят с ядра │ ушёл в I/O
└─────────────────────────┤
▼
BLOCKED
(событие пришло → обратно в READY)Ключевой момент для понимания производительности: процесс уходит в blocked, когда делает блокирующий системный вызов — например, читает файл. Пока диск вертится, держать его на ядре бессмысленно, поэтому ОС снимает его и пускает кого-то из ready. Это и есть причина, по которой ввод-вывод «прячется» за вычислениями: пока один ждёт диск, другой считает.
В Go тот же принцип работает на двух уровнях. Когда горутина делает блокирующий сетевой вызов, рантайм не блокирует поток ОС целиком — он паркует горутину и отдаёт поток другим горутинам (через netpoller). Снаружи это выглядит как синхронный код, а внутри — то же самое «не занимай процессор, пока ждёшь».
Context switch
Переключение с одного процесса на другой называется context switch: сохранить регистры текущего в его PCB, загрузить регистры следующего, передать управление. Это не бесплатно — прямые затраты (сотни наносекунд) плюс косвенные: вымывается кэш, сбрасывается TLB. Поэтому «переключать почаще, чтобы было честнее» — плохая идея: на одних переключениях можно сжечь весь полезный такт.
Именно баланс между честностью (каждому дать время) и накладными расходами (не переключаться слишком часто) и есть главная головная боль планировщика — ей посвящены следующие главы.
Чем процесс принципиально отличается от горутины?
В какое состояние уходит процесс, сделавший блокирующее чтение с диска?
Что спрашивают на собесе
- Чем процесс отличается от потока и от горутины? Что у них общее, что раздельное (адресное пространство — общее у потоков, раздельное у процессов).
- Что происходит при
fork()и почему он дешёвый (copy-on-write). - Почему context switch дорогой и при чём тут кэш и TLB.