202 lines
7.6 KiB
Python
202 lines
7.6 KiB
Python
"""示例 12:进程执行器 —— 硬性停止、绕开 GIL、崩溃隔离。
|
||||
|
|
|
|||
|
|
uv run python examples/12_process_executor.py
|
|||
|
|
|
|||
|
|
绝大多数任务该用默认的**线程执行器**:共享内存、没有 pickle 约束、能直接复用连接池。
|
|||
|
|
但有三件事线程做不到,只有子进程能:
|
|||
|
|
|
|||
|
|
1. **硬性停止保证**:任务不响应停止(不调 ``ctx.checkpoint()``)时,线程只能放弃等待
|
|||
|
|
并把结果记成 ``lost``(未知);子进程会被 ``terminate()`` → ``kill()`` 真的停掉;
|
|||
|
|
2. **绕开 GIL**:CPU 密集的计算能真正并行;
|
|||
|
|
3. **崩溃隔离**:任务里加载的原生扩展段错误,只会带走子进程。
|
|||
|
|
|
|||
|
|
本示例把 1 和 3 跑给你看,并把代价说清楚。
|
|||
|
|
|
|||
|
|
这个文件本身也是"目标函数必须是模块级"的演示:下面的任务都定义在模块顶层,因为
|
|||
|
|
spawn 起的子进程要通过"模块 + 函数名"把它们找回来。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from chongming_task import (
|
|||
|
|
ConfigurationError,
|
|||
|
|
FailureKind,
|
|||
|
|
ProcessExecutor,
|
|||
|
|
RetryPolicy,
|
|||
|
|
TaskStatus,
|
|||
|
|
Timeout,
|
|||
|
|
run_task,
|
|||
|
|
)
|
|||
|
|
from chongming_task.runtime.executors.base import ExecutionRequest
|
|||
|
|
|
|||
|
|
# 子进程不继承父进程的 logging 配置(spawn 起的是全新解释器),日志想看就得自己配。
|
|||
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# -----------------------------------------------------------------------------
|
|||
|
|
# 任务定义(模块级:spawn 的子进程要靠"模块 + 函数名"找回来)
|
|||
|
|
# -----------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cpu_burn(ctx: Any, rounds: int = 6) -> dict[str, object]:
|
|||
|
|
"""CPU 密集的活:子进程里不受父进程 GIL 的影响。"""
|
|||
|
|
total = 0
|
|||
|
|
for _ in range(rounds):
|
|||
|
|
ctx.checkpoint()
|
|||
|
|
for index in range(400_000):
|
|||
|
|
total += index % 7
|
|||
|
|
return {"rounds": rounds, "checksum": total, "pid": os.getpid()}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def deaf_loop(ctx: Any) -> str:
|
|||
|
|
"""**不**调用 checkpoint 的任务:停止请求它根本看不见。
|
|||
|
|
|
|||
|
|
这正是不配合的任务的样子——拿线程执行器它只会落 ``lost``;拿进程执行器它会被真的杀掉。
|
|||
|
|
"""
|
|||
|
|
del ctx
|
|||
|
|
end = time.monotonic() + 60
|
|||
|
|
while time.monotonic() < end:
|
|||
|
|
pass
|
|||
|
|
return "never"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def crash_hard(ctx: Any) -> str:
|
|||
|
|
"""模拟原生扩展段错误:直接消失,一句话都不留。"""
|
|||
|
|
del ctx
|
|||
|
|
os._exit(139)
|
|||
|
|
return "never" # pragma: no cover - 走不到这里
|
|||
|
|
|
|||
|
|
|
|||
|
|
# -----------------------------------------------------------------------------
|
|||
|
|
# 演示
|
|||
|
|
# -----------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def demo_cpu_bound() -> None:
|
|||
|
|
print("=" * 72)
|
|||
|
|
print("一、CPU 密集:任务跑在子进程里(父进程的 GIL 管不着它)")
|
|||
|
|
print("=" * 72)
|
|||
|
|
outcome = run_task(
|
|||
|
|
cpu_burn,
|
|||
|
|
6,
|
|||
|
|
task_id="cpu-1",
|
|||
|
|
executor=ProcessExecutor(),
|
|||
|
|
timeout=Timeout(per_attempt=30),
|
|||
|
|
)
|
|||
|
|
assert outcome.ok, outcome.failure
|
|||
|
|
payload = outcome.value
|
|||
|
|
print(f" 结果:{payload}")
|
|||
|
|
print(f" 监督进程 pid={os.getpid()},任务进程 pid={outcome.snapshot.pid}")
|
|||
|
|
print(f" 快照里的 executor={outcome.snapshot.executor}(写进存储,事后可查)")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def demo_hard_stop() -> None:
|
|||
|
|
print("=" * 72)
|
|||
|
|
print("二、硬性停止:不响应停止的任务被真的杀掉(线程执行器只能放弃等待)")
|
|||
|
|
print("=" * 72)
|
|||
|
|
started = time.monotonic()
|
|||
|
|
outcome = run_task(
|
|||
|
|
deaf_loop,
|
|||
|
|
task_id="deaf-1",
|
|||
|
|
executor=ProcessExecutor(),
|
|||
|
|
timeout=Timeout(idle=1), # 一秒没有心跳就判卡死
|
|||
|
|
retry=RetryPolicy(max_attempts=1),
|
|||
|
|
graceful_stop=0.5, # 优雅窗口:0.5 秒内不响应就强杀
|
|||
|
|
)
|
|||
|
|
elapsed = time.monotonic() - started
|
|||
|
|
print(f" 任务自己的循环要跑 60 秒,实际用了 {elapsed:.1f} 秒")
|
|||
|
|
print(f" 终态:{outcome.status.value};失败类型:{outcome.failure.kind.value}")
|
|||
|
|
print(f" 子进程 pid={outcome.snapshot.pid} —— 它已经不在了(ps 查不到)")
|
|||
|
|
assert outcome.status is TaskStatus.FAILED
|
|||
|
|
assert outcome.failure.kind is FailureKind.TIMEOUT_IDLE, "超时如实上报,不谎报'已停止'"
|
|||
|
|
assert elapsed < 30, "不该等到任务自己结束"
|
|||
|
|
print(" 注意:超时仍然记成超时(不是'已停止')——但资源确实释放了。")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def demo_crash_isolation() -> None:
|
|||
|
|
print("=" * 72)
|
|||
|
|
print("三、崩溃隔离:子进程崩了,监督进程活着并如实记一笔")
|
|||
|
|
print("=" * 72)
|
|||
|
|
outcome = run_task(
|
|||
|
|
crash_hard,
|
|||
|
|
task_id="crash-1",
|
|||
|
|
executor=ProcessExecutor(),
|
|||
|
|
timeout=Timeout(per_attempt=30),
|
|||
|
|
)
|
|||
|
|
assert not outcome.ok
|
|||
|
|
print(f" 监督进程还活着(pid={os.getpid()}),任务终态:{outcome.status.value}")
|
|||
|
|
print(f" 失败类型:{outcome.failure.kind.value};退出码:{outcome.failure.exit_code}")
|
|||
|
|
print(f" 说明:{outcome.failure.message}")
|
|||
|
|
print(" 换成线程执行器,这个崩溃会带走整个监督进程。")
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def demo_picklable_guard() -> None:
|
|||
|
|
print("=" * 72)
|
|||
|
|
print("四、代价:不可 pickle 的目标在**提交那一刻**就被拦下")
|
|||
|
|
print("=" * 72)
|
|||
|
|
|
|||
|
|
def local_closure(ctx: Any) -> str: # 局部函数:子进程里找不到它
|
|||
|
|
del ctx
|
|||
|
|
return "nope"
|
|||
|
|
|
|||
|
|
# 1) 直接交给执行器:当场抛错,并告诉你该改成什么。
|
|||
|
|
request = ExecutionRequest(task_id="bad-0", name="bad", target=local_closure, args=())
|
|||
|
|
try:
|
|||
|
|
ProcessExecutor().launch(request)
|
|||
|
|
except ConfigurationError as exc:
|
|||
|
|
print(f" launch() 直接报错 {type(exc).__name__}:")
|
|||
|
|
for line in str(exc).splitlines():
|
|||
|
|
print(f" {line}")
|
|||
|
|
else: # pragma: no cover - 应当抛错
|
|||
|
|
raise AssertionError("不可 pickle 的目标必须被拦下")
|
|||
|
|
|
|||
|
|
# 2) 走 run_task:任务**落 failed 并带上这条诊断**,调用方不会干等,也不会被
|
|||
|
|
# 说成"框架内部错误"——那是把任务侧的问题指向错误的排查方向。
|
|||
|
|
outcome = run_task(local_closure, task_id="bad-1", executor=ProcessExecutor())
|
|||
|
|
assert outcome.status is TaskStatus.FAILED
|
|||
|
|
assert outcome.failure is not None
|
|||
|
|
print(f"\n 走 run_task:终态 {outcome.status.value},retryable={outcome.failure.retryable}")
|
|||
|
|
print(f" 失败信息首行:{outcome.failure.message.splitlines()[0]}")
|
|||
|
|
print(" 配置类错误不重试——重试多少次都是同一个结果,只会白烧重试预算。")
|
|||
|
|
assert outcome.failure.retryable is False
|
|||
|
|
print()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
print()
|
|||
|
|
demo_cpu_bound()
|
|||
|
|
demo_hard_stop()
|
|||
|
|
demo_crash_isolation()
|
|||
|
|
demo_picklable_guard()
|
|||
|
|
|
|||
|
|
print("=" * 72)
|
|||
|
|
print("选型:默认线程,需要这三种能力时才换进程")
|
|||
|
|
print("=" * 72)
|
|||
|
|
print(
|
|||
|
|
"""
|
|||
|
|
线程(默认) 进程(ProcessExecutor)
|
|||
|
|
------------------------ ------------------------------------------
|
|||
|
|
共享内存、可复用连接池 参数与返回值必须可 pickle
|
|||
|
|
闭包 / lambda 都能用 目标必须是模块级函数,调用方要有 __main__ 守卫
|
|||
|
|
停止/暂停即时生效 停止/暂停有约 20ms 轮询延迟
|
|||
|
|
不配合的任务只能放弃(lost) 不配合的任务被真的杀掉
|
|||
|
|
受 GIL 限制 真并行
|
|||
|
|
原生扩展段错误带走整个进程 只带走子进程
|
|||
|
|
"""
|
|||
|
|
)
|
|||
|
|
print("全部通过 ✅")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|