Тема 07

Процессы и управление заданиями

Процессы и управление заданиями

Предыдущие темы показали работу с файлами, текстом, правами и потоками, но команды выполнялись как будто мгновенно. Что происходит внутри системы между нажатием Enter и появлением приглашения, рассмотрим сейчас. Программа, которая выполняется, — это процесс (англ. process). Каждая команда, скрипт и сетевой сервер — процессы. Умение видеть процессы, понимать их состояния и посылать им сигналы нужно для диагностики: зависшая программа или забитый процессор решаются именно этими средствами. Вводное изложение дано в учебнике Шоттса 2.

Примеры выполнялись в Bash на Ubuntu (WSL2). Вывод, зависящий от состояния системы (номера процессов, время, набор программ), приведён реальным; сокращения оговорены в тексте. Для учебных экспериментов в лабораторной работе используются четыре программы из окружения: worker.sh, zombie.py, cpu_hog.sh и openmany.py.

Программа и процесс

Что такое процесс

Программа (англ. program) — это файл на диске: набор машинных инструкций и данных, например /usr/bin/sleep. Пока программа лежит на диске, она ничего не делает. Процесс (англ. process) — это программа, загруженная в память и выполняющаяся. Одну и ту же программу можно запустить несколько раз: тогда будет несколько процессов с одним и тем же исполняемым файлом, но разной памятью, разными открытыми файлами и разным состоянием.

Ядро операционной системы ведёт таблицу процессов: для каждого процесса в ней хранятся идентификатор, владелец, состояние, открытые файлы, объём памяти и родитель. Пользовательские программы получают эти сведения через системные вызовы, а утилиты ps и pstree читают их из каталога /proc. Такой подход типичен для UNIX: состояние системы доступно как текст, что соответствует философии «текст как интерфейс» (тема 1).

Для команды sleep это хорошо видно: программа одна, процессов может быть много.

sleep 900 &
sleep 901 &
ps -o pid,stat,comm,args -C sleep
    PID STAT COMMAND         COMMAND
   9156 S    sleep           sleep 900
   9157 S    sleep           sleep 901

Оба процесса выполняют один и тот же исполняемый файл /usr/bin/sleep, но имеют разные номера и разные аргументы. Поэтому при разборе системы важно смотреть не только на имя программы, но и на полную командную строку (колонка args в примере выше).

Идентификаторы процесса

Каждому процессу ядро присваивает целое число — идентификатор процесса, PID (англ. process identifier). Номера выдаются по возрастанию, и после перезагрузки счёт начинается заново. Номер уникален только в данный момент: после завершения процесса его номер через некоторое время может получить другой процесс. Поэтому скрипты, которые сохраняют PID в файл, должны учитывать, что процесс мог уже завершиться.

Кроме собственного номера, у процесса есть номер родителя — PPID (англ. parent process identifier). Родитель — это процесс, который создал данный процесс. Так образуется дерево: каждый процесс, кроме самого первого, имеет ровно одного родителя. Оболочка, в которой вы набираете команды, — родитель всех запущенных из неё программ. Сведения о процессах удобно смотреть вместе: ps -o pid,ppid,comm выводит оба номера.

Текущая оболочка знает свой номер в переменной $$, а номер последнего фонового процесса — в переменной $!. Эти переменные полезны в скриптах, поскольку позволяют отправить сигнал именно нужному процессу.

sleep 902 &
echo "PID оболочки: $$"
echo "PID фонового sleep: $!"
ps -o pid,ppid,stat,comm -p $$,$!
PID оболочки: 9155
PID фонового sleep: 9159
  PID  PPID STAT COMMAND
 9155  9152 S    bash
 9159  9155 S    sleep

Колонка PPID у фонового sleep равна PID оболочки: именно оболочка создала процесс. Номер родителя самой оболочки (9152) — процесс, который её породил, например эмулятор терминала или сервер удалённого входа. Номера в примере условные: при каждом запуске они различаются.

Дерево процессов

Утилита pstree строит дерево наглядно. Флаг -p добавляет PID в скобках. Для проверки воспроизведём ситуацию, в которой оболочка запустила конвейер из двух программ и ещё одну фоновую команду.

sleep 1101 | sleep 1102 &
sleep 1103 &
pstree -p $$
bash(11431)─┬─pstree(11435)
            ├─sleep(11432)
            ├─sleep(11433)
            └─sleep(11434)

Все четыре дочерних процесса принадлежат оболочке bash с номером 11431. Команда pstree сама является её дочерним процессом (11435). Два процесса конвейера sleep 1101 | sleep 1102 выглядят в дереве как два независимых ребёнка оболочки, но по колонке PGID в следующем выводе видно, что они образуют одну группу процессов: номер группы у обоих равен 11432 — PID первого процесса конвейера.

ps -o pid,ppid,pgid,sid,stat,tty,comm --ppid $$
  PID  PPID  PGID   SID STAT TT       COMMAND
11432 11431 11432 11431 S    pts/5    sleep
11433 11431 11432 11431 S    pts/5    sleep
11434 11431 11434 11431 S    pts/5    sleep
11516 11431 11516 11431 R+   pts/5    ps

Группа процессов (англ. process group) — набор процессов, которыми оболочка управляет как единым целым: сигнал можно отправить всей группе, а управление терминалом передаётся группе целиком. Конвейер — это группа, поэтому Ctrl+C прерывает весь конвейер. Колонка SID показывает сеанс (англ. session) — набор групп, связанных с одним терминалом. Группа переднего плана отмечена знаком + в колонке STAT.

Заметим, что процесс ps в последней строке имеет состояние R+: он выполняется прямо сейчас (R) и находится в группе переднего плана (+). Смысл букв состояния рассмотрен ниже.

Процесс init и происхождение процессов

Если идти по цепочке родителей вверх, придём к процессу с номером 1. В классических системах UNIX и в Ubuntu это init (в Ubuntu — systemd), который ядро запускает первым. Он порождает системные службы и становится приёмным родителем для «осиротевших» процессов, о которых пойдёт речь ниже.

В среде WSL2, где выполнялись примеры, процесс 1 имеет другой вид: он называется /init и запускается инфраструктурой подсистемы, а не systemd. Вторая колонка COMMAND (args) содержит полную командную строку процесса.

