代码实战 · 2026 年 11 月 11 日

用 80 行 Python 写一个可靠的定时任务调度器

只靠标准库写一个进程内调度器:秒级间隔触发、异常隔离、失败指数退避、上一轮没跑完就跳过,并说清它和 cron 各自该用在什么场景。

对账任务要求每 90 秒拉一次上游文件,cron 只能给到分钟级;更麻烦的是服务部署了三个副本,谁都不该多跑一遍。

这类需求不需要引入 Celery 或 APScheduler。threading 加 heapq,八十行以内就能得到一个行为可预期的进程内调度器。前提是先想清楚它不负责什么。

先划清能力边界#

要解决的是四个具体问题:

  • 触发间隔:30 秒、90 秒这类非整分钟周期,cron 表达不了。
  • 重叠执行:上一轮跑了 3 分钟,下一轮又起来,两个线程写同一个临时文件。
  • 失败处理:上游偶发 502,等到下个周期太久,立刻重试又会把对方打挂。
  • 可观测:日志里要能看出是哪个任务、连续失败了几次。

如果只是「每天凌晨跑一次备份」,cron 配 flock 更省事。进程内调度器的价值在于秒级精度、能复用进程里的连接池和缓存,以及能直接读写内存状态。

顺带说一句为什么不用现成库。APScheduler 功能完整,代价是多一份依赖和一套配置心智;threading.Timer 每次触发都要重建对象,还会随任务数线性堆积线程。如果你的需求就是本文列的四条,自己写反而更容易读懂、更容易改。代码全部用标准库,粘贴到任何 Python 3.9 以上的环境都能跑。

用最小堆管理下一次触发时间#

堆顶永远是最早该执行的任务。每条记录带上 next_run(time.monotonic() 时间戳)和自增的 seq,后者用来打破同一时刻的平局。

这里有个必须知道的细节:dataclass(order=True) 会把所有字段拉进比较,而函数对象不能比大小。给 fn、name 加上 compare=False,否则注册第二个任务时就会抛 TypeError。

python
# scheduler.py —— 仅标准库,Python 3.9+
import heapq
import logging
import threading
import time
from dataclasses import dataclass, field

log = logging.getLogger("sched")

@dataclass(order=True)
class Job:
    next_run: float
    seq: int
    name: str = field(compare=False)
    interval: float = field(compare=False)
    fn: object = field(compare=False)
    running: bool = field(compare=False, default=False)
    failures: int = field(compare=False, default=0)

class Scheduler:
    def __init__(self, tick: float = 1.0):
        self._heap, self._seq = [], 0
        self._tick = tick
        self._stop = threading.Event()
        self._cv = threading.Condition()
        self._thread = threading.Thread(target=self._loop, name="sched", daemon=True)

    def every(self, seconds, name=None, catch_up=False):
        """注册固定间隔任务;catch_up=True 表示启动后先立刻跑一次。"""
        def register(fn):
            with self._cv:
                self._seq += 1
                first = time.monotonic() + (0.0 if catch_up else float(seconds))
                heapq.heappush(self._heap,
                               Job(first, self._seq, name or fn.__name__, float(seconds), fn))
                self._cv.notify()
            return fn
        return register

注册只做两件事:算出首次触发时间,塞进堆并唤醒正在等待的循环。用 Condition 而不是 time.sleep 轮询,新任务注册后能立刻被感知。

调度循环:三个判断缺一不可#

python
# 片段:以下方法接在 Scheduler 类内部
    def _loop(self):
        while True:
            with self._cv:
                if self._stop.is_set():
                    return
                if not self._heap:
                    self._cv.wait(self._tick)
                    continue
                job = self._heap[0]
                wait = job.next_run - time.monotonic()
                if wait > 0:
                    self._cv.wait(min(wait, self._tick))  # 有新任务注册会被提前唤醒
                    continue
                heapq.heappop(self._heap)
                if job.running:  # 上一轮没跑完:跳过本次,不排队
                    job.next_run = time.monotonic() + job.interval
                    heapq.heappush(self._heap, job)
                    continue
                job.running = True
            threading.Thread(target=self._run, args=(job,), daemon=True).start()

    def start(self):
        self._thread.start()

这三个判断分别对应三类故障:

  1. 等待要有上限。wait(min(wait, tick)) 里的 tick 是兜底,防止堆顶任务在很远的将来时,注册新任务也无法及时生效;notify() 负责让新注册立刻打断等待。
  2. 运行中直接跳过。不排队是刻意的:如果任务稳定慢于周期,排队只会把内存和时间差一起堆爆,日志里全是过期执行。
  3. 执行必须离开锁。_loop 只负责派发,真正的函数体放到新线程里跑,否则一个慢任务会把整个调度循环按住。

