Процеси: адресний простір, PCB і життєвий цикл
Файл виконуваної програми є пасивним набором інструкцій і даних. Коли ОС завантажує програму, надає їй пам'ять, стан процесора та ресурси, виникає процес. Кілька процесів можуть виконувати ту саму програму, але мати різні PID, дані, відкриті файли й етапи життєвого циклу.
Цілі лекції
Після опрацювання матеріалу ви зможете:
- пояснювати відмінність між програмою і процесом;
- описувати призначення code, data, heap і stack в адресному просторі;
- називати основні групи даних PCB;
- простежувати переходи між станами процесу;
- пояснювати ролі
fork,exec,wait,exitіCreateProcess; - читати дерево процесів і пояснювати роль PID 1, init та systemd;
- створювати й коректно очікувати дочірній процес у C/Linux.
Передумови
Потрібно знати роль ядра, User Mode і Kernel Mode, системні виклики та базові поняття C: функція main, локальна змінна, покажчик, перевірка коду помилки. Приклади розраховані на Linux із POSIX API та не потребують sudo.
1. Програма і процес
Програма - це файл або набір файлів із кодом і даними. Процес - екземпляр програми у виконанні разом із поточним станом та ресурсами.
Одна команда може породити кілька різних процесів:
sleep 20 &
sleep 20 &
jobs -l
Обидва процеси виконують /usr/bin/sleep, але мають різні PID. Команда jobs -l показує завдання поточного shell. Процеси самі завершаться приблизно через 20 секунд; команда не змінює конфігурацію системи.
До процесу належать:
- унікальний у межах поточного PID namespace ідентифікатор PID;
- віртуальний адресний простір;
- стан регістрів і поточна точка виконання;
- відкриті файлові дескриптори;
- облікові дані та права;
- зв'язки з батьківським і дочірніми процесами;
- інформація для планування та обліку ресурсів.
2. Віртуальний адресний простір
Кожний процес працює зі своїми віртуальними адресами. Ядро разом із MMU відображає їх на фізичну пам'ять або інші об'єкти. Через це однакова числова віртуальна адреса у двох процесах зазвичай не означає спільні дані.
Навчальна модель адресного простору має такі області:
| Область | Типовий вміст | Властивість |
|---|---|---|
| code / text | машинні інструкції програми | зазвичай доступна для виконання і не для запису |
| read-only data | константи, рядкові літерали | читання без запису |
| data / BSS | глобальні та статичні змінні | ініціалізовані дані та нульові початкові значення |
| heap | динамічні виділення malloc | змінюється за запитами програми й allocator |
| memory mappings | бібліотеки, файли, анонімні відображення | створюються через механізми на кшталт mmap |
| stack | кадри викликів, локальні змінні, адреси повернення | окремий стек для кожного потоку |
Схема є концептуальною. Точні адреси, порядок і напрям росту областей залежать від архітектури, формату програми, завантажувача та ASLR. Не можна писати переносний код, спираючись на те, що 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-файлі не слід ототожнювати зі сталою runtime-адресою. Переносний ISO C не гарантує перетворення покажчика на функцію до void *, тому приклад навмисно не друкує його через %p. Виведені адреси об'єктів можуть змінитися після нового запуску через ASLR. Завдання прикладу - побачити різні категорії, а не запам'ятати конкретні числа.
3. PCB: як ядро представляє процес
ОС потребує власного запису, щоб зупинити, відновити, планувати й завершити процес. Узагальнену структуру називають Process Control Block (PCB), або блоком керування процесом.
PCB зазвичай охоплює:
- PID, стан і зв'язки з іншими процесами;
- збережений процесорний контекст;
- дані планування: пріоритет, належність до черг, використаний CPU 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.
