一. 前言:命令超時了,為什麼工作還在跑? #
嗨,我是拍拍君。 你替外部命令設了 timeout,終端機也印出「已超時」。 可是背景 worker 還在寫檔,下一輪任務又開了一份。
程序退出、整組工作停止、pipe 讀完,是三件不同的事。
先前的 subprocess 指南 已介紹執行命令、管道和即時輸出。 今天不重講那些 API,而是用自己建立的短命測試程序,驗收整組收尾。 範圍限定 POSIX macOS/Linux;不是 Windows Job Objects 教學。
二. 先寫契約:誰是我們有權收掉的程序? #
這次的任務樹只有三層:
supervisor(我們的 Python)
└── parent(新 session,PID = PGID)
└── worker(繼承 parent 的 process group 與輸出 pipe)
start_new_session=True 讓新程序先建立 session。
後代若沒有自行更換群組,就繼承這個 PGID。
接著只對本次建立的群組送訊號,不掃描名稱相似的程序,也不收外部 PID。
我們的實驗契約是:
- 工作期限從呼叫開始,以
time.monotonic()計算。 - 超時先送 TERM,留一小段 grace,再對剩餘群組送 KILL。
- 等待期間持續讀 pipe,保留有限的部分輸出。
- 最後關閉 pipe、回收直接子程序,驗證 fixture 後代已不在執行。
- 後代不換 UID、不呼叫
setsid()/setpgid()逃離管理範圍。
Process group 不是安全沙箱,也不是任意後代追蹤器。
離開群組的程序不在這份契約內,不能承諾用一次 killpg() 清光整棵樹。
三. 安裝與成功條件:標準庫就夠 #
建立全新目錄,將下方程式分別存成三個檔案:
mkdir pypy-shutdown-lab
cd pypy-shutdown-lab
python3 --version
使用 Python 3.12+,且系統必須提供 waitid 與 WNOWAIT。
本文實測 Python 3.14.3/macOS;Linux 使用同一組 POSIX API,但本文未執行 Linux 驗收。
先分清楚結果欄位,避免看到 returncode == 0 就宣告全案成功:
| 欄位 | 意義 |
|---|---|
returncode |
直接子程序的結束碼,不是全體 worker 的工作結果 |
timed_out |
工作期限內沒有同時觀察到 leader 退出與 pipe EOF |
eof |
最後是否真的讀到 pipe 結尾 |
discarded |
超過保留額度、已讀取但未保存的 bytes |
elapsed |
實際花費,包含清理,不是硬即時保證 |
四. 核心實作:期限、群組訊號與有限 Drain #
把這段完整存成 supervisor.py:
import math
import os
import selectors
import signal
import subprocess
import time
from dataclasses import dataclass
@dataclass
class Outcome:
returncode: int
timed_out: bool
eof: bool
discarded: int
output: bytes
elapsed: float
def supervise(argv, timeout=1.0, grace=0.3, drain=0.5, limit=65536):
required = ('waitid', 'WNOWAIT', 'WEXITED', 'WNOHANG')
if os.name != 'posix' or not all(hasattr(os, x) for x in required):
raise NotImplementedError('requires POSIX waitid / WNOWAIT')
if any(not math.isfinite(x) or x <= 0 for x in (timeout, grace, drain)):
raise ValueError('durations must be finite and positive')
if not isinstance(limit, int) or limit < 0:
raise ValueError('limit must be a nonnegative integer')
started = time.monotonic()
proc = subprocess.Popen(
argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, start_new_session=True, bufsize=0,
)
sel = selectors.DefaultSelector()
kept = bytearray()
discarded = 0
eof = False
def signal_group(sig):
try:
os.killpg(proc.pid, sig)
except ProcessLookupError:
pass
except PermissionError:
# Accept EPERM only after observing a zombie-only owned group.
rows = subprocess.run(
['ps', '-axo', 'pgid=,stat='], capture_output=True,
text=True, check=True, timeout=0.5,
).stdout.splitlines()
states = [r.split()[1] for r in rows
if r.split() and r.split()[0] == str(proc.pid)]
if not (states and all(s.startswith('Z') for s in states)
and leader_exited()):
raise
def leader_exited():
info = os.waitid(os.P_PID, proc.pid,
os.WEXITED | os.WNOHANG | os.WNOWAIT)
return info is not None and info.si_pid == proc.pid
def pump(until, early=True):
nonlocal discarded, eof
while time.monotonic() < until:
if early and eof and leader_exited():
return True
delay = min(0.02, max(0, until - time.monotonic()))
if not sel.get_map():
time.sleep(delay)
continue
for key, _ in sel.select(delay):
try:
chunk = os.read(key.fd, 8192)
except BlockingIOError:
continue
if not chunk:
sel.unregister(key.fileobj)
eof = True
continue
room = max(0, limit - len(kept))
kept.extend(chunk[:room])
discarded += max(0, len(chunk) - room)
return eof and leader_exited()
try:
os.set_blocking(proc.stdout.fileno(), False)
sel.register(proc.stdout, selectors.EVENT_READ)
timed_out = not pump(started + timeout)
if timed_out:
signal_group(signal.SIGTERM)
pump(time.monotonic() + grace, early=False)
finally:
# Keep the leader unreaped until the final group signal.
try:
signal_group(signal.SIGKILL)
pump(time.monotonic() + drain)
finally:
sel.close()
proc.stdout.close()
proc.wait(timeout=drain)
return Outcome(proc.returncode, timed_out, eof, discarded,
bytes(kept), time.monotonic() - started)
stdout 與 stderr 合併成一條二進位 pipe,避免示範同時展開兩套讀取邏輯。
這會失去兩個通道的獨立身分,也不保證跨程序訊息具有業務上的總排序。
為什麼觀察退出,卻暫時不回收? #
poll()/wait() 會回收直接子程序。
這裡改用 waitid(..., WNOWAIT) 先看它是否結束,保留最後的回收動作。
所有群組訊號都在最後 wait() 之前送出;不把已回收 leader 的 PID 存起來,隔很久再當 PGID 使用。
呼叫端也不能有另一個 thread/SIGCHLD handler 搶先回收同一個 child。
參考 Python waitid 與 WNOWAIT 說明。
macOS 實測有一個值得留意的收尾:群組只剩 zombie 時,送訊號可能得到 EPERM。
程式只在額外查到該 PGID 全部是 zombie、且 leader 已退出時接受這個狀態。
若仍有活程序或不能確認,錯誤照樣拋出;不是一律忽略權限失敗。
這個特例限於上述同 UID、自建 fixture 契約,不能拿去掩蓋正式環境的權限問題。
為什麼正常完成也有最後一道 KILL? #
本範例的政策是「工作結束後不允許留下背景服務」。 leader 退出且 pipe EOF,不保證某個早已關閉 stdout 的 worker 也退出了。 所以正常路徑也在回收 leader 前做最後的群組清掃。 這是明確的 supervision 政策,不是所有外部命令都適合的預設。
超時路徑則先給完整 grace 時間,再做這一步。
送出 KILL 的嘗試不代表真的殺到活程序;合作退出的工作也會走過最後清掃。
timed_out=True 不會因為稍後合作退出、結束碼是零,就被改回成功。
五. 三個 Fixture:別拿真實服務練習 #
把下面存成 fixture.py。
每個 worker 最多睡 30 秒作為實驗保險;真正的管理期限仍由 supervisor 決定。
import os
import signal
import subprocess
import sys
import time
mode = sys.argv[1]
if len(sys.argv) == 3 and sys.argv[2] == 'worker':
def on_term(*_):
print('CHILD_TERM', flush=True)
raise SystemExit(0)
handler = signal.SIG_IGN if mode == 'ignore' else on_term
signal.signal(signal.SIGTERM, handler)
print(f'CHILD {os.getpid()} PGID {os.getpgrp()}', flush=True)
print('PARTIAL_STDERR', file=sys.stderr, flush=True)
time.sleep(30) # Finite lab failsafe, not the supervisor deadline.
else:
signal.signal(signal.SIGTERM,
lambda *_: print('PARENT_TERM', flush=True))
child = subprocess.Popen([sys.executable, '-u', __file__, mode, 'worker'])
print(f'PARENT {os.getpid()} CHILD_PID {child.pid}', flush=True)
if mode == 'holder':
raise SystemExit(0) # Child still owns the inherited output pipe.
raise SystemExit(child.wait())
| 模式 | 故意製造的狀況 | 應看到什麼 |
|---|---|---|
cooperate |
父子收到 TERM 後合作退出 | 有 CHILD_TERM,直接子程序可回傳零 |
ignore |
worker 忽略 TERM,parent 還在等它 | grace 後由 KILL 結束,直接子程序為 -SIGKILL |
holder |
parent 先回傳零,worker 仍握著 pipe | 仍會超時,不能因為 parent 是零就跳過群組收尾 |
worker 沒有另開 session,也沒有改變帳號權限。
六. 驗收:留下輸出,而且真的沒有活著的 Fixture #
最後存成 test_lab.py:
import re
import signal
import subprocess
import sys
import time
import unittest
from pathlib import Path
from supervisor import supervise
FIXTURE = str(Path(__file__).with_name('fixture.py'))
def assert_not_running(test, pid):
# A zombie is dead, but only its own parent/adopter can reap it.
until = time.monotonic() + 2
while time.monotonic() < until:
state = subprocess.run(
['ps', '-p', str(pid), '-o', 'stat='],
capture_output=True, text=True, timeout=1,
).stdout.strip()
if not state or state.startswith('Z'):
return
time.sleep(0.02)
test.fail(f'fixture process still running: {pid} {state}')
class ShutdownTests(unittest.TestCase):
def test_tree_modes(self):
for mode in ('cooperate', 'ignore', 'holder'):
with self.subTest(mode=mode):
result = supervise([sys.executable, '-u', FIXTURE, mode])
text = result.output.decode('utf-8', errors='replace')
self.assertTrue(result.timed_out)
self.assertTrue(result.eof)
self.assertEqual(result.discarded, 0)
self.assertIn('PARTIAL_STDERR', text)
self.assertLess(result.elapsed, 4)
parent, child = map(int, re.search(
r'PARENT (\d+) CHILD_PID (\d+)', text).groups())
pgid = int(re.search(r'PGID (\d+)', text).group(1))
self.assertEqual(pgid, parent)
self.assertNotEqual(pgid, __import__('os').getpgrp())
expected = -signal.SIGKILL if mode == 'ignore' else 0
self.assertEqual(result.returncode, expected)
if mode != 'ignore':
self.assertIn('CHILD_TERM', text)
assert_not_running(self, parent)
assert_not_running(self, child)
print(mode, result.returncode, result.eof)
def test_normal_and_nonzero(self):
for code in (0, 7):
with self.subTest(code=code):
result = supervise([sys.executable, '-c',
f"print('拍拍君'); raise SystemExit({code})"])
self.assertFalse(result.timed_out)
self.assertTrue(result.eof)
self.assertEqual(result.returncode, code)
self.assertIn('拍拍君', result.output.decode())
def test_retention_limit_still_drains(self):
result = supervise([sys.executable, '-c',
"import os; os.write(1, b'x' * 100000)"], limit=100)
self.assertEqual(len(result.output), 100)
self.assertEqual(result.discarded, 99900)
self.assertTrue(result.eof)
self.assertFalse(result.timed_out)
if __name__ == '__main__':
unittest.main()
執行:
python3 -m unittest -v test_lab
實測三個 unittest 方法通過,內含三種程序樹模式、零/非零結束碼及輸出截斷案例。
holder的 leader 是零,卻必須回報超時。ignore不能只送 TERM 後就返回。- stderr 的
PARTIAL_STDERR必須保留下來。 - PGID 必須屬於新建 parent,不能是 supervisor 自己的群組。
- pipe 要讀到 EOF,已識別的父子程序都不能仍在執行。
測試把 zombie 視為「已停止執行」,不是「已完全回收」。
supervisor 的 wait() 只能回收自己的直接 child;
失去 parent 的後代通常由系統接手回收,不能假裝呼叫一次 wait 就能收所有孫程序。
七. 輸出與時間:兩個不能省略的邊界 #
先 Drain,再談保存多少 #
保留額度預設 64 KiB,超額繼續讀,但只累計 discarded。
測試用 100,000 bytes 確認「不保存」不等於「不讀」。
如果讀到額度就停止 drain,寫入者可能被滿 pipe 卡住,反而拖垮收尾。
示範只保存前綴;需要完整輸出時,應改成磁碟 spool 並另訂容量、輪替與寫入失敗政策。 保留 bytes 上限,也不等於整個 Python 行程的記憶體上限。
一秒 Timeout,不代表一秒回傳 #
工作期限之外,還有 grace、最後 drain,以及直接 child 的有期限 wait。 排程延遲、程序建立、核心 I/O 狀態都會讓實際 elapsed 不同。
如果最後仍無法回收直接 child,TimeoutExpired 會向上傳遞;不能偽造已清乾淨。
若逃離群組的程序仍握著 pipe,最後 drain 會到期,eof=False,然後關閉我們的讀端。
關閉讀端並不表示那個外部程序已被終止。
八. 上線前,要保留哪些限制? #
這份小實驗適合有限輸出、同帳號、由自己建立群組的短工作。
- 不管理逃離原群組、改變身分或刻意對抗 supervisor 的程序。
- 不保證 TERM handler 有執行完業務補償;必須用自己的協定驗收。
- 不讓其他執行緒或訊號處理器搶先 reap 同一個直接 child。
- 不把兩次人工抄來的 PID 當成同一個永恆身分。
- 不把 Windows 的 process group 名稱相似,解讀成同樣的終止語意。
Windows 應另設計 Job Objects/控制事件與 pipe 處理,不能只換一個訊號常數。
結語:停止等待,不等於完成收尾 #
拍拍君最想留下的觀念是:期限、程序、輸出,要分開驗收。 先定義自己擁有哪個 group,再分階段停止它; 最後保留可用的部分輸出、關閉資源、回收直接 child,並確認 fixture 不再執行。
讓忽略 TERM、父程序先退、pipe 還開著的測試都跑一遍,答案會清楚很多。