MEASUREMENT
bench/signals/practice.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/sre/signals
- How to run it
python3 bench/signals/lifecycle.py > bench/signals/runs/lifecycle.txt python3 bench/signals/practice.py > bench/signals/runs/practice.txt
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для урока «Сигналы, зомби и PID 1»
| Файл | Что делает |
|---|---|
lifecycle.py |
четыре наблюдения без единого замера времени: потомок, чей статус не забрали; сирота и её новый родитель; SIGTERM первому процессу пространства имён — без обработчика и с ним; коды выхода при завершении сигналом |
practice.py |
ответы к задачам урока — прогоном, а не рассуждением: пять строк первой задачи и доля потомков, оставшихся зомби |
Запуск из корня репозитория:
python3 bench/signals/lifecycle.py > bench/signals/runs/lifecycle.txt
python3 bench/signals/practice.py > bench/signals/runs/practice.txt
Что в этих числах воспроизводимо, а что нет
Воспроизводимо у любого читателя на Linux: буква состояния Z, код
выхода, забранный wait; номер родителя сироты (1 или номер ближайшего
подписчика-жнеца, если он в системе есть); исход SIGTERM для PID 1 в обоих
случаях; коды 137 и 143.
Не воспроизводимо и не должно совпадать: номера процессов, время
остановки в миллисекундах и льготный срок. В practice.py льготный срок
сокращён до двух секунд, чтобы прогон был быстрым; у оркестраторов он обычно
десять или тридцать. Отсюда и число «2009 мс» в записи прогона — это не свойство системы, а
выбранный здесь срок плюс время, за которое доходит SIGKILL.
Требования к среде
unshare --fork --pid --mount-proc должен быть разрешён: без него первый
процесс пространства имён не создать, и третий блок lifecycle.py напечатает
«не нашёл потомка unshare». В контейнере, где сняты эти числа, он разрешён.
Числа сняты на CPython 3.11.15, Linux 6.18.44 (x86-64, два ядра). Версия интерпретатора здесь роли не играет: всё, что печатают оба скрипта, — свойства ядра, а не языка.
Script
201 lines"""Практика к уроку про сигналы: пять ответов и одна доля.
ЗАЧЕМ ОТДЕЛЬНЫЙ ФАЙЛ. Задачи урока проверяются сборкой (scripts/validate-practice.mjs):
показанный читателю код обязан построчно быть в скрипте, верный вариант —
дословно встречаться в записи прогона, а заявленное число — печататься самой
программой, а не считаться редакцией в уме. Поэтому здесь ровно то, что видит
читатель, и ничего больше.
ПОЧЕМУ КАЖДАЯ ИЗ ПЯТИ СТРОК ИЗМЕРЕНА, А НЕ ВПИСАНА. Первый черновик печатал
единицу третьей строкой прямо литералом: эксперимент с сиротой запускался,
его вывод отбрасывался, а «1» бралась из головы автора. Строка была бы верной
и совершенно бесполезной — она ничего не проверяла. Теперь номер родителя
читается из вывода внука, и если ядро когда-нибудь передаст сироту не первому
процессу, задача сломается, как и должна.
ЧТО ЗДЕСЬ ИЗМЕРЯЕТСЯ ОТДЕЛЬНО. Вторая половина — цена: сколько потомков
останутся зомби, если не звать `wait`, и сколько времени занимает остановка
процесса, который обрабатывает `SIGTERM`, против того, который его
игнорирует. Оркестратор ждёт вторую сторону весь льготный срок и только потом
посылает `SIGKILL`; здесь срок сокращён до двух секунд, чтобы прогон был
быстрым.
ЗАПУСК: python3 bench/signals/practice.py
Вывод: runs/practice.txt
"""
import os
import signal
import subprocess
import sys
import time
GRACE = 2.0
CHILDREN = 1000
SLEEPER = "import signal, sys, time\ntime.sleep(30)\n"
HANDLED = (
"import signal, sys, time\n"
"signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))\n"
"time.sleep(30)\n"
)
def child_of(pid: int) -> int | None:
"""Потомок процесса — тот, кто внутри пространства имён получил PID 1."""
try:
with open(f"/proc/{pid}/task/{pid}/children", encoding="utf-8") as fh:
kids = fh.read().split()
except FileNotFoundError:
return None
return int(kids[0]) if kids else None
def state_of(pid: int) -> str:
try:
with open(f"/proc/{pid}/stat", encoding="utf-8") as fh:
data = fh.read()
except FileNotFoundError:
return "no such process"
return data[data.rfind(")") + 2 :].split()[0]
def orphan_ppid() -> int:
"""Номер родителя процесса, чей родитель уже умер.
Внук печатает `os.getppid()` ПОСЛЕ смерти отца; его вывод и есть ответ.
Читается он из канала, а не подставляется автором.
"""
parent = (
"import subprocess, sys\n"
"subprocess.Popen([sys.executable, '-c', "
"'import os, time; time.sleep(0.5); print(os.getppid())'])\n"
)
done = subprocess.run(
[sys.executable, "-c", parent], capture_output=True, text=True, check=True
)
return int(done.stdout.split()[-1])
def pid1_state_after_sigterm(program: str) -> str:
"""Состояние первого процесса пространства имён через секунду после SIGTERM.
Ответ задачи — однобуквенное состояние из /proc, а не русское слово:
урок существует в двух языках и цитирует эту запись дословно обоими.
"""
proc = subprocess.Popen(
["unshare", "--fork", "--pid", "--mount-proc", sys.executable, "-c", program],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(0.3)
target = child_of(proc.pid)
if target is None:
proc.kill()
proc.wait()
return "no child of unshare"
os.kill(target, signal.SIGTERM)
time.sleep(1.0)
state = state_of(target)
proc.kill()
proc.wait()
return state
def stop_time(program: str) -> float:
"""Сколько занимает остановка процесса-PID-1 по правилам оркестратора.
Оркестратор посылает SIGTERM, ждёт льготный срок и добивает SIGKILL.
Возвращается время от SIGTERM до фактического конца процесса.
"""
# stderr гасится намеренно: когда родителя-`unshare` добивают, он пишет
# «sigprocmask unblock failed» — жалобу собственной уборки, к измеряемому
# отношения не имеющую. В записи прогона она была бы шумом, который
# читателю нечем объяснить.
proc = subprocess.Popen(
["unshare", "--fork", "--pid", "--mount-proc", sys.executable, "-c", program],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
time.sleep(0.3)
target = child_of(proc.pid)
if target is None:
proc.kill()
proc.wait()
return float("nan")
started = time.perf_counter()
os.kill(target, signal.SIGTERM)
deadline = started + GRACE
while time.perf_counter() < deadline:
if state_of(target) in ("no such process", "Z"):
break
time.sleep(0.005)
else:
os.kill(target, signal.SIGKILL)
while state_of(target) not in ("no such process", "Z"):
time.sleep(0.005)
elapsed = time.perf_counter() - started
proc.kill()
proc.wait()
return elapsed
def main() -> None:
print(f"Python {sys.version.split()[0]} · Linux {os.uname().release}")
print(f"orchestrator grace period in this run: {GRACE} s")
print()
# --- Часть 1: пять ответов о поведении ------------------------------
pid = os.fork()
if pid == 0:
os._exit(7)
time.sleep(0.2)
print(state_of(pid))
print(os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]))
print(orphan_ppid())
print(pid1_state_after_sigterm(SLEEPER))
killed = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(5)"])
time.sleep(0.2)
killed.send_signal(signal.SIGKILL)
print(128 - killed.wait())
# --- Часть 2: сколько потомков останутся зомби -----------------------
#
# ПОЧЕМУ ЗДЕСЬ ДОЛЯ, А НЕ ВРЕМЯ. Первый черновик печатал кратность «во
# сколько раз дольше останавливается процесс без обработчика» — и она
# прыгала между прогонами от двухсот до шестисот, потому что делится
# льготный срок на пять миллисекунд. Число, у которого в знаменателе
# планировщик, не годится в задачу с одним верным ответом. Доля зомби
# такой зависимости не имеет: незабранным остаётся каждый потомок, и это
# ровно то, что урок и утверждает.
print()
children = []
for _ in range(CHILDREN):
kid = os.fork()
if kid == 0:
os._exit(0)
children.append(kid)
time.sleep(0.5)
zombies = sum(1 for kid in children if state_of(kid) == "Z")
print(f"children created {len(children)}")
print(f"of them in state Z {zombies}")
print(f"share of children in state Z, % {100 * zombies / len(children):.1f}")
for kid in children:
os.waitpid(kid, 0)
print(f"left after wait {sum(1 for kid in children if state_of(kid) == 'Z')}")
# Цена остановки — отдельно и как наблюдение, а не как задача: делитель
# здесь миллисекундный, и кратность между прогонами гуляет.
print()
with_handler = stop_time(HANDLED)
without_handler = stop_time(SLEEPER)
print(f"stop time, SIGTERM handled {with_handler * 1000:.0f} ms")
print(f"stop time, no handler {without_handler * 1000:.0f} ms")
print(f"stop time, no handler, s {without_handler:.1f}")
if __name__ == "__main__":
main()