Процеси: адресний простір, PCB і життєвий цикл
Файл виконуваної програми є пасивним набором інструкцій і даних. Коли ОС завантажує програму, надає їй пам'ять, стан процесора та ресурси, виникає процес. Кілька процесів можуть виконувати ту саму програму, але мати різні PID (англ. Process Identifier — ідентифікатор процесу), дані, відкриті файли й етапи життєвого циклу.
Цілі лекції
Після опрацювання матеріалу ви зможете:
- пояснювати відмінність між програмою і процесом;
- описувати призначення code, data, heap і stack в адресному просторі;
- називати основні групи даних PCB (англ. Process Control Block — блок керування процесом);
- простежувати переходи між станами процесу;
- пояснювати ролі
fork,exec,wait,exitіCreateProcess; - читати дерево процесів і пояснювати роль PID 1, init та systemd;
- створювати й коректно очікувати дочірній процес у C/Linux.
Передумови
Потрібно знати роль ядра, User Mode і Kernel Mode, системні виклики та базові поняття C: функція main, локальна змінна, покажчик, перевірка коду помилки. Приклади розраховані на Linux із POSIX (англ. Portable Operating System Interface — переносний інтерфейс операційної системи) API (англ. Application Programming Interface — прикладний програмний інтерфейс) та не потребують sudo.
1. Програма і процес
Програма - це файл або набір файлів із кодом і даними. Процес - екземпляр програми у виконанні разом із поточним станом та ресурсами.
Одна команда може породити кілька різних процесів:
sleep 20 &
sleep 20 &
jobs -l
Обидва процеси виконують /usr/bin/sleep, але мають різні PID. Команда jobs -l показує завдання поточного shell. Процеси самі завершаться приблизно через 20 секунд; команда не змінює конфігурацію системи.
До процесу належать:
- унікальний у межах поточного PID namespace ідентифікатор PID;
- віртуальний адресний простір;
- стан регістрів і поточна точка виконання;
- відкриті файлові дескриптори;
- облікові дані та права;
- зв'язки з батьківським і дочірніми процесами;
- інформація для планування та обліку ресурсів.
2. Віртуальний адресний простір
Кожний процес працює зі своїми віртуальними адресами. Ядро разом із MMU (англ. Memory Management Unit — блок керування пам'яттю) відображає їх на фізичну пам'ять або інші об'єкти. Через це однакова числова віртуальна адреса у двох процесах зазвичай не означає спільні дані.
Навчальна модель адресного простору має такі області:
| Область | Типовий вміст | Властивість |
|---|---|---|
| code / text | машинні інструкції програми | зазвичай доступна для виконання і не для запису |
| read-only data | константи, рядкові літерали | читання без запису |
| data / BSS (англ. Block Started by Symbol — сегмент неініціалізованих даних) | глобальні та статичні змінні | ініціалізовані дані та нульові початкові значення |
| heap | динамічні виділення malloc | змінюється за запитами програми й allocator |
| memory mappings | бібліотеки, файли, анонімні відображення | створюються через механізми на кшталт mmap |
| stack | кадри викликів, локальні змінні, адреси повернення | окремий стек для кожного потоку |
Схема є концептуальною. Точні адреси, порядок і напрям росту областей залежать від архітектури, формату програми, завантажувача та ASLR (англ. Address Space Layout Randomization — рандомізація розташування адресного простору). Не можна писати переносний код, спираючись на те, що heap «завжди росте вгору», а stack «завжди вниз».
Спостереження адрес у C
Створіть address_space.c:
#include <stdio.h>
#include <stdlib.h>
int global_initialized = 7;
int global_zero;
static void marker(void) {
}
int main(void) {
int local = 11;
int *dynamic = malloc(sizeof(*dynamic));
if (dynamic == NULL) {
perror("malloc");
return 1;
}
*dynamic = 13;
marker();
printf("initialized data: %p\n", (void *)&global_initialized);
printf("zero-initialized: %p\n", (void *)&global_zero);
printf("heap allocation: %p\n", (void *)dynamic);
printf("stack local: %p\n", (void *)&local);
free(dynamic);
return 0;
}
Компіляція і запуск:
cc -std=c11 -Wall -Wextra -pedantic address_space.c -o address_space
./address_space
nm -n address_space | grep ' marker$'
Програма друкує адреси об'єктів data, BSS, heap і stack. Команда nm окремо знаходить символ функції marker у секції коду; її значення у PIE-файлі (англ. Position-Independent Executable — позиційно-незалежний виконуваний файл) не слід ототожнювати зі сталою runtime-адресою. Переносний ISO (англ. International Organization for Standardization — Міжнародна організація зі стандартизації) C не гарантує перетворення покажчика на функцію до void *, тому приклад навмисно не друкує його через %p. Виведені адреси об'єктів можуть змінитися після нового запуску через ASLR. Завдання прикладу - побачити різні категорії, а не запам'ятати конкретні числа.
3. PCB: як ядро представляє процес
ОС потребує власного запису, щоб зупинити, відновити, планувати й завершити процес. Узагальнену структуру називають Process Control Block (PCB), або блоком керування процесом.
PCB зазвичай охоплює:
- PID, стан і зв'язки з іншими процесами;
- збережений процесорний контекст;
- дані планування: пріоритет, належність до черг, використаний CPU (англ. Central Processing Unit — центральний процесор) time;
- посилання на структури адресного простору;
- відкриті файли та поточний каталог;
- облікові дані, сигнали й ліміти ресурсів;
- статистику та статус завершення.
PCB не є ділянкою, яку звичайна програма безпосередньо редагує у своєму адресному просторі. Це керівні дані ядра. У Linux немає буквально однієї структури з назвою PCB: центральну роль для задачі виконує task_struct, пов'язана з іншими структурами. У Windows представлення процесу пов'язане, зокрема, з EPROCESS. Термін PCB є корисною загальною моделлю.
Під час context switch ядро зберігає достатній стан поточного потоку та відновлює стан наступного. Частина інформації розташована в керівних структурах, частина - у стеку ядра або архітектурно залежних структурах.
4. Стани та переходи
Базова модель містить п'ять станів:
| Стан | Значення |
|---|---|
| New | ОС створює керівні структури та ресурси |
| Ready | процес готовий виконуватися й очікує CPU |
| Running | інструкції процесу зараз виконуються на CPU |
| Blocked / Waiting | процес чекає події, наприклад даних або завершення нащадка |
| Terminated | виконання завершене; ОС звільняє або вже звільнила ресурси |
Типові переходи:
New -> Ready: створення завершено;Ready -> Running: диспетчер надав CPU;Running -> Ready: витіснення або завершення кванта;Running -> Blocked: потрібна подія, якої ще немає;Blocked -> Ready: подія сталася;Running -> Terminated: процес виконавexit, повернувся зmainабо був завершений.
Процес у стані Blocked не «стоїть у черзі за CPU»: навіть отримавши CPU, він не може продовжити без очікуваної події. Після події він спочатку стає Ready, а момент переходу до Running визначає планувальник.
Реальні ОС мають детальніші стани. Наприклад, Linux-утиліти можуть показувати R, S, D, T, Z та інші коди. Навчальна п'ятистанова модель пояснює загальну логіку, але не замінює документацію конкретного ядра.
5. POSIX-модель: fork, exec, wait, exit
Unix-подібні системи розділяють створення процесу та заміну його програми.
fork: створити нащадка
fork() створює дочірній процес, який продовжує виконання з точки повернення fork. Успішний виклик повертається двічі:
- у батьківському процесі - PID дочірнього процесу, значення більше нуля;
- у дочірньому процесі -
0; - за помилки в батьківському процесі -
-1, і нащадка немає.
Логічно нащадок отримує знімок стану батька. Сучасні системи зазвичай оптимізують пам'ять через Copy-on-Write: фізичні сторінки не копіюються негайно, а розділяються до спроби запису.
exec: замінити програмний образ
Сімейство exec не створює ще один процес. Успішний exec замінює code, data, heap і stack поточного процесу новою програмою. PID зберігається. Успішний exec не повертається до старого коду; повернення означає помилку.
wait і waitpid: отримати завершення
Батьківський процес викликає wait або waitpid, щоб дочекатися зміни стану нащадка й забрати статус завершення. Якщо нащадок уже завершився, але статус ще не отримано, у Unix-моделі залишається мінімальний запис zombie. Він не виконує код, але займає запис у таблиці процесів до wait або до обробки його новим батьком.
exit: завершити процес
Повернення з main еквівалентне виклику exit із цим кодом для нормального C-завершення. ОС закриває ресурси процесу, а код завершення стає доступним батьківському процесу. За домовленістю 0 означає успіх, ненульове значення - певну помилку, але точний зміст визначає програма.
6. Безпечний приклад fork і waitpid
Створіть fork_wait.c:
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
printf("Дочірній процес: pid=%ld, ppid=%ld\n",
(long)getpid(), (long)getppid());
fflush(stdout);
_exit(7);
}
printf("Батьківський процес: pid=%ld, child=%ld\n",
(long)getpid(), (long)child);
int status;
pid_t waited;
do {
waited = waitpid(child, &status, 0);
} while (waited == -1 && errno == EINTR);
if (waited == -1) {
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
printf("Нащадок завершився з кодом %d\n", WEXITSTATUS(status));
} else {
printf("Нащадок завершився не через звичайний exit\n");
}
return 0;
}
Компіляція й запуск:
cc -std=c11 -Wall -Wextra -pedantic fork_wait.c -o fork_wait
./fork_wait
У результаті будуть PID і код 7. Однак перший рядок може надрукувати як батьківський, так і дочірній процес. Після fork обидва є Ready або Running за рішенням планувальника; стандарт POSIX не гарантує порядок їхнього виведення.
waitpid гарантує інше: підсумковий рядок зі статусом друкується батьком після завершення конкретного нащадка. Саме синхронізація через waitpid, а не припущення про швидкість процесів, задає потрібний порядок.
fflush(stdout) перед _exit явно передає рядок бібліотеці/ядру. _exit завершує дочірній процес без повторної обробки успадкованих буферів stdio. Для звичайного завершення окремої програми найчастіше використовують повернення з main або exit.
7. Комбінація fork + exec
Shell зазвичай створює нащадка, а нащадок замінює себе потрібною програмою. Безпечний фрагмент запускає /bin/echo:
pid_t child = fork();
if (child == -1) {
perror("fork");
return 1;
}
if (child == 0) {
execl("/bin/echo", "echo", "Запущено через exec", (char *)NULL);
perror("execl");
_exit(127);
}
int status;
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
return 1;
}
Рядок після execl виконається лише за помилки. Код 127 часто використовують оболонки для невдалого запуску команди, але це домовленість, а не автоматична властивість exec.
8. Windows: CreateProcess
Windows API не використовує пару fork + exec як основну модель. CreateProcessW одним викликом створює новий процес і його первинний потік та завантажує задану програму. Батьківський код отримує handles процесу й потоку та їхні ідентифікатори.
Порівнювати варто за метою, а не механічно:
| Завдання | POSIX | WinAPI |
|---|---|---|
| Створити копію поточного процесу | fork | немає прямого загального аналога |
| Замінити образ поточного процесу | exec | інша модель запуску |
| Створити процес із новою програмою | часто fork + exec | CreateProcessW |
| Дочекатися завершення | waitpid | WaitForSingleObject для process handle |
| Отримати код завершення | макроси WIFEXITED, WEXITSTATUS | GetExitCodeProcess |
Handles після використання потрібно закривати через CloseHandle. PID та handle не є одним і тим самим: PID ідентифікує, а handle є посиланням із правами доступу до об'єкта ядра.
9. Дерево процесів, init і systemd
У Unix-подібній моделі процес має батьківський PID, тому зв'язки утворюють дерево. Подивитися фрагмент дерева поточного сеансу можна без привілеїв:
ps -o pid,ppid,state,comm -p $$
ps -o pid,ppid,state,comm --forest -u "$USER"
Виведення залежить від системи. $$ у shell означає PID поточної оболонки.
Перший userspace-процес у системному PID namespace зазвичай має PID 1. Історично його називають init. У багатьох сучасних Linux-дистрибутивах цю роль виконує systemd: він запускає й контролює системні служби, організовує залежності та приймає осиротілі процеси для подальшого прибирання їхніх статусів.
У контейнері PID 1 може бути не systemd, а сама програма контейнера або мінімальний init. Тому завжди відрізняйте загальну роль процесу PID 1 від конкретної реалізації.
Якщо батьківський процес завершується раніше за живого нащадка, нащадок стає orphan і перепризначається відповідному процесу-прибирачу, часто PID 1 або subreaper. Orphan не те саме, що zombie: orphan може продовжувати виконання, zombie вже завершився й очікує отримання статусу.
Практичні завдання
Завдання 1. Дослідіть адреси
Скомпілюйте address_space.c і запустіть його двічі. Позначте, яка адреса відповідає data, BSS, heap і stack, а за виведенням nm знайдіть символ marker у code. Поясніть, чому показані значення не слід вважати сталими runtime-адресами.
Завдання 2. Простежте два шляхи після fork
Скомпілюйте fork_wait.c. Запустіть не менше трьох разів і запишіть порядок перших двох рядків. Не робіть висновку, що один процес «завжди перший», навіть якщо у трьох запусках порядок випадково збігся.
Завдання 3. Побудуйте керований запуск
Розширте приклад: у дочірній гілці виконайте /bin/echo через execl, у батьківській - waitpid, перевірку WIFEXITED та WEXITSTATUS. Обробіть помилки fork, exec і waitpid. Намалюйте послідовність станів обох процесів від fork до завершення та вкажіть місце, де батько може бути Blocked.
Підсумок
- Процес є програмою у виконанні зі станом, адресним простором і ресурсами.
- Code, data, heap і stack мають різне призначення; точне розміщення залежить від системи.
- PCB є моделлю керівних даних ядра про процес, а не частиною вихідного коду.
- Ready означає готовність до CPU, Blocked - очікування іншої події.
forkстворює нащадка,execзамінює поточний образ,waitpidотримує завершення,exitзавершує процес.- Порядок виконання батька й нащадка після
forkне визначений; потрібний порядок створюють механізмами синхронізації. CreateProcessWреалізує Windows-модель створення процесу без прямого повторенняfork+exec.- Дерево процесів відображає батьківські зв'язки; PID 1 виконує особливу роль init, але не завжди є systemd.
