Логотип коледжу
Оптико-механічний фаховий коледж

Процеси: адресний простір, 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кадри викликів, локальні змінні, адреси поверненняокремий стек для кожного потоку

Адресний простір процесу, PCB і переходи між станами

Схема є концептуальною. Точні адреси, порядок і напрям росту областей залежать від архітектури, формату програми, завантажувача та 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 процесу й потоку та їхні ідентифікатори.

Порівнювати варто за метою, а не механічно:

ЗавданняPOSIXWinAPI
Створити копію поточного процесуforkнемає прямого загального аналога
Замінити образ поточного процесуexecінша модель запуску
Створити процес із новою програмоючасто fork + execCreateProcessW
Дочекатися завершенняwaitpidWaitForSingleObject для process handle
Отримати код завершеннямакроси WIFEXITED, WEXITSTATUSGetExitCodeProcess

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.

Завдання

1. Що найточніше відрізняє процес від файла програми?

2. Де зазвичай розміщують динамічно виділені через `malloc` об'єкти?

3. Яке призначення PCB?

4. Який перехід типовий, коли процес у стані Running викликає `read` і мусить чекати даних?

5. Яке значення повертає успішний `fork()` у дочірньому процесі? Запишіть лише число.

6. Навіщо батьківський процес викликає `wait` або `waitpid`?