派发之后的执行体是这样收尾的:

python
# 片段:以下方法同样接在 Scheduler 类内部
    def _run(self, job):
        started = time.monotonic()
        try:
            job.fn()
            job.failures = 0
        except Exception:
            job.failures += 1
            log.exception("job %s failed (attempt %d)", job.name, job.failures)
        finally:
            # 失败退避:间隔 × 2^n,封顶 8 倍;成功后退回原间隔
            delay = job.interval * min(2 ** job.failures, 8)
            job.running = False
            with self._cv:
                job.next_run = max(started + delay, time.monotonic())
                heapq.heappush(self._heap, job)
                self._cv.notify()

    def stop(self, timeout: float = 10.0):
        self._stop.set()
        with self._cv:
            self._cv.notify_all()
        self._thread.join(timeout)

三个判断之后是清理工作。无论成功还是异常,finally 都要把任务重新压回堆里,否则这个任务从此消失;running=False 也要在这里复位,不然下一轮会被永久跳过。

退避公式是 interval × 2^failures,最长 8 倍。它适合「上游短时间不可用」,不适合「必须准点」的场景——后者应该让任务失败并告警,而不是悄悄推迟。这套策略的代价是延迟不可控:连续失败三次后,一次重试可能排到 40 秒开外,用之前先确认业务能接受。

跑起来#

python
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO,
                        format="%(asctime)s %(levelname)s %(message)s")
    sched = Scheduler(tick=0.2)

    @sched.every(5, name="sync-ledger", catch_up=True)
    def sync_ledger():
        log.info("pull ledger ...")

    sched.start()
    try:
        time.sleep(30)
    finally:
        sched.stop()

启动后会立刻补跑一次,之后每 5 秒触发。catch_up=True 的语义仅仅是「进程起来先跑一次」,不是把停机期间漏掉的周期全部补上——这一点容易被误解。

调试时建议再加一行观测:记录 actual_start - job.next_run 的差值,也就是实际开始时间比计划晚了多少。这个值持续偏大,说明线程池已被占满或者锁竞争严重;偶尔尖刺则属于正常的 GC 和调度抖动。tick 决定了精度上限,默认 1 秒,要做到亚秒级就把它调小,代价是空转次数变多。

注意

stop() 只是不再派发新任务。已经在跑的任务会在 join 超时后被守护线程直接终止,写文件到一半就可能留下半成品。所有任务都应设计成可重入、可重试,落盘用「临时文件 + os.replace」。

上线前还得补三件事#

进程内调度器最大的风险不在代码,在部署形态。

场景 cron + flock 本文调度器
整点、每天固定时刻 合适 不擅长,需自己算时间
秒级、亚分钟间隔 做不到 合适
复用进程内连接池 做不到 合适
多副本只跑一次 需文件锁 做不到,必须外部锁
崩溃后补齐漏跑 做不到 只能补一次,不能补齐
  • 多副本:三台机器就是三份执行。用 Redis SET key val NX EX ttl 或数据库行锁,锁的 TTL 要大于单次执行时间,否则锁过期后仍会并发。
  • 卡死:Python 线程杀不掉。给任务加「开始时间 + 心跳」,超时就告警;真正的硬超时只能把任务丢进子进程。
  • 重启丢失:堆在内存里,重启即清空。要精确补偿,得把 last_run 落库,启动时按「漏了几个周期」决定补几次。

  • 重活别放进线程:这里的并发模型是「一个任务一个线程」,适合 IO 等待。真要做视频转码、大批量计算,线程会被 GIL 串行化,应该把任务提交给进程池,调度器只负责按时投递。

还有个容易忽略的坑:不要用 time.time() 做调度。NTP 回拨几分钟,调度器就会静默停摆同样长的时间;monotonic() 单调递增,只适合测量间隔,不能直接当墙钟时间写日志。

结论#

  • 用 heapq + Condition + 守护线程,80 行足够覆盖秒级周期的进程内调度。
  • dataclass(order=True) 里的函数字段必须 compare=False,这是最先撞上的坑。
  • 重叠执行选择「跳过」而不是「排队」,失败选择「指数退避」而不是「立即重试」。
  • 调度器不解决多副本去重和持久化,这两件事要在部署层补。
  • 用 monotonic() 计算间隔,用 time.time() 打日志,两者不要混用。

完

#Python#调度#标准库
回到顶部 ↑