性能:线程 vs 进程 executor¶
每个 GPScheduler 调度器都同时注册一个 thread executor(APScheduler 的
ThreadPoolExecutor)和一个 process executor(ProcessPoolExecutor),各自大小为
max_workers。每个 job 通过 executor 字段选择池。线程是默认,进程是可
选。本页说明各自的使用时机、可 pickle 约束、worker_init 在两者中的行为、优雅关停语义,以及
跨平台差异。
选择 executor¶
| 用线程(默认)当…… | 用进程当…… |
|---|---|
| job 是 I/O 密集型(HTTP、DB、磁盘、网络) | job 是 CPU 密集型(数据处理、模型推理、哈希) |
| 你想要快启动和共享的内存状态 | 你想要真正并行(线程绕不过 CPython 的 GIL) |
| 你需要自由传递不可 pickle的对象 | 你想要 job 之间的进程级隔离 |
| 你想要免费的全局注入(共享内存) | 你希望关停时能真正强杀卡住的 job |
executor 选择优先级¶
一个 job 的 executor 按以下顺序解析(第一个值胜出):
所以 scheduler.yaml 的 executor 是默认;任何 job 都可通过设置自己的 executor 字段
覆盖它。单个调度器可以同时让一些 job 走线程、另一些走进程。
max_workers¶
单个 max_workers 值独立应用于两个池 —— 线程池最多可容纳 max_workers 个并发线程 job,
进程池最多可容纳 max_workers 个并发进程 job。它们是独立的池。
按 job 的执行计数器¶
调度器为每个注册的 job 保留一个内存中的执行计数器(覆盖所有 job,不只是带 max_runs 上限
的那些),存续于 GPScheduler 实例的整个生命周期。这些计数器用于可观测性 —— 它们不会随每次执行
而增长(最多只是 Python int 的重新绑定)—— 并被固定的已注册 job id 集合所限定。移除一个 job 不
会清除它的计数器。
可 pickle 约束(进程 job)¶
进程 job 运行在派生的 worker 进程中,这意味着它们的参数要跨进程边界。因此,对任何
executor: process 的 job:
- 它的
args和kwargs, - 注入的
gp_globals(即scheduler.yaml的job_globals值),
……都必须可 pickle。gpscheduler 在加载时强制这一点:它在校验期间对 job 的函数、args、
kwargs 试 pickle,失败就在启动时抛 JobSignatureError(Fail-Early)—— 而不是在 job 首次
运行时。
线程 job 没有可 pickle 约束;它可以接受任何参数。
worker_init 为重对象绕开约束¶
这是 worker_init 存在的关键原因。活跃的连接 handler、socket、锁或 ML 模型不可 pickle,
所以不能放进 args/kwargs。但 worker_init 在首次执行时在 worker 进程内部构建对象并缓
存 —— 对象从不跨进程边界。所以即便是进程 job 也能用一个完全不可 pickle 的对象,只要该对象由
@worker_init 函数构建而非作为参数传入。
完整模式见 API → worker_init。
worker_init 缓存作用域(按 executor)¶
worker_init 构建的缓存对象,其作用域因 executor 而异:
| Executor | worker_init 何时运行 |
缓存作用域 |
|---|---|---|
thread |
该 job 首次执行时(lazy) | 主进程 —— 一个对象被所有线程共享 |
process |
该 job 在每个 worker 进程内首次执行时(lazy) | 每个 worker 进程 —— 各 worker 建各自的对象 |
在线程池中,worker_init 只跑一次,之后每次运行复用同一个对象(如一个共享的 DB 连接)。在进
程池中,每个 worker 进程在首次使用时独立构建自己的对象(如每个 worker 有自己的连接)。这是设
计使然:进程间无共享内存,所以每个 worker 都需要自己的。
gpscheduler 保证初始化器在每个执行上下文里只运行一次(内部用锁)。但返回的对象是否能在 并发线程间安全共享是你的责任。
进程 job 与 stdout¶
进程 job 跑在独立的 spawned worker 进程里,该进程有自己的 stdout 缓冲区,与主进程相互独
立。当主进程输出到终端时,stdout 是行缓冲(遇 \n 即 flush)。但当 stdout 被重定向到文件
或管道时——例如 gpscheduler run > log.txt、systemd unit、daemon 或任何非 TTY 目标——Python
会切换为块缓冲(仅当缓冲区写满约 4–8 KB 时才 flush)。由于 worker 进程是长期存活的(被池化复
用)且通常输出量不大,缓冲区可能很久都不会写满,于是进程 job 的 print() 输出在重定向文件里
可能看起来延迟甚至缺失——尽管 job 实际上正常执行了。
这是 concurrent.futures.ProcessPoolExecutor 的标准行为,不是 gpscheduler 的 bug。它只影响进程
job 的 print()/stdout 的可见性——job 的实际执行、返回值、副作用以及 gpclog 输出都不受影响
(gpclog 以 flush=True 直接写自己的文件,不走 stdout)。线程 job 与主进程共用缓冲区,通常会被
其它活动顺带 flush,因此很少受影响。
要可靠地观察进程 job:
- 不要依赖
print()做进程 job 的诊断输出。通过gpclog或自己写文件来记日志——无论进程以 何种方式启动都能立即看到(这也是任意 job——无论线程还是进程——的推荐做法;见 API → 嵌入式用法)。 - 如果必须实时看
stdout,让调度器在前台终端运行,或设置PYTHONUNBUFFERED=1(python -u也可以),使每次写入都立即 flush。
优雅关停超时¶
scheduler.yaml 的 timeout 字段(或 shutdown(timeout=...) 的 timeout 参数)限定调度器
等待运行中 job 结束多久后才强制退出:
- 超时之前: 两种 executor 模型都等待所有运行中的 job 完成(优雅)。
- 超时之后: 行为因模型而异 —— 这是需要理解的诚实权衡:
| Executor | 超时之后 |
|---|---|
thread |
存活的工作线程被守护化。主进程退出,残留线程随之死亡。finally 块不保证运行,所以中途被打断的 job 可能留下不一致的状态。 |
process |
worker 子进程被终止(terminate())。它们收到一个终止信号,所以 job 函数的 try/finally / atexit 有机会运行。 |
两种模型都保证主进程在超时后退出 —— 这才是用户的真实要求。唯一差异是一个被打断的 job 能
得到多少自救机会。如果你无法容忍任务中途被打断,请用进程模型、设一个宽裕的 timeout、或不设
超时(永远等待)。
注意: 没有单次 job 执行超时。
timeout仅是调度器级的优雅关停上限。如果 job 需要运 行时长上限,请在函数内部强制(如 POSIX 上的signal.alarm、线程超时)。
跨平台注意事项¶
GPScheduler 同时跑在 Windows 和 Linux 上。下面的差异在实践中很重要。
进程启动开销¶
ProcessPoolExecutor 派生全新的 worker 进程。Linux 上默认启动方式是 fork,相对廉价。
Windows(和 macOS)上启动方式是 spawn:每个新 worker 从零重新导入整个模块树,包括你的
packages。所以:
- 目标包必须能干净导入(没有在重新导入下会坏的顶层副作用,没有未解析的导入)。
- 进程 job 在 Windows 上的首次运行延迟高于 Linux。
worker 是池化复用的,所以这个开销按 worker 计,而非按每次 job 运行计。
Ctrl+C 与 worker 进程¶
Windows 上,Ctrl+C 被广播给整个进程组,不只是主进程。一个阻塞在工作队列上的进程池
worker 否则会收到该中断,并向 stderr 倾倒一段 KeyboardInterrupt 回溯。
GPScheduler 防止了这一点:每个派生的 worker 在初始化期间为 SIGINT 安装 signal.SIG_IGN,
所以 worker 绝不响应 Ctrl+C。Ctrl+C 上的优雅关停完全由主进程独占,两个操作系统上都
如此(单一代码路径 —— 没有 per-OS 分支)。你按 Ctrl+C,主进程运行优雅关停,worker 进程退出而
不打印杂散回溯。
用于关停的信号¶
| 平台 | 优雅关停信号 |
|---|---|
| Linux / macOS | SIGINT(Ctrl+C)和 SIGTERM(kill <pid>) |
| Windows | 仅 SIGINT(Ctrl+C) |
Windows 上,taskkill 不能可靠送达 SIGTERM,且关闭控制台窗口或用任务管理器不保证运行优
雅关停(进程只得到几秒钟,且 winapi 路径未被处理)。Windows 上请用 Ctrl+C 做优雅关停。
Windows 没有直接的 SIGKILL 等价物 —— taskkill /F 是硬杀,没有优雅路径。见
CLI → 停止调度器。
信号处理器的线程约束¶
关停信号处理器从主线程安装(signal.signal() 的要求)。阻塞的 run() 循环以短超时轮询一
个 threading.Event,而非无限等待 —— 这是一个 Windows 专有的变通,因为无限 Event.wait() 会
阻塞在 WaitForSingleObject 中,无法被 Ctrl+C 可靠唤醒。POSIX 上这种轮询是无害的。这些都
在内部处理,你无需做任何事。
gpclog 初始化¶
gpscheduler 自身的日志(启动、关停、被 APScheduler executor 捕获的 job 执行异常)走
gpclog。有两点需知:
- logger 从主线程初始化。
GPScheduler.__init__在后台调度器线程启动前获取gpcloglogger。请从主线程构造调度器;绝不从 job 线程内部首次调用gpclog.get_logger(...)。 (见 API → 嵌入式用法。) - 进程 worker 重新初始化自己的 logger。 每个派生的 worker 获取一个带进程号后缀的 per-进程
logger,所以调度器的日志会按 worker 正确发出。
loguruhandler 不跨进程边界。
job 内部日志(在 job 函数内部产生的日志)是 job 自己的责任 —— gpscheduler 不配置也不转 发它。