ps -p 1 -o pid,comm,args
  PID COMMAND         COMMAND
    1 init(Ubuntu-24. /init

Название init(Ubuntu-24. в колонке COMMAND обрезано ядром до 15 символов, поэтому полная строка /init видна только в колонке args. Это частая особенность: короткое имя процесса (comm) не всегда совпадает с командной строкой (args), и при поиске нужного процесса следует смотреть на обе колонки.

Создание процессов: fork, exec и wait

Как оболочка запускает программу

Когда в терминале набрана команда ls -l и нажата клавиша Enter, оболочка должна выполнить /usr/bin/ls и дождаться завершения. Ядро не предоставляет единого вызова «запустить программу» как основного механизма UNIX. Вместо этого используются два более простых действия, заложенных ещё в ранних версиях системы 1. Первое — fork («раздвоение»): текущий процесс создаёт почти точную копию. Второе — exec («замена»): процесс заменяет свою программу на другую, загруженную из файла. Оболочка выполняет fork, в ребёнке — exec, а родитель ждёт завершения ребёнка.

Схема на рисунке показывает эту последовательность для команды ls -l в оболочке.

Схема fork, exec и wait: оболочка создаёт копию, копия заменяет программу на ls, оболочка ждёт завершения
Порождение процесса: fork создаёт копию оболочки, exec заменяет её программой ls, родитель ожидает завершения

После fork у ребёнка те же открытые файлы, переменные окружения и текущий каталог, что и у родителя. Поэтому перенаправление, которое оболочка задала до запуска команды (тема 5), действует и в ребёнке. Замена программы через exec сохраняет PID, открытые файлы и окружение: меняется только код, поэтому в ps процесс не получает нового номера.

Трассировка системных вызовов

Наблюдать эту последовательность можно напрямую: утилита strace выводит системные вызовы процесса. Ограничим вывод вызовами создания процессов и ожидания их завершения. Команда bash -c с двумя командами через точку с запятой запускает ls в отдельном процессе, поскольку только так видны fork и exec.

strace -f -qq -e trace=clone,clone3,execve,wait4,exit_group bash -c 'ls /etc/hostname; echo done' 2>&1 | grep -v resumed
execve("/usr/bin/bash", ["bash", "-c", "ls /etc/hostname; echo done"], 0x7fff37eadd30 /* 48 vars */) = 0
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x706b82eb7a10) = 9105
[pid 9104] wait4(-1,  <unfinished ...>
[pid 9105] execve("/usr/bin/ls", ["ls", "/etc/hostname"], 0x592988c86190 /* 48 vars */) = 0
/etc/hostname
[pid 9105] exit_group(0)               = ?
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=9105, si_uid=1000, si_status=0, si_utime=0, si_stime=1} ---
wait4(-1, 0x7ffec14b2ad0, WNOHANG, NULL) = -1 ECHILD (No child processes)
done
exit_group(0)                           = ?

Трасса показывает весь сценарий. Процесс 9104 (оболочка) выполняет clone, появляется ребёнок 9105. В нём вызывается execve с программой /usr/bin/ls. Оболочка вызывает wait4 и блокируется, пока ребёнок не завершится; ядро посылает ей SIGCHLD, и wait4 возвращает код завершения. Последний wait4 с флагом WNOHANG возвращает ECHILD: детей больше нет. Строка done — вывод второй команды, которую оболочка выполнила сама.

Ожидание завершения и код возврата

Вызов wait (в виде системного вызова wait4) выполняет две задачи. Во-первых, родитель узнаёт, что ребёнок завершился. Во-вторых, родитель забирает код возврата ребёнка — число, которое программа передала при завершении. По соглашению, код 0 означает успех, а любой другой код — ошибку. Именно этот код оболочка сохраняет в переменной $?, и на нём основаны операторы && и || (тема 5).

ls /etc/hostname; echo "код: $?"
ls /нет/такого/каталога; echo "код: $?"
/etc/hostname
код: 0
ls: cannot access '/нет/такого/каталога': No such file or directory
код: 2

Код 2 — стандартный код ошибки утилиты ls при неверном аргументе. Текст сообщения здесь английский, поскольку в примере использована английская локаль; на русской системе сообщение будет переведено, а код останется тем же. Сам процесс-ребёнок сообщил о нём через код завершения, и оболочка получила его через wait. Заметим, что сообщение об ошибке ушло в поток stderr, а код возврата — отдельный канал информации. Оба механизма нужны для диагностики.

Если родитель не вызывает wait, код возврата ребёнка не забирается, и ребёнок остаётся в таблице процессов в виде зомби. Эта ситуация будет подробно рассмотрена в разделе о состояниях процесса, а в лабораторной работе зомби создаётся намеренно.

Фоновый запуск и конвейеры

Знак & в конце команды сообщает оболочке, что не нужно ждать завершения процесса: оболочка выполняет fork, в родителе сразу печатает приглашение, а ребёнок работает в фоне (background). Номер последнего фонового процесса доступен через $!. Для конвейера оболочка создаёт по процессу на каждую команду; все они выполняются одновременно и связаны каналами (тема 5).

Фоновый процесс остаётся дочерним процессом оболочки. Пока оболочка жива, она получает SIGCHLD и вызывает wait. Если же оболочка завершилась раньше фонового процесса, тот становится сиротой и переходит к процессу 1. Так объясняется, почему задания иногда «исчезают» при закрытии терминала, — см. раздел «Управление заданиями».

Состояния процессов и наблюдение

Состояния процесса

В каждый момент процесс находится в одном из состояний. Состояние определяет, получает ли он процессор и чего он ждёт. Ядро переводит процесс из состояния в состояние по событиям: процесс запросил данные с диска, пришёл сигнал, процесс завершился. Основные состояния, которые встречаются в выводе ps и top, перечислены в таблице.

БукваНазваниеСмыслПример
Rrunning (выполняется или готов к выполнению)процесс выполняется на процессоре или стоит в очереди на негоцикл cpu_hog.sh, занятый вычислениями
Ssleeping (ожидание прерываемое)процесс ждёт события (ввод с клавиатуры, сеть, таймер) и может быть прерван сигналомsleep, сервер, ожидающий подключения
Duninterruptible sleep (ожидание непрерываемое)процесс ждёт завершения операции ввода-вывода и не может быть прерван даже сигналом SIGKILLчтение с зависшего сетевого диска
Tstopped (остановлен)процесс приостановлен сигналом SIGSTOP, SIGTSTP или при фоновом обращении к терминалузадание, остановленное Ctrl+Z
Zzombie (зомби)процесс завершился, но родитель ещё не забрал его код возвратадочерний процесс, которого не «дождались»

Состояние D заслуживает особого внимания. Процесс в нём не реагирует даже на SIGKILL, потому что ждёт завершения операции с диском, которую нельзя отменить. Долгое пребывание в D — признак проблемы с устройством или файловой системой. В учебной среде это состояние трудно воспроизвести, поэтому в лабораторной работе оно не используется; описание дано по ps 7.

Процесс в состоянии S не потребляет процессор, поэтому большинство процессов обычной системы находятся именно в нём. Подсчитаем первую букву колонки STAT для всех процессов текущей машины.

ps -eo stat= | cut -c1 | sort | uniq -c
      1 R
     40 S

В момент измерения на процессор претендовал один процесс — сама команда ps, — а сорок процессов ждали событий. Такая картина типична для простаивающей системы: процессор почти не занят, и большая часть процессов спит.

Переходы между состояниями показаны на рисунке.

Диаграмма состояний процесса: R, S, D, T, Z и события, которые переводят процесс из одного состояния в другое
Состояния процесса и события, вызывающие переходы между ними

Состояние T возникает при сигналах SIGTSTP (Ctrl+Z) и SIGSTOP. Проверим второй случай: приостановим фоновый процесс sleep сигналом SIGSTOP и вернём его сигналом SIGCONT.

sleep 910 &
P=$!
kill -STOP $P; sleep 0.2; ps -o pid,stat,comm -p $P
kill -CONT $P; sleep 0.2; ps -o pid,stat,comm -p $P
kill $P
  PID STAT COMMAND
17052 T    sleep
  PID STAT COMMAND
17052 S    sleep

После SIGSTOP процесс перешёл в состояние T, после SIGCONT вернулся в S: остановка не сбрасывает его состояние ожидания.

Колонка STAT в ps aux

Команда ps aux — самый распространённый способ увидеть все процессы системы. Её заголовок содержит колонку STAT, в которой, помимо основной буквы состояния, могут стоять дополнительные символы. Пример строки заголовка и строки процесса:

ps aux | head -1
ps aux | grep -E " 17067 " | grep -v grep
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
student     17067  0.0  0.0   3216  1792 ?        S    13:58   0:00 sleep 910

Колонки: USER — владелец; PID — номер; %CPU и %MEM — доля процессорного времени и памяти; VSZ и RSS — виртуальный размер и объём в физической памяти (в килобайтах); TTY — терминал (знак ? означает его отсутствие); START и TIME — время запуска и накопленное процессорное время; COMMAND — командная строка.

Дополнительные символы в STAT читаются так: s — процесс является лидером сеанса (обычно это оболочка); + — процесс находится в группе переднего плана терминала; < — повышенный приоритет; N — пониженный приоритет (значение nice больше нуля, см. раздел о приоритетах); l — процесс многопоточный. Так, строка Ss у оболочки означает «спящая, лидер сеанса», а R+ у команды ps — «выполняется, на переднем плане».

Зомби и сироты

Зомби (англ. zombie) — процесс, который уже завершился, но запись о нём осталась в таблице процессов, пока родитель не вызвал wait. Он не потребляет процессор и память, но занимает место в таблице; при большом их количестве ядро не может создавать новые процессы. Сигнал мёртвому процессу бесполезен, поэтому устранить зомби можно только одним способом: завершить родителя (или заставить его вызвать wait). Тогда зомби передаётся процессу-приёмному родителю, который забирает своих детей и убирает зомби. Этот приём используется в лабораторной работе.

Сирота (англ. orphan) — процесс, родитель которого завершился раньше него. Сирота не становится зомби: ядро передаёт его процессу-приёмному родителю, которым в Ubuntu служит процесс 1, а в WSL2 — /init. Разница между зомби и сиротой важна: зомби уже мёртв и ждёт, пока родитель его заберёт; сирота жив и работает.

Проверим создание зомби с помощью учебной программы zombie.py из окружения лабораторной работы. Родитель создаёт дочерний процесс, тот сразу завершается, а родитель спит и не вызывает wait.

python3 zombie.py 120 &
sleep 1
ps -o pid,ppid,stat,comm --ppid $!
  PID  PPID STAT COMMAND
22310 22306 Z    python3 <defunct>

Запись <defunct> в колонке COMMAND означает, что процесс мёртв; состояние Z подтверждает это. Родитель (PID 22306) продолжает спать, поэтому зомби остаётся. Когда родитель завершится, зомби исчезнет:

kill -TERM 22306
sleep 1
ps -o pid,ppid,stat,comm -p 22310
  PID  PPID STAT COMMAND

Пустой вывод означает, что процесса 22310 больше нет. Отправка сигнала самому зомби (например, kill -9 22310) не даёт результата: команда kill возвращает код 0, но зомби продолжает стоять в таблице, так как уже мёртв. Именно это различие проверяется в лабораторной работе.

Инструменты наблюдения

Для наблюдения используются утилиты ps (снимок состояния), top (обновляемая картина с сортировкой по нагрузке), pgrep (поиск PID по шаблону) и lsof (открытые файлы). Состояния процессов в UNIX и Linux общие для всех систем этого семейства 4.

Самый прямой источник сведений — каталог /proc: виртуальная файловая система, которую ядро формирует при чтении. Для каждого процесса в /proc/<pid>/ есть файл status с состоянием, cmdline с командной строкой (аргументы разделены нулевыми байтами), каталог fd с открытыми файлами и ссылка exe на исполняемый файл. Все файлы описаны в справочной странице proc(5) 7.

sleep 910 &
P=$!
sleep 0.3
grep -E '^(Name|State|Tgid|Pid|PPid|Uid|Threads|VmRSS):' /proc/$P/status
tr '\0' ' ' < /proc/$P/cmdline; echo
Name:	sleep
State:	S (sleeping)
Tgid:	17067
Pid:	17067
PPid:	17066
Uid:	1000	1000	1000	1000
VmRSS:	    1792 kB
Threads:	1
sleep 910 

Поля Pid и Tgid совпадают, потому что процесс однопоточный; у многопоточной программы Tgid — номер группы потоков. Колонка Uid содержит идентификаторы пользователя и по ней определяется владелец. Команда ls -l /proc/<pid>/fd показывает открытые файлы процесса: стандартные потоки 0, 1 и 2 из темы 5. У процесса, запущенного из интерактивной оболочки без перенаправлений, все три ссылки указывают на терминал, например /dev/pts/0; при перенаправлении ссылка указывает на соответствующий файл.

Утилита top в пакетном режиме (-b) выводит одну итоговую картину и полезна для скриптов. Строки заголовка показывают общую загрузку, а таблица — процессы с наибольшей нагрузкой. Ниже приведён фрагмент вывода во время работы cpu_hog.sh.

./cpu_hog.sh 25 &
sleep 2
top -b -n1 | head -8
top - 13:16:34 up 40 min,  0 users,  load average: 6.93, 2.74, 1.50
Tasks:  60 total,   2 running,  58 sleeping,   0 stopped,   0 zombie
%Cpu(s): 18.5 us,  5.4 sy,  0.0 ni, 64.1 id, 10.3 wa,  0.0 hi,  1.6 si,  0.0 st
MiB Mem :   7915.2 total,   5140.2 free,   1333.8 used,   1441.3 buff/cache
MiB Swap:   2048.0 total,   2048.0 free,      0.0 used.   6400.6 avail Mem 

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
23311 student      20   0    4788   3328   3072 R 100.0   0.0   0:02.37 bash

В строке Tasks видно число процессов в каждом состоянии, включая зомби (0 zombie). Строка %Cpu(s) разбивает загрузку процессора: us — пользовательские программы, sy — ядро, id — простой, wa — ожидание ввода-вывода. Процесс bash с номером 23311 — это cpu_hog.sh: его командная строка видна в ps, а top показывает имя исполняемого файла. Это и есть задача из лабораторной работы: найти пожирателя CPU.

Имя в колонке COMMAND не всегда совпадает с тем, что запущено: скрипт на Bash выполняется процессом bash, и имя скрипта видно только в колонке args.

Сигналы

Что такое сигнал

Сигнал (англ. signal) — это короткое асинхронное уведомление, которое ядро или другой процесс отправляет процессу. Сигнал не содержит данных в привычном смысле: он сообщает лишь номер события. Процесс получает сигнал в произвольный момент, поэтому его поведение при получении сигнала заранее определено одним из трёх способов: действие по умолчанию, перехват обработчиком или игнорирование.

Сигналы приходят из нескольких источников. Ctrl+C в терминале вызывает SIGINT, Ctrl+Z — SIGTSTP, ядро посылает SIGCHLD родителю при завершении ребёнка, команда kill отправляет любой сигнал по выбору пользователя. Посылать сигнал можно процессу своего пользователя; попытка отправить сигнал чужому процессу без прав завершается ошибкой «Operation not permitted».

Сигнал обозначают именем (SIGTERM) или номером (15); для x86-64 номера стандартизированы. Полный список выводит kill -l. В сокращённой записи начало списка выглядит так:

kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
...

Номера 32 и 33 зарезервированы библиотекой GNU C; сигналы реального времени (34 и далее) здесь не рассматриваются. Далее в тексте используются только сигналы из таблицы ниже.

Таблица основных сигналов

Таблица содержит сигналы, которые встречаются на практике: их действие по умолчанию, и можно ли перехватить сигнал программой. Описание взято из справочной страницы signal(7) 7.

СигналНомерДействие по умолчаниюПерехватываетсяТипичный источник и смысл
SIGHUP1завершениедазакрытие терминала или гибель управляющего процесса; у демонов — сигнал перечитать конфигурацию
SIGINT2завершениедаCtrl+C в терминале
SIGQUIT3завершение с дампом памятидаCtrl+\ в терминале
SIGKILL9завершениенетпринудительное завершение, которое нельзя перехватить
SIGUSR110завершениедасигнал, определённый самой программой
SIGSEGV11завершение с дампом памятидаобращение к недоступной памяти
SIGPIPE13завершениедазапись в канал, у которого нет читателя (тема 5)
SIGALRM14завершениедаистёк таймер
SIGTERM15завершениедавежливая просьба завершиться
SIGCHLD17игнорируетсядазавершился или остановился ребёнок
SIGCONT18продолжение (если процесс остановлен)давозобновление остановленного процесса
SIGSTOP19остановканетпринудительная остановка, которую нельзя перехватить
SIGTSTP20остановкадаCtrl+Z в терминале
SIGTTIN21остановкадафоновый процесс пытается читать с терминала
SIGTTOU22остановкадафоновый процесс пытается писать в терминал

Из таблицы видно главное правило: SIGKILL и SIGSTOP нельзя перехватить, заблокировать или проигнорировать. Это гарантия администратора, что системой можно управлять даже при неисправном процессе.

Строка SIGCHLD показывает, что завершение ребёнка не требует вмешательства: по умолчанию сигнал игнорируется, а оболочки перехватывают его, чтобы вызвать wait. Поэтому фоновые задания не остаются зомби, пока оболочка жива.

Перехват сигнала и поведение программы

Программа может заменить действие по умолчанию собственным обработчиком (англ. handler): при получении сигнала выполняется её код, после чего работа продолжается, например сохраняются данные или удаляются временные файлы. В скриптах Bash обработчики задаются командой trap (тема 15); в лабораторной работе они уже написаны в worker.sh. Устройство обработки сигналов в UNIX подробно описано в книге Керриска 3.

Игнорирование сигнала означает, что программа не реагирует на него вовсе. Оболочка делает это по умолчанию для фоновых команд, запущенных без управления заданиями, то есть из скрипта: в этом можно убедиться экспериментально. Ниже скрипт запускает worker.sh в фоне и посылает ему по очереди сигналы SIGINT, SIGQUIT и SIGUSR1; после каждого проверяется состояние процесса.

#!/usr/bin/env bash
$HOME/lab-07/lr-env/worker.sh >/tmp/int.log 2>&1 &
p=$!
sleep 1.5
kill -INT $p
sleep 1.2
echo "после SIGINT: $(ps -o stat= -p $p 2>/dev/null || echo нет процесса)"
kill -QUIT $p
sleep 1.2
echo "после SIGQUIT: $(ps -o stat= -p $p 2>/dev/null || echo нет процесса)"
kill -USR1 $p
sleep 1.2
echo "после SIGUSR1: $(ps -o stat= -p $p 2>/dev/null || echo нет процесса)"
после SIGINT: S
после SIGQUIT: S
/tmp/sigtest.sh: line 12: 21206 User defined signal 1   /home/student/lab-07/lr-env/worker.sh > /tmp/int.log 2>&1
после SIGUSR1: нет процесса

Сигналы SIGINT и SIGQUIT не завершили процесс: фоновое задание неинтерактивной оболочки получает их игнорируемыми. Сигнал SIGUSR1 завершил процесс, поскольку ни worker.sh, ни оболочка не назначили ему обработчик, а действие по умолчанию — завершение. Оболочка сообщила о «User defined signal 1» и кодом возврата 138 (128 + 10). Код выхода 128 плюс номер сигнала — стандартная запись для процессов, убитых сигналом.

В интерактивной оболочке с управлением заданиями картина иная: фоновое задание получает SIGINT с действием по умолчанию, и kill -INT завершает worker.sh с кодом 130 (128 + 2). Эта разница проверяется в лабораторной работе.

kill, pkill и killall

Команда kill отправляет сигнал по PID в виде kill [-СИГНАЛ] PID. Без имени сигнала отправляется SIGTERM. Записи kill -9 1234, kill -KILL 1234 и kill -s SIGKILL 1234 эквивалентны. Сигнал 0 не воздействует на процесс, но позволяет проверить, существует ли он.

Команда pkill ищет процессы по шаблону имени и отправляет им сигнал. Ключ -x требует точного совпадения имени, -f ищет шаблон в полной командной строке. Команда killall работает по точному имени программы и без явного шаблона отправит сигнал всем процессам с этим именем, включая те, которые не принадлежат задаче. Поэтому killall на общем сервере опасен.

Осторожность при pkill -f обязательна: шаблон сравнивается с полной командной строкой, и под удар может попасть сама оболочка, которая запустила pkill. Безопасный приём — сначала выполнить pgrep -a с тем же шаблоном и убедиться в списке совпадений.

Почему сначала отправляют SIGTERM, а не SIGKILL? SIGTERM даёт программе возможность аккуратно завершиться: сохранить данные, закрыть файлы, удалить временные каталоги. SIGKILL завершает процесс мгновенно, и всё незавершённое остаётся как есть: повреждённые файлы, неосвобождённые блокировки. Правило «сначала TERM, затем KILL» означает, что сильный сигнал применяется, только если мягкий не сработал за разумное время.

./worker.sh >/tmp/w.log 2>&1 &
W=$!
sleep 2.5
kill -TERM $W
wait $W; echo "код возврата: $?"
cat /tmp/w.log
код возврата: 0
worker.sh запущен, PID 20586
13:16:01  тик 1
13:16:02  тик 2
13:16:03  тик 3
[worker 20586] получен SIGTERM: очистка (1 с) и выход
[worker 20586] очистка завершена

Процесс завершился с кодом 0, а не 143: обработчик SIGTERM вызвал exit 0 после очистки. Если бы обработчика не было, код возврата был бы 143 (128 + 15), а сообщения очистки не появились бы вовсе. Тот же сигнал SIGKILL даёт код 137 и не оставляет следов в журнале: программа не получает возможности что-либо сообщить.

Управление заданиями в интерактивной оболочке

Фоновый запуск и список заданий

Интерактивная оболочка позволяет вести несколько программ одновременно: одна занимает терминал, другие работают в фоне. Набор процессов, которым оболочка управляет как единым целым, называется заданием (англ. job). Оболочка нумерует задания и отличает задание переднего плана от фоновых. Механизмы описаны в стандарте POSIX 8 и в руководстве по Bash 6.

Команда jobs выводит список заданий текущей оболочки. Задания нумеруются в квадратных скобках — это номер задания, а не PID; отдельно выводится PID процесса при флаге -l. Знак + отмечает задание, над которым работают fg и bg по умолчанию, знак - — предыдущее задание. Ниже запускается фоновый процесс и выводится список.

$ sleep 100 &
[1] 24166
$ jobs
[1]+  Running                 sleep 100 &

Задания остаются в списке, пока процессы работают. Номер задания используется в командах fg, bg, kill и wait в форме %N: %1 — первое задание, %% — текущее. Без процентного знака kill 1 отправил бы сигнал процессу init, поэтому внимательность при указании номера важна.

Ctrl+Z, fg и bg

Нажатие Ctrl+C отправляет SIGINT группе переднего плана и обычно прерывает программу. Ctrl+Z отправляет SIGTSTP: программа останавливается, но не завершается и может быть продолжена. Команда bg продолжает остановленное задание в фоне, fg переводит задание на передний план и отдаёт ему терминал.

Процесс, остановленный сигналом SIGTSTP, находится в состоянии T: он не потребляет процессор, но занимает память. Забытые остановленные задания — частая причина лишней нагрузки в длинной сессии, поэтому полезно проверять jobs. Ниже приведена сессия в интерактивной оболочке Bash в псевдотерминале: строки с приглашением $ введены вручную, остальные — вывод оболочки.

$ sleep 100 &
[1] 24166
$ jobs
[1]+  Running                 sleep 100 &
$ sleep 200
^Z
[2]+  Stopped                 sleep 200
$ jobs
[1]-  Running                 sleep 100 &
[2]+  Stopped                 sleep 200
$ bg %2
[2]+ sleep 200 &
$ jobs
[1]-  Running                 sleep 100 &
[2]+  Running                 sleep 200 &
$ fg %2
sleep 200
^C
$ jobs
[1]+  Running                 sleep 100 &

Команда sleep 200 была запущена на переднем плане, поэтому приглашение не возвращалось. Ctrl+Z перевёл её в состояние «Stopped». Команда bg %2 продолжила задание в фоне (знак & в выводе), а fg %2 вернула его на передний план; Ctrl+C его прервало. Задание 1 всё это время работало независимо.

Схема на рисунке показывает переходы задания между состояниями и клавиши, которые их вызывают.

Схема состояний задания: фон, передний план, остановлено; переходы по командам fg, bg, Ctrl+Z и Ctrl+C
Состояния задания: переходы между передним планом, фоном и остановкой

Фоновое задание, которое обращается к терминалу для чтения, получает SIGTTIN и останавливается, а при записи — SIGTTOU (см. таблицу сигналов). Это защита: двум программам нельзя одновременно отвечать на одни и те же нажатия клавиш. Фоновая программа, которой нужен ввод, переходит в состояние T и ждёт fg.

Отрыв от терминала: nohup и disown

Задание, запущенное в фоне, связано с терминалом. Если терминал закрыт или связь потеряна, ядро посылает управляющему процессу сеанса SIGHUP. Оболочка пересылает SIGHUP своим заданиям и завершается, поэтому фоновые задания гибнут вместе с терминалом. Это важно при работе по SSH, когда соединение может оборваться (тема 11).

Есть два способа защитить задание от SIGHUP. Команда nohup запускает программу с игнорируемым SIGHUP; её вывод при необходимости уходит в файл nohup.out 5. Команда disown удаляет задание из таблицы оболочки, и оболочка перестаёт посылать ему SIGHUP.

Проверим оба способа в одной интерактивной сессии. Процесс sleep 701 запускается обычным фоном, sleep 702 — фоном с последующим disown, sleep 703 — через nohup, sleep 704 — через nice. Затем оболочке посылается SIGHUP командой kill -HUP $$.

$ sleep 701 &
[1] 26939
$ sleep 702 &
[2] 26940
$ disown %2
$ nohup sleep 703 >/dev/null 2>&1 &
[2] 26941
$ nice -n 10 sleep 704 &
[3] 26942
$ jobs -l
[1]  26939 Running                 sleep 701 &
[2]- 26941 Running                 nohup sleep 703 > /dev/null 2>&1 &
[3]+ 26942 Running                 nice -n 10 sleep 704 &
$ echo shell=$$
shell=26928
$ kill -HUP $$

Команда disown %2 удалила sleep 702 из таблицы заданий, поэтому в jobs -l его нет. После отправки SIGHUP оболочка завершилась, а проверка процессов показала, что живы только два процесса:

pgrep -a -x sleep
26940 sleep 702
26941 sleep 703

Процессы sleep 701 и sleep 704 завершились: это обычные задания оболочки, и им переслан SIGHUP. Процесс sleep 702 выжил, потому что отделён командой disown, а sleep 703 защищён nohup, который делает SIGHUP игнорируемым. Заметим, что disown лишь убирает задание из-под управления оболочки, а nohup защищает от сигнала.

На практике для длинных задач используют терминальные мультиплексоры (тема 8) и службы systemd (тема 9), которые не зависят от сеанса входа.

Приоритеты и загрузка системы

Приоритеты: nice и renice

Процессор делит между процессами планировщик: процессы с более высоким приоритетом получают процессор чаще. Значение «любезности» (англ. niceness) меняется от -20 (наивысший приоритет) до 19 (наименьший); по умолчанию 0. Обычный пользователь может только понижать приоритет своих процессов; повышать — привилегия суперпользователя.

Команда nice запускает программу с заданным значением, а renice меняет значение у уже работающего процесса. Команда nice входит в набор GNU coreutils 5, а renice — в пакет util-linux. Ниже фоновому процессу sleep 1201 меняется приоритет с 0 на 5 командой renice, а затем проверяется результат.

sleep 1201 &
P1=$!
renice -n 5 -p "$P1"
ps -o pid,ni,stat,etime,args -p "$P1"
27348 (process ID) old priority 0, new priority 5
  PID  NI STAT     ELAPSED COMMAND
27348   5 SN         00:00 sleep 1201

Колонка NI показывает значение «любезности», а буква N в STAT — что приоритет понижен относительно нормы. Попытка уменьшить значение ниже текущего завершается ошибкой:

sleep 1301 &
renice -n -5 -p $!
renice: failed to set priority for 27351 (process ID): Permission denied

Приоритет полезен для фоновых расчётов, которые не должны мешать интерактивной работе: их запускают с nice -n 19. Приоритет не защищает от нагрузки на диск и память.

Загрузка системы: uptime и load average

Команда uptime показывает текущее время, время работы системы с момента загрузки, число вошедших пользователей и среднюю загрузку (load average). Средняя загрузка — это среднее число процессов, которые в данный момент выполняются (состояние R) или ждут ввода-вывода (состояние D), за последние 1, 5 и 15 минут.

uptime
cat /proc/loadavg
 13:58:50 up  1:22,  0 users,  load average: 0.20, 0.15, 0.49
0.20 0.15 0.49 2/308 17205

Файл /proc/loadavg содержит те же три числа, а также число готовых к работе процессов из общего числа (здесь 2 из 308) и последний выданный PID. Load average нужно сравнивать с числом ядер: на двенадцатиядерной системе значение 12 означает полную загрузку, а 1.0 — почти простой. Если значение за минуту заметно выше значения за 15 минут, нагрузка появилась недавно.

Load average учитывает и процессы в состоянии D. Поэтому высокая загрузка при почти свободном процессоре (строка wa в выводе top) означает, что система ждёт диск, а не вычисляет.

Для поиска процесса-«пожирателя» нагрузки удобно отсортировать список по колонке %CPU. В результате первая строка и содержит самого прожорливого потребителя процессора:

ps aux --sort=-%cpu | head -3
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
student     23311 79.0  0.0   4788  3328 ?        R    13:16   0:02 bash ./cpu_hog.sh 25

Строка с cpu_hog.sh в состоянии R и с наибольшим %CPU — это и есть искомый процесс. Заметим, что значение %CPU в ps aux — средняя доля процессорного времени за всю жизнь процесса, а в top — за последний интервал обновления. Для текущей картины нагрузки нужен top.

Память: free -h

Команда free показывает состояние оперативной памяти и области подкачки (swap). Ключ -h выводит величины в удобочитаемом виде (мегабайты и гигабайты).

free -h
               total        used        free      shared  buff/cache   available
Mem:           7.7Gi       1.2Gi       4.7Gi       3.0Mi       1.9Gi       6.4Gi
Swap:          2.0Gi          0B       2.0Gi

Самая важная колонка — available: объём памяти, который можно выделить новым программам без подкачки, с учётом освобождаемого кэша. Колонка free мала, потому что Linux использует свободную память под кэш файлов (buff/cache). Признак проблемы — маленький available и растущая колонка used в строке Swap: тогда система начинает подкачку, и быстродействие резко падает. Виновника ищут сортировкой ps aux --sort=-rss.

Ресурсы процесса и журнал ядра

До сих пор процесс рассматривался как объект, который можно увидеть и остановить. Остаётся вопрос, что процесс держит и сколько ему позволено. Он держит открытые файлы и сокеты, расходует память, а ядро ограничивает его пределами и в крайнем случае завершает, если памяти не хватает. Раздел собирает инструменты для этих задач: watch для повторяющегося наблюдения, lsof и /proc/PID/fd для открытых файлов, журнал ядра, OOM-killer и команды ulimit и prlimit.

watch: повторяющееся наблюдение

Многие проверки приходится повторять: растёт ли файл, исчез ли процесс, изменилась ли нагрузка. Команда watch из пакета procps запускает указанную команду заново через равные промежутки времени и показывает её вывод на весь экран. Команда задаётся одной строкой, поэтому конвейеры и подстановки заключают в кавычки: иначе их разберёт текущая оболочка, а не watch. Основные опции: -n секунды задаёт интервал (по умолчанию 2 секунды), -d подсвечивает символы, изменившиеся с прошлого запуска, -t убирает заголовок, -g завершает watch, когда вывод изменится. Прервать наблюдение можно сочетанием Ctrl+C 7.

watch -n 1 -d 'date +%S'

Ниже первый кадр: в первой строке интервал, команда, имя узла и время запуска, а ниже вывод команды. Имя узла заменено на host. Цифры, изменившиеся с прошлого кадра, терминал показывает в инверсном цвете; здесь это не видно.

Every 1.0s: date +%S                                host: Sun Oct 18 19:49:14 2026

14

Типичные применения: watch -n 1 'ps -o pid,stat,etime,comm -p PID' показывает, как меняется состояние процесса; watch -d 'ls -l /var/log/app.log' позволяет следить за ростом файла; watch -n 5 free -h показывает динамику памяти. Для обычного текстового потока вместо watch годится tail -f из темы 3, а watch удобнее, когда нужно перезапускать целую команду и сравнивать кадры. Так как watch занимает терминал и перерисовывает экран, в скриптах и в перенаправлении вывода в файл его не применяют: для скриптов служит цикл с sleep; циклы Bash рассматриваются в теме 14.

Открытые файлы: lsof и /proc/PID/fd

Каждый открытый файл в UNIX представлен числом, дескриптором: 0, 1 и 2 — стандартные потоки, следующие свободные номера получают файлы, которые процесс открыл сам. Сокеты, каналы и устройства тоже занимают дескрипторы, поэтому вопрос «что делает процесс» нередко сводится к вопросу «что у него открыто». Самый прямой ответ даёт каталог /proc/PID/fd, где каждый дескриптор — символическая ссылка на объект 7. Для веб-сервера, который слушает порт, там видны потоки, перенаправленные в /dev/null, и сокет.

python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &
H=$!
ls -l /proc/$H/fd
total 0
lr-x------ 1 student student 64 Oct 18 19:49 0 -> /dev/null
l-wx------ 1 student student 64 Oct 18 19:49 1 -> /dev/null
l-wx------ 1 student student 64 Oct 18 19:49 2 -> /dev/null
lrwx------ 1 student student 64 Oct 18 19:49 3 -> socket:[1368738]

Запись socket:[1368738] означает сокет с номером inode, но не говорит, на каком он порту. Эту информацию добавляет lsof (англ. list open files): он обходит /proc, собирает дескрипторы и показывает их в виде таблицы. Опция -p PID ограничивает вывод одним процессом, -i :порт ищет, кто использует сетевой порт, -u имя отбирает файлы пользователя, +D каталог рекурсивно находит процессы, держащие файлы внутри каталога. Опции -n и -P отключают преобразование адресов и портов в имена: вывод становится быстрее и однозначнее. Без прав root lsof видит только процессы текущего пользователя, чего достаточно для учебных задач.

lsof -i :8765
lsof -p $H | head -4
COMMAND   PID USER   FD   TYPE  DEVICE SIZE/OFF NODE NAME
python3 11520 student    3u  IPv4 1368868      0t0  TCP localhost:8765 (LISTEN)
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF   NODE NAME
python3 11520 student  cwd    DIR    8,48     4096 678196 /home/student/demo
python3 11520 student  rtd    DIR    8,48     4096      2 /
python3 11520 student  txt    REG    8,48  5941864 265033 /usr/bin/python3.10

Вывод получен в Ubuntu 22.04, поэтому исполняемый файл называется python3.10; в Ubuntu 24.04 там будет python3.12, а остальные колонки те же. Разберём колонки. FD — дескриптор с режимом доступа: 3u значит чтение и запись, 4r только чтение, 5w только запись; вместо числа бывают служебные записи cwd (текущий каталог), rtd (корневой каталог), txt (исполняемый файл) и mem (подгруженные библиотеки). TYPE — тип объекта: REG обычный файл, DIR каталог, IPv4 сетевой сокет. NODE для сокета содержит протокол и состояние, для файла — номер inode. NAME показывает путь или адрес с портом, а состояние LISTEN говорит, что процесс ожидает соединений. Команда lsof -i :8765 — первое средство при ошибке «Address already in use»: она называет процесс, который занял порт.

Вторая частая задача — понять, кто мешает размонтировать или удалить каталог. Команда lsof +D и родственная fuser -v показывают процессы, у которых внутри каталога что-то открыто.

mkdir d && cd d
sleep 300 > out.txt &
cd ..
lsof +D d
fuser -v d/out.txt
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF   NODE NAME
sleep   10931 student  cwd    DIR   8,48     4096 678643 /home/student/demo/d
sleep   10931 student    1w   REG   8,48        0 678644 /home/student/demo/d/out.txt
                     USER        PID ACCESS COMMAND
/home/student/demo/d/out.txt:
                     student      10931 F.... sleep

Процесс sleep держит текущий каталог (cwd) и файл out.txt на дескрипторе 1, куда оболочка перенаправила его вывод. Если бы каталог был точкой монтирования, размонтирование завершилось бы ошибкой «target is busy», а виновника назвали бы именно lsof и fuser; подробно — в теме 10.

Особый случай — удалённый, но открытый файл. Команда rm лишь удаляет имя из каталога; данные остаются на диске, пока их держит хотя бы один процесс. Типичная ситуация на сервере: диск заполнен гигабайтами журнала, администратор удалил файл, а свободного места не прибавилось, потому что программа продолжает в него писать. Признак в /proc и lsof — пометка (deleted).

exec 7>/home/student/demo/big.log
echo data >&7
rm /home/student/demo/big.log
ls -l /proc/$$/fd/7
lsof +L1 -a -p $$
exec 7>&-
l-wx------ 1 student student 64 Oct 18 19:49 /proc/10237/fd/7 -> /home/student/demo/big.log (deleted)
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NLINK   NODE NAME
bash    10237 student    7w   REG   8,48        5     0 579965 /home/student/demo/big.log (deleted)

Опция +L1 отбирает файлы, у которых меньше одной жёсткой ссылки (колонка NLINK равна 0): их имя удалено, а данные живы. Последняя команда exec 7>&- закрывает дескриптор, и только тогда ядро освобождает место. Для настоящего процесса есть два пути: перезапустить программу, чтобы она закрыла файл, или опустошить файл по ссылке : > /proc/PID/fd/N, не останавливая процесс. Как место на диске связано с inode и ссылками, рассказано в теме 10.

Журнал ядра: dmesg и journalctl -k

У ядра есть собственный журнал: кольцевой буфер фиксированного размера в памяти. Причина в том, что ядро начинает писать сообщения раньше, чем запускаются файловые системы, journald и вообще любой процесс. Сообщения об обнаружении оборудования, ошибках драйверов, подключении дисков и нехватке памяти нужно сохранить, даже если их некому принять. Когда буфер заполняется, новые сообщения вытесняют самые старые, поэтому давние события из dmesg исчезают. Уровни важности те же, что в journalctl -p: от emerg до debug.

Читает буфер команда dmesg. Каждая строка начинается со времени в секундах с момента запуска ядра. Опция -T переводит его в календарное время, -l (или --level) отбирает уровни, -x добавляет в строку название подсистемы и уровень, -w ожидает новые сообщения, подобно tail -f. В системах с включённым kernel.dmesg_restrict команду может выполнять только root. В Ubuntu 24.04 этот параметр по умолчанию равен 1, и без прав администратора dmesg отвечает dmesg: read kernel buffer failed: Operation not permitted; там журнал ядра читают командой sudo dmesg или journalctl -k (пользователю из группы adm она доступна без sudo). В среде, где получены примеры ниже (WSL2), ограничения нет.

dmesg -x --level=err,warn | head -4
kern  :warn  : [    0.143513] Speculative Return Stack Overflow: IBPB-extending microcode not applied!
kern  :warn  : [    0.143513] Speculative Return Stack Overflow: WARNING: See https://kernel.org/doc/html/latest/admin-guide/hw-vuln/srso.html for mitigation options.
kern  :err   : [    0.184144] PCI: Fatal: No config space access function found
kern  :warn  : [    0.216104] PCI: System does not support PCI

Предупреждения здесь не говорят о неисправности: WSL2 запускает ядро в виртуальной машине без реальной шины PCI. Это пример того, что сообщение уровня err надо читать, а не пугаться его. Опция -T в WSL2 и в виртуальных машинах может показать неверное время, так как пересчитывает время запуска ядра, а оно расходится с часами после приостановки машины; справочная страница прямо предупреждает, что результат может быть неточным. Надёжнее ориентироваться на секунды от запуска ядра.

Если в системе работает systemd, те же сообщения читает и journalctl -k (то есть --dmesg): преимущество в том, что журнал journald хранит их на диске, между перезагрузками, и позволяет применять уже знакомые фильтры -b, --since и -p 4. В WSL2 без systemd команда возвращает -- No entries --, поэтому там остаётся dmesg.

OOM-killer: что происходит при нехватке памяти

Ядро по умолчанию выдаёт процессам памяти больше, чем есть на самом деле: большинство программ запрашивают с запасом и используют лишь часть. Это называется перераспределением (англ. overcommit), режим задаётся файлом /proc/sys/vm/overcommit_memory. Пока процессы трогают страницы лишь изредка, схема работает. Когда они начинают реально использовать больше, чем есть оперативной памяти и подкачки, ядро не может ни выделить, ни ждать вечно. Тогда запускается OOM-killer (англ. out-of-memory killer): код ядра, который выбирает процесс и принудительно завершает его сигналом SIGKILL. Из состояния «нет памяти» это единственный выход, который сохраняет остальную систему живой 3.

Жертву выбирают по числу oom_score, которое ядро вычисляет для каждого процесса. Главный вклад — доля памяти процесса (резидентная, подкачанная и память под таблицы страниц) от общего объёма: чем больше процесс занимает, тем выше балл, максимум 1000. К расчёту прибавляется поправка oom_score_adj, число от -1000 до 1000, которое можно менять. Положительное значение делает процесс более вероятной жертвой, отрицательное защищает, а -1000 полностью исключает его из выбора. Оба значения доступны в /proc/PID/.

cat /proc/self/oom_score /proc/self/oom_score_adj
666
0

В этой среде у любого процесса базовый балл равен 666: он зависит от настроек WSL2 и других ядер может быть иным, поэтому важны относительные изменения. Покажем их. Запустим процесс, занимающий 400 МБ, затем изменим поправку.

python3 -c "
import os, time
b = bytearray(400*1024*1024)
for i in range(0, len(b), 4096): b[i] = 1
print(os.getpid(), flush=True); time.sleep(30)" > pid.txt &
sleep 2.5
P=$(cat pid.txt)
grep VmRSS /proc/$P/status
cat /proc/$P/oom_score /proc/$P/oom_score_adj
echo 300 > /proc/$P/oom_score_adj
cat /proc/$P/oom_score /proc/$P/oom_score_adj
echo -1000 > /proc/$P/oom_score_adj
VmRSS:	  418176 kB
694
0
894
300
bash: echo: write error: Permission denied

Занятые 400 МБ добавили к базовому баллу 28 единиц, поправка +300 — ровно 300. Обычный пользователь может повышать oom_score_adj своих процессов, то есть назначать их жертвами, но не понижать: для отрицательного значения нужны права root (или возможность CAP_SYS_RESOURCE). Это то же правило асимметрии, что и у nice выше: добровольно уступить можно, захватить больше нельзя.

Следы работы OOM-killer остаются в журнале ядра. Ищите две строки: invoked oom-killer называет процесс, чей запрос памяти вызвал срабатывание, а Out of memory: Killed process называет жертву с её размером. В этой среде срабатываний не было и поиск не находит ничего (код возврата 1 означает «совпадений нет»):

dmesg | grep -iE 'out of memory|killed process'
echo "код возврата: $?"
код возврата: 1

Реально исчерпать память машины в учебных целях не нужно, поэтому типичные записи приводятся по документации. Ниже пример вывода (не получен в этой среде).

[ 8123.456789] python3 invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0, oom_score_adj=0
[ 8123.457102] Mem-Info:
[ 8123.457300] Out of memory: Killed process 4242 (python3) total-vm:9437184kB, anon-rss:7340032kB, file-rss:1024kB, shmem-rss:0kB, UID:1000 pgtables:14500kB oom_score_adj:0

Здесь жертвой стал процесс 4242 с резидентной анонимной памятью около 7 ГБ. Со стороны пользователя это выглядит так: программа внезапно исчезает без собственного сообщения, оболочка пишет Killed, а код возврата равен 137 (128 плюс номер сигнала 9, см. раздел о кодах возврата). Если рядом шла работа нескольких процессов, первое подозрение при необъяснимом Killed — OOM-killer; проверяют его командой dmesg -T | grep -i 'killed process' или journalctl -k | grep -i 'killed process'.

Предел памяти можно задать и группе процессов, а не всей машине. Тогда срабатывает та же логика, но в пределах группы: в журнале появляется строка Memory cgroup out of memory, а остальные процессы системы не страдают. Так работают лимиты контейнеров и systemd-run -p MemoryMax=; подробно — в теме 12.

Пределы ресурсов: ulimit, prlimit и limits.conf

Помимо общей нехватки ресурсов, ядро ограничивает каждый процесс отдельно. Предел (англ. resource limit) задаёт, сколько открытых файлов, процессов, памяти или процессорного времени процесс может использовать. У каждого предела два значения: мягкий (англ. soft) действует прямо сейчас, а жёсткий (англ. hard) — потолок, выше которого мягкий предел поднять нельзя. Обычный пользователь может свободно менять мягкий предел в пределах жёсткого и необратимо понижать жёсткий; повысить жёсткий предел может только root. Пределы наследуются: дочерний процесс при fork получает копию пределов родителя и не может превысить их 3.

Встроенная команда оболочки ulimit показывает и меняет пределы текущей оболочки и всех процессов, которые она запустит. Основные опции: -a показать всё, -n число открытых файлов, -u число процессов пользователя, -v виртуальная память в килобайтах, -c размер файла дампа памяти (core), -S и -H выбирают мягкий и жёсткий предел. Без -S и -H при установке значения меняются оба предела, а при чтении показывается мягкий 6.

ulimit -Sn; ulimit -Hn
ulimit -u; ulimit -c; ulimit -v
1048576
1048576
31621
0
unlimited

Лимит файлов здесь необычно велик; в классическом дистрибутиве мягкий предел равен 1024. Размер дампа памяти -c равен нулю: при аварии файл core не создаётся. Разработчики включают его командой ulimit -c unlimited, чтобы разбирать падения.

Лучше всего предел -n виден на программе, которая открывает файлы без закрытия. Ниже учебный скрипт openmany.py делает это в цикле, пока не получит ошибку, а предел снижен в подоболочке до 64.

bash -c 'ulimit -n 64; python3 openmany.py; echo "код: $?"'
открыто 61 файлов, затем: [Errno 24] Too many open files: '/dev/null'
код: 1

Из 64 дескрипторов три уже заняты стандартными потоками, поэтому открыть удалось 61. «Too many open files» (ошибка EMFILE, номер 24) — типичная авария сетевых серверов: каждое соединение занимает дескриптор, и при мягком пределе 1024 сервер перестаёт принимать клиентов. Поэтому серверам повышают предел -n, но только в пределах жёсткого. Проверим правила мягкого и жёсткого пределов и наследование.

bash -c 'ulimit -Sn 100; ulimit -Hn 200
         bash -c "ulimit -Sn; ulimit -Hn; ulimit -Sn 150; ulimit -Sn; ulimit -Sn 300"'
bash -c 'ulimit -n 200; ulimit -Hn 400'
100
200
150
bash: line 1: ulimit: open files: cannot modify limit: Invalid argument
bash: line 1: ulimit: open files: cannot modify limit: Operation not permitted

Внутренняя оболочка унаследовала мягкий 100 и жёсткий 200. Подняв мягкий до 150, она получила отказ при попытке выйти за жёсткие 200 (ошибка «Invalid argument»). Во втором примере ulimit -n 200 без -S и -H понизил оба предела, и повышение жёсткого до 400 запрещено («Operation not permitted»). Вывод: понизить предел можно командой, вернуть назад — только запуском новой оболочки с прежними пределами, поэтому осторожные эксперименты проводят в подоболочке с bash -c или в круглых скобках.

Команда ulimit действует на себя и будущих потомков. Чтобы увидеть пределы чужого процесса, читают файл /proc/PID/limits, а изменить их на лету позволяет утилита prlimit (она входит в пакет util-linux; подробнее в справочной странице prlimit(1)).

sleep 500 &
P=$!
prlimit --pid $P --nofile
prlimit --pid $P --nofile=512:1024
grep 'open files' /proc/$P/limits
RESOURCE DESCRIPTION                 SOFT    HARD UNITS
NOFILE   max number of open files 1048576 1048576 files
Max open files            512                  1024                 files     

Формат --nofile=мягкий:жёсткий задаёт оба значения; понижать пределы работающего процесса обычный пользователь может, повышать жёсткий — нет. ulimit в оболочке действует до её закрытия. Постоянные пределы для пользователей задаются в файле /etc/security/limits.conf и в каталоге /etc/security/limits.d/, которые читает PAM-модуль pam_limits при входе. Строка файла имеет формат «кому, тип, предел, значение», например:

student   soft   nofile   4096
student   hard   nofile   65536
@dev      -      nproc    2048

Первая колонка — имя пользователя, группа с префиксом @ или * для всех; вторая — soft, hard или - (оба); третья — название предела (nofile, nproc, core, as и другие); четвёртая — значение. В этой среде файл содержит только комментарии. Строки вступают в силу при следующем входе в систему и касаются сеансов, открытых через PAM; службы systemd эти файлы не читают, а задают пределы директивами LimitNOFILE= и подобными в юнит-файле (тема 9). Администратору удобно запомнить порядок проверки: что показывает /proc/PID/limits у проблемного процесса, оттуда ли значение (оболочка, limits.conf или юнит), и только потом менять настройку.

Итоги темы

Процесс — это выполняющаяся программа с номером, родителем, состоянием и набором открытых файлов. Оболочка создаёт процессы через fork и exec и ждёт их через wait; код возврата лежит в основе операторов && и || из темы 5. Состояния R, S, D, T и Z показывают, чем занят процесс: зомби ждёт, пока родитель его заберёт, остановленный процесс ждёт fg или SIGCONT.

Сигналы — основной инструмент управления процессами. SIGTERM даёт программе возможность завершиться аккуратно, SIGINT и SIGTSTP отвечают на действия пользователя, SIGHUP связывает процесс с терминалом. SIGKILL и SIGSTOP нельзя перехватить, поэтому их применяют только в крайнем случае. Управление заданиями строится на тех же сигналах.

Ресурсы процесса контролируются отдельно. Команды lsof и каталог /proc/PID/fd показывают открытые файлы и сокеты, включая удалённые, но ещё занятые файлы; watch позволяет наблюдать за изменением состояния; журнал ядра dmesg хранит сообщения, которые некому принять раньше, чем запустятся остальные службы. При нехватке памяти OOM-killer завершает процесс с наибольшим oom_score, а следы остаются в журнале ядра. Пределы ulimit (мягкий и жёсткий) ограничивают число файлов, процессов и объём памяти; они наследуются потомками, а их постоянные значения задаются в limits.conf или в юнит-файле.

Тема подготовила почву для следующих. Службы, которые должны работать постоянно и переживать перезагрузку, описываются в теме 9 средствами systemd. Переменные окружения, которые наследуются дочерними процессами, рассмотрены в теме 8. Для курса «Компьютерные сети» знания о процессах, сигналах и зомби нужны при работе с сетевыми серверами и при диагностике зависших программ.

Литература

  1. Ritchie D. M., Thompson K. The UNIX Time-Sharing System. — Communications of the ACM, 1974, Vol. 17, No. 7, С. 365–375, DOI: 10.1145/361011.361061.
  2. Shotts W. The Linux Command Line: A Complete Introduction. 2nd ed. — No Starch Press, 2019. Открытая версия: https://linuxcommand.org/tlcl.php
  3. Kerrisk M. The Linux Programming Interface. — No Starch Press, 2010.
  4. Nemeth E., Snyder G., Hein T. R., Whaley B., Mackin D. UNIX and Linux System Administration Handbook. 5th ed. — Addison-Wesley, 2017.
  5. Free Software Foundation. GNU Coreutils Manual. — https://www.gnu.org/software/coreutils/manual/
  6. Free Software Foundation. GNU Bash Reference Manual. — https://www.gnu.org/software/bash/manual/
  7. The Linux man-pages project. — https://man7.org/linux/man-pages/
  8. The Open Group. The Open Group Base Specifications Issue 7 (IEEE Std 1003.1-2017), Shell Command Language. — https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html