Перейти до вмісту

Процеси

Ви запускаєте ./server, він падає, ви перезапускаєте і отримуєте bind: address already in use, хоча ps не показує жодного server. Ви натискаєте Ctrl+C, а процес не помирає. Робите kill -9, і він усе одно висить у списку зі станом Z.

У кожної з цих ситуацій є точне пояснення, і всі три впираються в одне: що саме операційна система вважає процесом, які в нього бувають стани і хто прибирає після його смерті. Про це й піде мова — від fork до запису в таблиці, який хтось мусить прочитати.

Передумови. Уявлення про переривання і таймер (модуль 2) та про межу між простором користувача і простором ядра (модуль 3).

Програма є файлом на диску, послідовністю байтів у форматі, який ядро вміє завантажувати; у Linux це ELF. Вона цілком пасивна й сама по собі нічого не робить.

Процес — це програма у стані виконання разом з усім, що ядро для неї завело: адресний простір, відкриті файли, поточний каталог, права, лічильник команд, стек. З одного файлу /usr/bin/python3 може працювати сорок процесів одночасно, і всі сорок будуть незалежні.

Кожен процес бачить власну пам’ять і не бачить чужої. Те, що він бачить, поділене на секції з різними правами доступу.

Секції адресного простору процесу: код, дані, купа, вільні адреси, стек 0x0000…0xFFFF…код (text)читання + виконаннядані (data, bss)глобальні та статичнікупа (heap)malloc, росте вниз ↓незакріплені адресистек (stack)кадри викликів, росте вгору ↑купастекзустрілися — переповнення стека або відмова malloc
Класичне розташування секцій. Реальний адресний простір складніший: між секціями стоять сторінки без прав доступу, а базові адреси зсунуті випадково (ASLR, модуль 16).
  • Код (text) містить інструкції. Його можна читати й виконувати, але не записувати: спроба переписати власний код завершує процес.
  • Дані (data, bss) — це глобальні та статичні змінні. У data лежать ті, що мають початкове значення, у bss — ті, що починаються з нуля і тому не займають місця у файлі.
  • Купа (heap) — динамічна пам’ять, яку віддає malloc. Росте в бік старших адрес.
  • Стек зберігає кадри викликів функцій і локальні змінні, а росте назустріч купі.

Порожнеча між купою і стеком — це не вільна пам’ять процесу, як може здатися. Це адреси, за якими нічого не закріплено: звернення туди дає сторінковий виняток (page fault), обслужити який ядро не може, і процес отримує SIGSEGV.

Усе, що ядро знає про процес, лежить в одній структурі — дескрипторі процесу, або process control block. У Linux це struct task_struct, і структура ця чимала, кілька сотень полів. Основні групи такі:

Група Що містить Навіщо
Ідентифікація PID, PPID, UID, GID хто це і від чийого імені працює
Стан поточний стан, код завершення що з ним робити далі
Контекст лічильник команд, регістри, вказівник стека щоб відновити виконання після перемикання
Пам’ять вказівник на адресний простір, межі секцій щоб MMU знав, що йому транслювати
Ввід-вивід таблиця відкритих файлів, робочий каталог щоб read(3, …) знав, що таке «3»
Планування пріоритет, nice, спожитий час щоб планувальник знав, кого пускати
Зв’язки батько, діти, група процесів щоб побудувати дерево і доставляти сигнали

Живуть дескриптори в пам’яті ядра, а не процесу. Сам процес не може ні прочитати свій дескриптор напряму, ні підправити в ньому пріоритет, для цього існують системні виклики.

Діаграма станів процесу: створений, готовий, виконується, заблокований, завершений прийнятодиспетчеризаціяexit()витіснення: квант вичерпанозапит В/Вподія насталастворенийготовийвиконуєтьсязавершенийзаблокованийна одному ядрі стан «виконується» має рівно один процес
Готовий і заблокований — принципово різні очікування. Готовий чекає на процесор і піде виконуватись, щойно його пустять. Заблокований не піде, навіть якщо процесор простоює.

Найпоширеніша помилка в цій темі — плутати «готовий» і «заблокований». Різниця тут суто практична. Якщо система гальмує, а процеси стоять у стані «готовий», не вистачає процесора. Якщо ж вони заблоковані, процесор ні до чого, і вузьке місце шукайте в диску або мережі.

Перехід із «виконується» в «готовий» називають витісненням: спрацював таймер, квант вичерпано, ядро забрало процесор. Нічого поганого процес при цьому не зробив і нічого не чекає, його просто поставили в чергу.

Контекстне перемикання

Section titled “Контекстне перемикання”

Щоб зняти процес з ядра процесора й поставити інший, ядро зберігає регістри першого в його дескриптор, завантажує регістри другого і перемикає адресний простір. Це контекстне перемикання, і безкоштовним воно не буває.

Пряма вартість вимірюється одиницями мікросекунд, а от непряма більша. Новий процес приходить на холодні кеші й порожній TLB, тому перші тисячі його інструкцій виконуються повільніше, ніж могли б. Через це квант часу й не роблять дуже малим: чим частіші перемикання, тим більша частка процесора йде на обслуговування самих перемикань.

