↓快轉到主要內容
  1. 教學文章/

Python subprocess 超時治理:POSIX Process Group、升級終止與輸出收尾

·7 分鐘· loading · loading · ·
Python Subprocess POSIX Process Groups Timeout Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 156: 本文

一. 前言:命令超時了,為什麼工作還在跑?
#

嗨,我是拍拍君。 你替外部命令設了 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,然後關閉我們的讀端。 關閉讀端並不表示那個外部程序已被終止。

八. 上線前,要保留哪些限制?
#

這份小實驗適合有限輸出、同帳號、由自己建立群組的短工作。

  1. 不管理逃離原群組、改變身分或刻意對抗 supervisor 的程序。
  2. 不保證 TERM handler 有執行完業務補償;必須用自己的協定驗收。
  3. 不讓其他執行緒或訊號處理器搶先 reap 同一個直接 child。
  4. 不把兩次人工抄來的 PID 當成同一個永恆身分。
  5. 不把 Windows 的 process group 名稱相似,解讀成同樣的終止語意。

Windows 應另設計 Job Objects/控制事件與 pipe 處理,不能只換一個訊號常數。

結語:停止等待,不等於完成收尾
#

拍拍君最想留下的觀念是:期限、程序、輸出,要分開驗收。 先定義自己擁有哪個 group,再分階段停止它; 最後保留可用的部分輸出、關閉資源、回收直接 child,並確認 fixture 不再執行。

讓忽略 TERM、父程序先退、pipe 還開著的測試都跑一遍,答案會清楚很多。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 156: 本文

相關文章

Python SQLite 線上備份:WAL、Connection.backup 與還原驗收
·7 分鐘· loading · loading
Python SQLite WAL Backup Restore Testing
Polars Pivot / Unpivot:長寬表轉換、重複鍵與 Round-trip 契約
·7 分鐘· loading · loading
Python Polars Pivot Unpivot Data-Contracts Testing
Rich 報表匯出:Record Buffer、HTML/SVG 與可重現分享
·8 分鐘· loading · loading
Python Rich HTML SVG Reports Testing
Streamlit Form 提交生命週期:草稿、已套用狀態與失敗保留
·9 分鐘· loading · loading
Python Streamlit Forms Session State Validation Testing
Textual DataTable 身分契約:Row Key、排序篩選與選取還原
·7 分鐘· loading · loading
Python Textual DataTable RowKey TUI Testing
Streamlit URL 狀態契約:Query Params、篩選還原與雙向同步
·8 分鐘· loading · loading
Python Streamlit Query-Params URL State Testing