Files

202 lines
7.6 KiB
Python
Raw Permalink Normal View History

"""示例 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())