У Unix процес не створюється з нуля. Він клонується з наявного, а потім, за потреби, заміщує свій образ іншою програмою.

  1. fork() створює породжений процес, майже точну копію батьківського: ті самі відкриті файли, той самий адресний простір, та сама точка виконання. Відрізняються тільки PID і те, що повертає сам fork: батькові — PID дитини, дитині — нуль.

  2. execve() заміщує адресний простір процесу новою програмою. PID при цьому лишається тим самим, бо це той самий процес, який тепер виконує інший код.

  3. exit() завершує процес. Ядро звільняє пам’ять і закриває файли, але дескриптор поки лишає: у ньому код завершення, якого ще ніхто не прочитав.

  4. wait() у батька читає цей код і остаточно прибирає дескриптор.

Розділення на fork і exec здається надлишковим рівно доти, доки не помітиш проміжок між ними, у якому дитина вже існує, але ще не стала іншою програмою. Саме там оболонка робить перенаправлення: відкриває файл, підміняє дескриптор 1 і аж тоді викликає exec. Тому ./program > out.txt не вимагає від самої program жодної підтримки перенаправлення, усе відбувається до її запуску.

Ці два стани плутають, бо обидва звучать як «процес без нормального батька».

Зомбі-процес уже завершився, але його батько ще не викликав wait. Пам’яті він не займає, від нього лишився один запис у таблиці процесів із кодом завершення. Один зомбі нікому не заважає, а от тисяча означає баг у батькові, який не прибирає за дітьми, і врешті вичерпає таблицю PID.

Убити зомбі неможливо, він і так мертвий, тож kill -9 на нього не діє ніяк. Прибрати його можна, тільки змусивши батька зробити wait або вбивши самого батька.

Процес-сирота — випадок протилежний: живий процес, чий батько помер першим. Ядро негайно призначає йому нового батька, зазвичай PID 1. Оскільки init завжди викликає wait, сироти прибираються коректно, і саме тому вбивство батька лікує накопичення зомбі.

Сигнал — найпростіший спосіб щось повідомити процесу: одне число, жодних даних. Процес може встановити обробник, проігнорувати сигнал або дати спрацювати типовій дії.

Сигнал Типова дія Коли
SIGINT (2) завершити Ctrl+C
SIGTERM (15) завершити ввічливе прохання зупинитись — те, що шле kill без аргументів
SIGKILL (9) завершити примусово; не перехоплюється і не ігнорується
SIGSEGV (11) завершити з дампом звернення за недійсною адресою
SIGCHLD (17) ігнорувати дитина завершилась — сигнал для wait
SIGSTOP / SIGCONT зупинити / продовжити Ctrl+Z і fg

Команду kill назвали невдало: вона нікого не вбиває, а надсилає сигнал, за замовчуванням SIGTERM. Різниця тут істотна. SIGTERM процес може перехопити й коректно закритися: дописати файл, закрити з’єднання, зняти блокування. SIGKILL перехопити неможливо, тому після нього лишаються недописані файли та незняті блокування. Тож починати варто завжди з SIGTERM.

Усе, що ядро знає про процеси, доступне як файли в /proc. Це не справжні файли — procfs генерує вміст у момент читання.

Terminal window
ps -eo pid,ppid,stat,wchan:20,comm --sort=-pcpu | head

STAT — стан процесу: R виконується або готовий, S чекає (переривано), D чекає (непереривано), Z зомбі, T зупинений. WCHAN показує, у якій саме функції ядра процес заснув — для стану D це прямо називає причину.

Terminal window
cat /proc/self/status | head -20

/proc/self — це завжди процес, який читає. Тут видно PID, PPID, стан, кількість потоків і споживану пам’ять.

Terminal window
cat /proc/$$/maps

Секції адресного простору поточної оболонки: діапазон адрес, права (r-xp, rw-p), і що саме за цими адресами відображено. Тут видно і код, і купу ([heap]), і стек ([stack]), і всі підвантажені бібліотеки.

Terminal window
ls -l /proc/$$/fd

Таблиця відкритих файлів: 0, 1, 2 — стандартні потоки. Саме ці числа підміняє оболонка при перенаправленні.

Terminal window
strace -f -e trace=clone,execve,wait4 -- sh -c 'ls > /dev/null'

Повний цикл у трьох викликах: clone (так реалізований fork), execve у дитині, wait4 у батька. -f потрібен, щоб трасувати і дитину теж.

Створити зомбі й подивитись на нього:

Terminal window
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.

Перевір себе

1. Процес у стані «готовий» і процес у стані «заблокований» — обидва не виконуються. У чому практична різниця?
2. Що повертає fork() у породженому процесі?
3. Чому оболонка робить fork і exec окремими викликами, а не одним?
4. Процес висить у ps зі станом Z. Що зробить kill -9?
5. Чим SIGKILL принципово відрізняється від SIGTERM?
6. Чому контекстне перемикання коштує більше, ніж збереження й відновлення регістрів?

A1 — власний shell. fork, exec, wait, конвеєри, перенаправлення, керування задачами. Прямо застосовує все з цього модуля: проміжок між fork і exec, підміну дескрипторів 0/1/2, прибирання дітей через wait.

B2 — mini-top. Читання /proc, розбір status і stat, обчислення завантаження процесора за різницею лічильників між двома вимірами.