Задача из cron превысила лимит timeout 600, в логе код 124. Через минуту cron запускает её снова, и обёртка flock -n молча выходит: lock занят, «предыдущий запуск ещё идёт». Но timeout уже вернул 124: главный процесс задачи завершился, а её фоновые потомки могли остаться.
Я проверил в контейнере семь обычных способов, которыми задача оставляет что-то работать в фоне: setsid -f, nohup … &, start-stop-daemon --background, двойной fork на Python с закрытием дескрипторов и без, ssh-agent, gpg-agent. Каждый запускался из задачи, которая держит flock, и обрывался через timeout 2 и subprocess.run(timeout=2). Демона не убила ни одна обёртка. Будет ли пропущен следующий запуск, решает сама фоновая утилита: закрывает ли она унаследованные дескрипторы. gpg-agent и наивный двойной fork держат lock, ssh-agent и start-stop-daemon отпускают. Зато у тех, что отпускают, процессы копятся: пять запусков подряд дали пять живых ssh-agent. А оборванный запуск в обоих случаях заканчивается одинаково: 124 от timeout, TimeoutExpired от subprocess.run.
Стенд
Задача берёт lock на дескрипторе 9, уходит в фон одним из семи способов и ждёт 30 секунд. Сама она ждёт с закрытым дескриптором, так что после её смерти lock может держать только фоновый потомок:
exec 9>"$D/lock"; flock -n 9 || { echo "job: lock busy"; exit 1; } case $M in setsid_f) setsid -f sleep 30 </dev/null >/dev/null 2>&1 ;; nohup_bg) nohup sleep 30 </dev/null >/dev/null 2>&1 & ;; ssd_bg) start-stop-daemon --start --background --make-pidfile \ --pidfile "$D/ssd.pid" --exec /usr/bin/sleep -- 30 ;; py_dfork) python3 dfork.py keep ;; # двойной fork + setsid py_dfork_close) python3 dfork.py close ;; # то же + os.closerange(3, 1024) ssh_agent) eval "$(ssh-agent -s)" >/dev/null ;; gpg_agent) export GNUPGHOME="$D/gh.$$"; mkdir -m 700 "$GNUPGHOME" gpg-agent --daemon >/dev/null 2>&1 ;; esac sleep 30 9>&-
Через две секунды после возврата обёртки скрипт находит в /proc новые процессы, смотрит, у кого среди открытых файлов есть lock, и пробует flock -n тем же файлом, как следующий запуск. У ssh-agent и gpg-agent каталог /proc/PID/fd закрыт для чтения: оба помечают себя как non-dumpable. Держателя я находил иначе: убивал выживших по одному и смотрел, после кого lock освободился. Каждая ячейка прогнана три раза, повторы совпали. Образ python:3.15 (Debian 13), coreutils 9.7, Python 3.15.1.
Почему вообще важен дескриптор. По man flock(2) блокировка принадлежит открытому описанию файла, копии после fork её разделяют, и снимается она, когда закроется последняя копия. Любой потомок, который унаследовал дескриптор 9 и не закрыл его, держит lock, даже если про lock ничего не знает.
Семь способов уйти в фон
способ |
жив после таймаута |
lock: |
lock: |
кто держит |
|---|---|---|---|---|
|
да |
занят 3/3 |
занят 3/3 |
sleep |
|
только после |
свободен 3/3 |
занят 3/3 |
sleep |
|
да |
свободен 3/3 |
свободен 3/3 |
никто |
двойной fork без закрытия fd |
да |
занят 3/3 |
занят 3/3 |
python3 |
двойной fork + |
да |
свободен 3/3 |
свободен 3/3 |
никто |
|
да |
свободен 3/3 |
свободен 3/3 |
никто |
|
да |
занят 3/3 |
занят 3/3 |
gpg-agent |
Обёртки отработали одинаково во всех 42 прогонах: вернулись через 2,00 секунды с кодом 124 или TimeoutExpired. Ни одна не убила процесс, который ушёл в свою сессию.
Единственная строка, где обёртка что-то решает, — nohup. Он не создаёт новую сессию и даже не меняет группу, он только игнорирует SIGHUP. timeout без --foreground шлёт TERM всей группе процессов, и sleep под nohup умирает. subprocess.run(timeout=) шлёт SIGKILL одному PID, своему прямому ребёнку, поэтому тот же sleep выживает и держит lock. Это видно и по исходникам: в src/timeout.c сигнал уходит группе, в subprocess.run вызывается process.kill().
Во всех остальных строках результат определяет утилита. setsid -f, самодельный двойной fork и gpg-agent унаследованный дескриптор не трогают. start-stop-daemon --background, ssh-agent и двойной fork по образцу PEP 3143 (на стенде — закрыть дескрипторы 3–1023) дескриптор закрывают. Из документации timeout и subprocess этого не узнать, это свойство конкретной программы и её версии.
Пять запусков подряд
Свободный lock выглядит как хороший исход. Я запустил каждую задачу пять раз подряд под timeout 2, как cron с коротким интервалом, с общим lock-файлом:
способ |
прошли |
пропущены |
живых фоновых в конце |
lock после |
|---|---|---|---|---|
|
1 |
4 |
1 |
занят |
двойной fork без закрытия |
1 |
4 |
1 |
занят |
|
1 |
4 |
1 |
занят |
|
5 |
0 |
1 |
свободен |
двойной fork + |
5 |
0 |
5 |
свободен |
|
5 |
0 |
5 |
свободен |
У одной причины два исхода. Потомок, который держит дескриптор, съедает следующие запуски, и cron видит это как «уже идёт». Потомок, который его закрыл, запускам не мешает, но после каждого таймаута остаётся новый процесс. Только start-stop-daemon не размножился, потому что не стартует второй экземпляр при живом PID из pidfile. Таймаут здесь ни при чём. Оборванный запуск во всех шести строках вернул 124, пропущенные вернули 1 от flock -n. По коду первого запуска не угадать, что будет дальше: пропуски или утечка процессов.
Что освободило lock
Флаги timeout тут не помогают. На синтетическом дереве из трёх потомков с тем же lock (обычный фоновый, после setsid, с trap '' TERM) я прогнал timeout 2, --foreground и -k 1 2: setsid-потомка не достал ни один вариант, lock оставался занят во всех 15 прогонах.
subprocess.run(timeout=2) убил только прямого ребёнка, Popen с os.killpg — всех, кроме setsid-потомка.
Без cgroup всё дерево закрыл один вариант. Обёртка объявляет себя subreaper через prctl(PR_SET_CHILD_SUBREAPER), и осиротевшие потомки переподвешиваются к ней вместо PID 1. После killpg она обходит /proc, собирает всех, кто числится её потомками, и шлёт им KILL:
PR_SET_CHILD_SUBREAPER = 36 ctypes.CDLL(None, use_errno=True).prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) p = subprocess.Popen(cmd, start_new_session=True) try: p.wait(timeout=2) except subprocess.TimeoutExpired: os.killpg(p.pid, signal.SIGTERM) ... # ждём главный процесс до секунды, затем killpg KILL while left := descendants(os.getpid()): # обход /proc по ppid for pid in left: os.kill(pid, signal.SIGKILL) ... # пожать зомби через waitpid(-1, WNOHANG)
На синтетическом дереве это заняло 2,05 секунды вместо 2,00, setsid-потомок убит, lock свободен. На демонах из таблицы я subreaper не прогонял; механизм тот же (двойной fork отрывает их от родителя), но это только вывод из устройства, замера нет. Другой путь — cgroup, например systemd с KillMode=control-group: из группы процессов уходят через setsid, а cgroup процесс так не покидает. В моём контейнере /sys/fs/cgroup смонтирован только для чтения, дочернюю группу создать нельзя, поэтому cgroup.kill я не проверял.
Что из этого следует
Демон, оставленный задачей, переживает и timeout, и subprocess.run, и дальше всё зависит от того, закрыл ли он унаследованный дескриптор с lock. Закрыл — следующие запуски идут, но после каждого таймаута в системе на один процесс больше, если утилита сама не проверяет, что уже запущена, как start-stop-daemon с pidfile. Не закрыл — cron пропускает запуски, пока демон жив. Оборванный запуск в обоих случаях возвращает одно и то же: 124 или TimeoutExpired.
Что с этим делать. Проверьте, закрывает ли ваша фоновая утилита унаследованный дескриптор с lock: gpg-agent, setsid -f и самодельный двойной fork его сохраняют и держат lock, ssh-agent, start-stop-daemon и fork с закрытием дескрипторов — закрывают.
Закрыть дескриптор принудительно (9>&- для фонового потомка или flock -o) можно, только если задача переживёт одновременный второй запуск. На стенде с flock -o второй запуск стартовал, пока потомки первого ещё работали: после его старта они сделали 60 записей. Если фоновый процесс ушёл в свою сессию и должен умереть вместе с задачей, нужен subreaper или cgroup, обёртка с таймаутом этого не сделает.
Окружение: блоки с утилитами — образ python:3.15 (Debian 13), Python 3.15.1, GNU coreutils timeout 9.7, gpg-agent 2.4.7, start-stop-daemon 1.22.22, OpenSSH 10.0p1; синтетическое дерево — python:3.15-slim, Python 3.15.0, bash 5.2.37, util-linux flock 2.41.5. Мануал timeout на сайте GNU описывает 9.12, логику группы и -k я сверял по текущему src/timeout.c.