Процеси
Навіщо це
Section titled “Навіщо це”Ви запускаєте ./server, він падає, ви перезапускаєте і отримуєте
bind: address already in use, хоча ps не показує жодного server.
Ви натискаєте Ctrl+C, а процес не помирає. Робите kill -9,
і він усе одно висить у списку зі станом Z.
У кожної з цих ситуацій є точне пояснення, і всі три впираються в одне:
що саме операційна система вважає процесом, які в нього бувають стани
і хто прибирає після його смерті. Про це й піде мова — від fork
до запису в таблиці, який хтось мусить прочитати.
Передумови. Уявлення про переривання і таймер (модуль 2) та про межу між простором користувача і простором ядра (модуль 3).
Програма — не процес
Section titled “Програма — не процес”Програма є файлом на диску, послідовністю байтів у форматі, який ядро вміє завантажувати; у Linux це ELF. Вона цілком пасивна й сама по собі нічого не робить.
Процес — це програма у стані виконання разом з усім, що ядро для неї
завело: адресний простір, відкриті файли, поточний каталог, права,
лічильник команд, стек. З одного файлу /usr/bin/python3 може працювати
сорок процесів одночасно, і всі сорок будуть незалежні.
Адресний простір
Section titled “Адресний простір”Кожен процес бачить власну пам’ять і не бачить чужої. Те, що він бачить, поділене на секції з різними правами доступу.
- Код (text) містить інструкції. Його можна читати й виконувати, але не записувати: спроба переписати власний код завершує процес.
- Дані (data, bss) — це глобальні та статичні змінні. У
dataлежать ті, що мають початкове значення, уbss— ті, що починаються з нуля і тому не займають місця у файлі. - Купа (heap) — динамічна пам’ять, яку віддає
malloc. Росте в бік старших адрес. - Стек зберігає кадри викликів функцій і локальні змінні, а росте назустріч купі.
Порожнеча між купою і стеком — це не вільна пам’ять процесу, як може
здатися. Це адреси, за якими нічого не закріплено: звернення туди дає
сторінковий виняток (page fault), обслужити який ядро не може,
і процес отримує SIGSEGV.
Дескриптор процесу
Section titled “Дескриптор процесу”Усе, що ядро знає про процес, лежить в одній структурі — дескрипторі процесу,
або process control block. У Linux це struct task_struct, і структура ця
чимала, кілька сотень полів. Основні групи такі:
| Група | Що містить | Навіщо |
|---|---|---|
| Ідентифікація | PID, PPID, UID, GID | хто це і від чийого імені працює |
| Стан | поточний стан, код завершення | що з ним робити далі |
| Контекст | лічильник команд, регістри, вказівник стека | щоб відновити виконання після перемикання |
| Пам’ять | вказівник на адресний простір, межі секцій | щоб MMU знав, що йому транслювати |
| Ввід-вивід | таблиця відкритих файлів, робочий каталог | щоб read(3, …) знав, що таке «3» |
| Планування | пріоритет, nice, спожитий час |
щоб планувальник знав, кого пускати |
| Зв’язки | батько, діти, група процесів | щоб побудувати дерево і доставляти сигнали |
Живуть дескриптори в пам’яті ядра, а не процесу. Сам процес не може ні прочитати свій дескриптор напряму, ні підправити в ньому пріоритет, для цього існують системні виклики.
Стани і переходи
Section titled “Стани і переходи”Найпоширеніша помилка в цій темі — плутати «готовий» і «заблокований». Різниця тут суто практична. Якщо система гальмує, а процеси стоять у стані «готовий», не вистачає процесора. Якщо ж вони заблоковані, процесор ні до чого, і вузьке місце шукайте в диску або мережі.
Перехід із «виконується» в «готовий» називають витісненням: спрацював таймер, квант вичерпано, ядро забрало процесор. Нічого поганого процес при цьому не зробив і нічого не чекає, його просто поставили в чергу.
Контекстне перемикання
Section titled “Контекстне перемикання”Щоб зняти процес з ядра процесора й поставити інший, ядро зберігає регістри першого в його дескриптор, завантажує регістри другого і перемикає адресний простір. Це контекстне перемикання, і безкоштовним воно не буває.
Пряма вартість вимірюється одиницями мікросекунд, а от непряма більша. Новий процес приходить на холодні кеші й порожній TLB, тому перші тисячі його інструкцій виконуються повільніше, ніж могли б. Через це квант часу й не роблять дуже малим: чим частіші перемикання, тим більша частка процесора йде на обслуговування самих перемикань.
Створення процесу
Section titled “Створення процесу”У Unix процес не створюється з нуля. Він клонується з наявного, а потім, за потреби, заміщує свій образ іншою програмою.
-
fork()створює породжений процес, майже точну копію батьківського: ті самі відкриті файли, той самий адресний простір, та сама точка виконання. Відрізняються тільки PID і те, що повертає самfork: батькові — PID дитини, дитині — нуль. -
execve()заміщує адресний простір процесу новою програмою. PID при цьому лишається тим самим, бо це той самий процес, який тепер виконує інший код. -
exit()завершує процес. Ядро звільняє пам’ять і закриває файли, але дескриптор поки лишає: у ньому код завершення, якого ще ніхто не прочитав. -
wait()у батька читає цей код і остаточно прибирає дескриптор.
Розділення на fork і exec здається надлишковим рівно доти, доки
не помітиш проміжок між ними, у якому дитина вже існує, але ще не стала
іншою програмою. Саме там оболонка робить перенаправлення: відкриває файл,
підміняє дескриптор 1 і аж тоді викликає exec. Тому ./program > out.txt
не вимагає від самої program жодної підтримки перенаправлення, усе
відбувається до її запуску.
Зомбі та сироти
Section titled “Зомбі та сироти”Ці два стани плутають, бо обидва звучать як «процес без нормального батька».
Зомбі-процес уже завершився, але його батько ще не викликав wait.
Пам’яті він не займає, від нього лишився один запис у таблиці процесів
із кодом завершення. Один зомбі нікому не заважає, а от тисяча означає
баг у батькові, який не прибирає за дітьми, і врешті вичерпає таблицю PID.
Убити зомбі неможливо, він і так мертвий, тож kill -9 на нього не діє
ніяк. Прибрати його можна, тільки змусивши батька зробити wait
або вбивши самого батька.
Процес-сирота — випадок протилежний: живий процес, чий батько помер першим.
Ядро негайно призначає йому нового батька, зазвичай PID 1. Оскільки init
завжди викликає wait, сироти прибираються коректно, і саме тому вбивство
батька лікує накопичення зомбі.
Сигнали
Section titled “Сигнали”Сигнал — найпростіший спосіб щось повідомити процесу: одне число, жодних даних. Процес може встановити обробник, проігнорувати сигнал або дати спрацювати типовій дії.
| Сигнал | Типова дія | Коли |
|---|---|---|
SIGINT (2) |
завершити | Ctrl+C |
SIGTERM (15) |
завершити | ввічливе прохання зупинитись — те, що шле kill без аргументів |
SIGKILL (9) |
завершити | примусово; не перехоплюється і не ігнорується |
SIGSEGV (11) |
завершити з дампом | звернення за недійсною адресою |
SIGCHLD (17) |
ігнорувати | дитина завершилась — сигнал для wait |
SIGSTOP / SIGCONT |
зупинити / продовжити | Ctrl+Z і fg |
Команду kill назвали невдало: вона нікого не вбиває, а надсилає сигнал,
за замовчуванням SIGTERM. Різниця тут істотна. SIGTERM процес може
перехопити й коректно закритися: дописати файл, закрити з’єднання, зняти
блокування. SIGKILL перехопити неможливо, тому після нього лишаються
недописані файли та незняті блокування. Тож починати варто завжди
з SIGTERM.
Як це насправді в Linux
Section titled “Як це насправді в Linux”Усе, що ядро знає про процеси, доступне як файли в /proc. Це не справжні файли —
procfs генерує вміст у момент читання.
ps -eo pid,ppid,stat,wchan:20,comm --sort=-pcpu | headSTAT — стан процесу: R виконується або готовий, S чекає (переривано),
D чекає (непереривано), Z зомбі, T зупинений. WCHAN показує, у якій
саме функції ядра процес заснув — для стану D це прямо називає причину.
cat /proc/self/status | head -20/proc/self — це завжди процес, який читає. Тут видно PID, PPID, стан,
кількість потоків і споживану пам’ять.
cat /proc/$$/mapsСекції адресного простору поточної оболонки: діапазон адрес, права (r-xp, rw-p),
і що саме за цими адресами відображено. Тут видно і код, і купу ([heap]),
і стек ([stack]), і всі підвантажені бібліотеки.
ls -l /proc/$$/fdТаблиця відкритих файлів: 0, 1, 2 — стандартні потоки. Саме ці числа
підміняє оболонка при перенаправленні.
strace -f -e trace=clone,execve,wait4 -- sh -c 'ls > /dev/null'Повний цикл у трьох викликах: clone (так реалізований fork), execve
у дитині, wait4 у батька. -f потрібен, щоб трасувати і дитину теж.
Створити зомбі й подивитись на нього:
sh -c 'sleep 0.3 & exec sleep 5' & sleep 1; ps -eo pid,ppid,stat,comm | grep -w sleepРядок зі станом Z і є зомбі: перший sleep завершився, а прибрати його нікому.
exec тут обов’язковий, і саме він робить приклад надійним. Без нього оболонка
лишається живою, і більшість оболонок встигають прибрати завершену фонову задачу
самі — зомбі не з’явиться. exec заміщає оболонку процесом sleep, який
про wait нічого не знає й не може прибрати нічого. Через п’ять секунд
він завершиться, зомбі осиротіє й дістанеться PID 1, який його й прибере.
Типові помилки розуміння
Section titled “Типові помилки розуміння”«fork копіює пам’ять процесу». Не копіює. Сторінки позначаються
доступними тільки для читання й копіюються поштучно, коли в них пишуть.
Тому fork великого процесу коштує майже стільки ж, скільки fork малого.
«Зомбі жере ресурси, треба терміново вбивати». У зомбі немає ні пам’яті, ні процесорного часу, лише запис у таблиці. Проблема не в окремому зомбі, а в батькові, який їх накопичує. Та й убити зомбі неможливо, він уже мертвий.
«Процес у стані D завис, зроблю kill -9». SIGKILL теж доставляється
через перевірку сигналів, а процес у непереривному сні до цієї перевірки
просто не доходить. Зі стану D він вийде, коли завершиться операція
вводу-виводу, якої він чекає. Або не вийде ніколи, якщо диск не відповідає.
«ps не показує процес, отже його немає». Порт може тримати дитина,
якої ви не помітили, або процес у стані, що не потрапив у ваш фільтр.
ss -lptn покаже, який PID тримає сокет, і це надійніше за ps | grep.
«PID унікальний назавжди». PID переприсвоюються після завершення
процесу, тож зберігати PID і через хвилину слати за ним сигнал — це гонитва:
за тим числом може вже бути хтось інший. Для таких випадків існують
cgroups і pidfd.
Перевір себе
Лабораторна
Section titled “Лабораторна”A1 — власний shell. fork, exec, wait, конвеєри, перенаправлення,
керування задачами. Прямо застосовує все з цього модуля: проміжок між fork
і exec, підміну дескрипторів 0/1/2, прибирання дітей через wait.
B2 — mini-top. Читання /proc, розбір status і stat, обчислення
завантаження процесора за різницею лічильників між двома вимірами.
Джерела
Section titled “Джерела”- OSTEP, розділи 4–5 — Abstraction: The Process, Process API
- Silberschatz, Operating System Concepts, розділ 3
- Таненбаум, Modern Operating Systems, розділ 2.1
man 2 fork,man 2 execve,man 2 wait,man 7 signal,man 5 proc- Documentation/filesystems/proc.rst — що саме означає кожне поле