Python的定时异步任务听起来像是个小功能,但真到生产环境里把它做稳,比想象中要绕得多。前段时间我在重构一个内部的数据采集服务,里面全是“每30秒拉一次行情”“每5分钟刷一次缓存”“每天凌晨3点做一次对账”这类需求。一开始偷懒,全用while循环加asyncio.sleep,跑了一周问题就接踵而至:任务越堆越多、延迟越来越大、偶尔某个协程异常,整个事件循环差点被带崩。后面我沉下心把asyncio的事件循环和loop.call_later调用彻底过了一遍,重新设计了一套定时异步任务调度方案,这才稳住。这篇文章把整个思考过程、关键源码逻辑、最终封装和踩坑记录都写出来,给同样在折腾异步定时任务的你一个参考。无论你是写爬虫、做数据采集,还是维护WebSocket长连接服务,这些内容应该都用得上。
1. 什么场景逼我认真研究定时异步任务
1.1 从同步定时到异步定时:问题不在“定时”而在“异步”
定时任务在业务里太常见了,常见到大家会随手写。爬虫需要定时抓取页面,微服务需要定时上报心跳,缓存服务需要定时刷新热点数据,消息队列消费者需要定时批量ack。这些需求本质都一样:在某个时间点或者每隔一段时间,触发一段业务逻辑。
同步编程里,大家用time.sleep、sched、threading.Timer,一套组合拳打下来好像也没什么问题。但一旦你用了asyncio,情况就变了。time.sleep会直接卡住整个线程,而asyncio的定时异步任务是在单线程事件循环里跑,你卡住线程就等于卡住了所有协程、所有IO事件、所有其他任务。这就逼着你去用asyncio自己的定时机制。
其实asyncio里做定时有两条路:一条是asyncio.sleep,一条是loop.call_later。大部分人的第一反应是前者,在while循环里await asyncio.sleep(5),简单直观。但如果你接触过前端,应该能理解setInterval和setTimeout的差异,call_later更像是asyncio对setTimeout的底层实现。要说清楚这个差异,得先把它的几个同类接口认识一遍。
1.2 三个相似API的分工:call_soon、call_later、call_at
asyncio事件循环提供了三个“注册回调”的接口,很多人容易搞混,我放在一起对比:
| API | 触发时机 | 传入参数 | 典型用途 |
|---|---|---|---|
loop.call_soon | 事件循环下一轮迭代,立即入队 | 普通函数 | 跨线程调度、把任务塞回主循环 |
loop.call_later | 当前时间 + delay秒之后 | 普通函数 | 一次性延迟调度、定时任务基础 |
loop.call_at | loop.time() + 指定绝对时间 | 普通函数 | 固定时间点触发,一般底层调用 |
注意一个关键点,这三个接口调度的都是“普通回调函数”,不是协程对象。如果你传入的是一个async def函数,它只会被创建出一个协程对象,然后立刻被丢弃,永远不会执行。正确的做法是:在普通回调里用asyncio.ensure_future或者loop.create_task把协程包装成Task。这个细节后面我会专门讲。
call_soon没什么时间精度可言,它就是把回调丢进“下一轮待执行”列表,只要当前正在执行的任务让出控制权,它就跑。call_later和call_at则是真正的“定时器”,它们返回一个TimerHandle对象,你可以通过handle.cancel()取消这次调度。
1.3 为什么说call_later特别适合做定时任务地基
拿闹钟来类比:asyncio.sleep像你设置的一个倒计时,倒计时结束你就醒了,但你睡觉的这段时间,状态是挂起的,你没法做别的事(虽然协程里它可以同时等多个东西)。而call_later像你拜托事件循环这个“班长”记在小本子上:到点了叫某个函数起床。你完全不占据任何资源,那个函数到点自然被调用。
这带来几个实际好处:第一,延迟时间非常精确,它从注册那一刻到触发,走的是事件循环内部的高精度时钟;第二,调度的是普通函数,不需要专门创建一个Task对象去“等时间”,省内存、省调度开销;第三,返回的TimerHandle可以精准取消;第四,它可以递归注册自己,实现周期任务,而且能在“任务运行完之后再计算下一次间隔”,天然规避了睡眠式循环的任务重叠问题。
我最终选它做地基,就是看中这四点。接下来我们深入看一下它内部到底怎么工作的,这决定了后面封装调度器的边界在哪。
2. loop.call_later的源码级拆解:它怎么做到“到点执行”
2.1 事件循环的时钟体系:loop.time() 不是 time.time()
很多人第一次看到loop.call_at会懵:这个when到底填什么?是time.time()加几秒吗?不是。asyncio内部有一套独立的时钟,来自loop.time(),它返回的是单调递增时钟的秒数,跟墙上时间没有任何关系。
为什么不用time.time()?因为墙上时钟是可以被用户修改、被NTP同步的。如果系统时间被往前回调了2秒,你基于time.time()写的定时器就会全部推迟2秒。这对金融行情采集这种场景是无法接受的。单调时钟只能单调递增,不受系统时间跳变影响,这才适合做调度。
看CPython的源码就一目了然:
def call_later(self, delay, callback, *args, context=None): if delay is None: raise TypeError('delay must not be None') timer = self.call_at(self.time() + delay, callback, *args, context=context) return timercall_later本质上就是call_at(self.time() + delay, ...)。把相对时间转成绝对时间,然后丢给call_at。所以call_at的when参数,指的是“单调时钟上的绝对时间点”,你一般不会直接用它,都是透过call_later传相对时间。
2.2 定时器在事件循环内部是如何管理的
call_at的实现也很有趣,它在asyncio的base_events.py里,关键代码是:
def call_at(self, when, callback, *args, context=None): timer = events.TimerHandle(when, callback, args, self, context) heapq.heappush(self._scheduled, timer) return timer_scheduled是一个最小堆,堆里的元素是TimerHandle。每个TimerHandle内部存了触发时间when和一个自增的序列号_seq。为什么要有序列号?因为最小堆在时间相同的时候,需要有一个确定的排序依据,这就是先进先出(FIFO)——先注册的回调先执行。
事件循环每一轮迭代的时候,会做这样一件事:
while self._scheduled: handle = self._scheduled[0] if handle._when > self.time(): break handle = heapq.heappop(self._scheduled) handle._scheduled = False self._ready.append(handle)翻译成大白话:事件循环每次都看堆顶那个定时器,如果它的触发时间还没到,就跳出循环,该干嘛干嘛去;如果到了,就把它从堆里弹出来,丢进“待执行队列”,然后继续看下一个定时器。因为堆顶永远是最早的定时器,所以这个判断是高效的,你注册一万个定时任务,它也只是比O(1)复杂一点点。
理解了这点,你就明白了一个重要结论:call_later的精度,取决于事件循环多久迭代一次。如果某个协程内部做耗时运算不await,事件循环根本没机会跑,所有定时任务都会跟着延误。这不是call_later的问题,是你阻塞了事件循环。
2.3 Handle与取消:别小看你拿到的这个返回对象
loop.call_later返回的是TimerHandle,它是Handle的子类。很多初学者拿到返回对象随手丢掉了,等到想取消任务的时候才发现没有把柄。这里有两个使用原则:
原则一:只要你想在某个条件下取消这个定时任务,就必须保存这个Handle。
import asyncio def say_hello(): print("hello") async def main(): loop = asyncio.get_running_loop() handle = loop.call_later(5, say_hello) await asyncio.sleep(1) handle.cancel() print(f"after cancel, handle cancelled = {handle.cancelled()}") asyncio.run(main())handle.cancel()之后,回调不会再被触发。但这个操作只对“尚未执行”的回调有效。如果事件循环已经把它从堆里取出来放进_ready了,取消就来不及了。
原则二:Handle可以被重复调用cancel(),不会报错。它在内部维护了_cancelled标志,取消后再取消是安全的操作。
我还遇到过一种需求:任务本来想取消,但希望给一个信号让回调内部知道自己被取消了。这时可以拿handle.cancelled()作为判断条件,但要注意,这个状态一旦设为True,后续就算你反悔了也撤销不了。
3. 手写一个满足生产需求的AsyncScheduler
3.1 最朴素的call_later周期任务写法
直接用call_later实现“每隔5秒执行一次”,最朴素的写法是递归注册:
import asyncio def set_interval(loop, interval, callback): def _run(): callback() loop.call_later(interval, _run) loop.call_later(interval, _run) def job(): print("job running") async def main(): loop = asyncio.get_running_loop() set_interval(loop, 2, job) await asyncio.sleep(7) asyncio.run(main())这个写法值得玩味的地方在于_run的执行顺序:先执行callback(),然后才注册下一次。这意味着什么?如果callback()同步阻塞了3秒,那么“下次执行”是在3秒后再过2秒,也就是两次执行的实际间隔是5秒,而不是你以为的2秒。
这种“执行完再等一个周期”的策略,天然防止了任务重叠。但它的代价是:如果任务执行时间大于间隔,周期会无限拉长,变成一个“每次执行完休息固定时间”的模式。这个特性放在后面坑三里细聊。
3.2 从同步回调升级到异步协程回调
上面例子里的callback是普通函数,实际业务里,我们的定时任务基本是async def函数,可能要await数据库查询、awaitHTTP接口、await队列拉取。call_later不接受协程函数,需要包一层。
错误示范:
async def async_job(): print("job start") await asyncio.sleep(1) print("job end") # 直接这样调只会看到一个warning,协程永远不会执行 loop.call_later(2, async_job)正确做法是在普通回调里创建Task:
import asyncio def schedule_async(loop, delay, coro_func): def _run(): asyncio.ensure_future(coro_func()) loop.call_later(delay, _run)asyncio.ensure_future会把协程包装成Task,事件循环就会在后台调度它。这里有个隐藏风险:被包装后的Task如果没人持有引用,会不会被垃圾回收?在Python 3.7之后,事件循环内部会强引用所有未完成的Task,所以不用担心这一点。但它也带来另一个坑:Task异常没有被处理的话,会走异常处理器,可能静默。这点放到第四章详细说。
3.3 完整封装:支持异常隔离、状态控制的调度器
知道了基本原理,就可以封装一个像样的调度器了。我这里给出一版我在采集服务里实际用过的简化版本,去掉业务耦合,保留核心骨架:
import asyncio import logging from typing import Any, Awaitable, Callable, Optional logger = logging.getLogger(__name__) class AsyncScheduler: """基于loop.call_later实现的定时异步任务调度器。 特性: - 支持注册异步协程函数,周期执行 - 任务异常不会影响调度器,只记录日志 - 支持取消单个任务和全部任务 - 记录执行次数和最近执行状态 """ def __init__(self, loop: Optional[asyncio.AbstractEventLoop] = None): self._loop = loop or asyncio.get_event_loop() self._jobs: dict[str, dict[str, Any]] = {} self._ids = 0 def add_job( self, coro_func: Callable[..., Awaitable], interval: float, *args, run_immediately: bool = False, **kwargs, ) -> str: """注册一个异步定时任务,返回job_id,可用它取消任务。""" self._ids += 1 job_id = f"job-{self._ids}" self._jobs[job_id] = { "active": True, "coro_func": coro_func, "args": args, "kwargs": kwargs, "interval": interval, "handle": None, "task": None, "run_count": 0, "last_error": None, "last_run_at": None, } if run_immediately: self._run_job(job_id) else: self._schedule_next(job_id) return job_id def _schedule_next(self, job_id: str) -> None: job = self._jobs.get(job_id) if not job or not job["active"]: return job["handle"] = self._loop.call_later( job["interval"], self._run_job, job_id ) def _run_job(self, job_id: str) -> None: job = self._jobs.get(job_id) if not job or not job["active"]: return coro = job["coro_func"](*job["args"], **job["kwargs"]) task = asyncio.ensure_future(coro, loop=self._loop) job["task"] = task def _done(t: asyncio.Task) -> None: if not self._jobs.get(job_id): return job = self._jobs[job_id] job["run_count"] += 1 if t.cancelled(): job["last_error"] = "cancelled" else: exc = t.exception() if exc: job["last_error"] = repr(exc) logger.error("job %s 执行异常: %s", job_id, exc, exc_info=exc) else: job["last_error"] = None self._schedule_next(job_id) task.add_done_callback(_done) def cancel_job(self, job_id: str) -> bool: job = self._jobs.get(job_id) if not job: return False job["active"] = False if job["handle"]: job["handle"].cancel() if job["task"] and not job["task"].done(): job["task"].cancel() return True def cancel_all(self) -> None: for job_id in list(self._jobs.keys()): self.cancel_job(job_id) def stats(self, job_id: str) -> Optional[dict[str, Any]]: return self._jobs.get(job_id)用法示例:
import asyncio async def fetch_price(symbol: str): print(f"fetching {symbol}...") await asyncio.sleep(0.5) print(f"fetched {symbol}") async def main(): scheduler = AsyncScheduler() scheduler.add_job(fetch_price, 2.0, "BTC", run_immediately=True) scheduler.add_job(fetch_price, 3.0, "ETH") await asyncio.sleep(7) scheduler.cancel_all() asyncio.run(main())运行后日志会显示两个任务分别以2秒和3秒的周期执行,互不干扰。
3.4 封装过程中的关键设计决策
这里有几个我反复斟酌过的点,值得单独解释:
第一,为什么用call_later递归而不是用asyncio.sleep循环。asyncio.sleep写法会额外占用一个Task对象,而且每次循环都要经历“被唤醒 → 执行协程 → 再sleep”的完整状态机。call_later递归实现了同样的效果,但周期性靠的是事件循环的定时器,不需要额外的协程等待,语义上更贴近“到点了该干活了”。
第二,为什么在done_callback里继续调度,而不是在_run_job一开始就注册下一次。如果在注册时就把下一次排好,任务执行时间大于间隔时,就会产生重叠执行,多个实例并发跑同一个任务。这在很多场景是有害的:数据库连接池会被打爆,外部接口会被限流。在done_callback里调度,天然保证一个任务实例完全结束后才启动下一个。
第三,为什么任务执行异常也要继续调度。定时任务的特点是“这次挂了,不代表下次不该跑”。比如定时对账失败,可能是临时数据没准备好,如果直接停止调度,就等于这个任务永久失联了。我们录日志、记录last_error,然后继续下一轮,这才是定时任务应该有的自愈姿态。真正确认任务不可恢复时,应该显式调用cancel_job。
4. 定时任务跑一星期后,我踩过的那些坑
封装写完了,不代表就高枕无忧了。这个调度器在我服务里跑了快一周之后,各种幺蛾子才真正开始冒头。下面这五个坑,全部来自真实排查过程,我不按难度排,按“踩坑频率”排。
4.1 坑一:回调里出现同步阻塞,整个loop跟着卡死
某天我的采集任务突然全部延迟了十几秒,但进程还活着,CPU不高,也没有报错。先怀疑是不是数据库慢了,查了一圈都没有。最后看事件循环的监控日志,发现有个定时任务每隔一段时间会卡住事件循环大概10秒。
定位过程是这样的:我加了这样一个探测监控:
import time def monitor_loop(): loop = asyncio.get_running_loop() last_check = loop.time() def _check(): nonlocal last_check now = loop.time() if now - last_check > 1: print(f"loop blocked for {now - last_check:.2f}s") last_check = now loop.call_later(0.5, _check) loop.call_later(0.5, _check)这个0.5秒定期检查的定时器,一旦发现“距离上次检查过去了超过1秒”,就说明有一段时间事件循环被阻塞了。跑了一晚上,定位到是一个同事在某个定时任务里直接用了requests.get(),同步HTTP请求卡了几秒,导致整个loop瘫痪。
解法很简单:在异步任务里用httpx.AsyncClient或者aiohttp,如果不得不调用同步库,就塞进线程池:
await asyncio.to_thread(requests.get, "https://example.com/api")Python 3.9+直接用asyncio.to_thread,更老版本用loop.run_in_executor(None, func, args)。核心思想是:任何可能阻塞IO或耗时的同步代码,都不要直接跑在事件循环线程里。
4.2 坑二:协程异常没有逃逸,任务静默消失
第二个坑更隐蔽。某个定时任务跑着跑着就“失联”了,调度器日志里没有错误,但我发现它的run_count停止增长了。
问题出在asyncio.ensure_future创建的Task,它内部的异常如果不被await、没有done_callback读取,事件循环默认会走loop.get_exception_handler()。关键是,asyncio默认的异常处理器在3.8之后会打一条日志,但很多人没配置日志级别,或者被第三方库的日志处理器吞掉了,看起来就像什么都没发生。
我犯的错误是在早期版本里对_run_job没有加add_done_callback,导致某个协程在第50次执行时因为一个脏数据抛了KeyError,但异常只是进了默认处理器,服务依然“正常”运转,只有数据不再更新了。
解决思路分两步。第一步,在调度器里强制读取每个Task的异常,这就是我上面封装里_done回调里调用t.exception()的原因。只要调用了exception(),就算我们不处理,这个异常也不会被asyncio当作“未处理异常”二次上报,同时我们能记录到last_error。第二步,设置一个全局异常处理器,把所有漏网之鱼集中落日志:
def global_exception_handler(loop, context): exception = context.get("exception") if exception: print(f"unhandled exception: {exception}") # 可换成logging else: print(f"loop error: {context['message']}") loop.set_exception_handler(global_exception_handler)这个处理器能兜住很多你意想不到的异常:Task被取消、socket读取超时、Future内部错误等等。
4.3 坑三:任务漂移与堆积——sleep循环的隐形陷阱
我最早用asyncio.sleep写周期任务时,代码长这样:
async def bad_loop(): while True: await do_work() await asyncio.sleep(interval)看起来没问题,但你算一笔账:如果do_work()耗时是1秒,interval是2秒,那实际周期是3秒;如果某一次do_work()因为网络重试耗时8秒,之后虽然正常了,但周期已经变成了“8秒+2秒=10秒”,而且后面每次都是10秒。这就是漂移:周期一旦被拉长,永远不会自动恢复。
call_later递归版本也有类似特性,但它好一点,因为它是“执行完后,再过interval秒”,所以只要某次跑得久,下一次起点自动是“完成后+interval”,不会出现重叠,也不会无限累积。
如果你要求“固定频率”,也就是不管任务跑多久,每隔interval秒必须触发一次,那就不能简单递归了。需要记录开始时间:
async def fixed_rate_runner(): next_time = loop.time() + interval while True: await do_work() next_time += interval remaining = next_time - loop.time() if remaining > 0: await asyncio.sleep(remaining) else: # 已经跟不上频率了,跳过等待立刻执行,但提醒告警 logger.warning("task can't keep up: behind by %.2fs", -remaining)这种“按绝对时间轴累加”的思路,能避免漂移,但引入了重叠风险:如果do_work耗时超过interval,下一个周期已经到点了,任务还在跑。所以固定频率通常要配合信号量或锁,限制最大并发数。
到底选哪个策略,取决于业务:拉行情希望固定频率,做对账希望不重叠,没有银弹。你们在设计调度器时,先想清楚这个任务能不能重叠运行。
4.4 坑四:多线程里调用loop.call_later会报错
我的采集服务里有一个生产者线程,从外部消息队列拉数据,线程内部想往asyncio主循环里丢定时任务,直接调loop.call_later,跑了几分钟抛了RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one。
这个错误很直白:call_later不是线程安全的,不能在非事件循环线程里直接调用。跨线程调度事件循环内的工作,必须用loop.call_soon_threadsafe。它内部会做两件事:加锁把回调塞进_ready队列;同时给事件循环写一个self._write_to_self(),让事件循环的唤醒管道收到通知,从而立刻从select()里醒来处理新任务。
正确的跨线程写法:
def schedule_from_thread(loop, delay, callback): def _schedule(): loop.call_later(delay, callback) loop.call_soon_threadsafe(_schedule)当然更简单的建议是:尽量别跨线程操作loop,把所有调度都放在协程里做。如果有传统线程模块必须配合,就用concurrent.futures把协程包成asyncio.Future,让主循环用await loop.run_in_executor(...)等结果,而不是反过来让线程去碰loop。
4.5 坑五:程序退出时,未完成任务和定时器被“吊死”
这个坑在开发和运维环境都容易遇到。我用Ctrl+C结束采集服务时,发现进程退得拖泥带水,甚至像卡死一样。原因很简单:asyncio.run(main())退出的瞬间,事件循环还没关闭,但我注册的call_later定时器还有效,任务协程还挂在后台,进程把这些都当作需要清理的对象。
如果你只是简单地在main末尾加了await asyncio.sleep(100)想挂住进程,退出时就会看到Task was destroyed but it is pending!的告警。这是提醒你有Task没被正确取消。
优雅退出需要显式清理:
async def shutdown(scheduler: AsyncScheduler, loop: asyncio.AbstractEventLoop): print("shutting down...") scheduler.cancel_all() tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptions=True) loop.run_until_complete(loop.shutdown_asyncgens()) loop.run_until_complete(loop.shutdown_default_executor()) print("shutdown complete")这里有两个细节。第一,loop.shutdown_default_executor()是3.9之后才有的,用于等待线程池任务收尾;第二,all_tasks()会包含当前任务本身,要排除掉,否则取消自己会出问题。
生产环境里,我建议把主程序写成事件循环+信号处理的模式:监听SIGINT和SIGTERM,收到信号就启动优雅退出流程,而不是让进程被默认的KeyboardInterrupt粗暴打断。这个模式配合上面的shutdown函数,基本能保证每次重启不丢任务、不残留定时器。
5. 别急着造轮子:自研、原生API和成熟框架的取舍
5.1 三种方案的横向对比
我的调度器封装在实际服务里跑得很稳,但我必须承认,它不是所有场景的最优解。定时异步任务这个领域,已经有很成熟的方案。做个对比:
| 方案 | 调度精度 | 功能丰富度 | 依赖成本 | 适用场景 |
|---|---|---|---|---|
loop.call_later自研 | 高,毫秒级 | 低,需要自己写异常、重试 | 零依赖 | 少量定时任务、需要深度定制 |
asyncio.sleep循环 | 中,会漂移 | 极低 | 零依赖 | 一次性脚本、原型验证 |
| APScheduler AsyncIOScheduler | 高 | 高,支持cron表达式、持久化、多种触发器 | 需安装apscheduler库 | 复杂调度规则、多任务管理 |
| Celery beat | 中高 | 非常全,但面向分布式 | 重,需要broker | 分布式系统中需要任务队列配合 |
自研方案最大的优势其实是“透明”:每一行代码都是自己写的,出了问题能直接定位。但如果你只是需要一个“每天凌晨2点执行一次”的任务,用APScheduler写三行就完事了,没必要自己实现一整套cron解析。
以APScheduler的异步调度器为例,它的核心用法简洁得多:
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.cron import CronTrigger import asyncio async def collect_report(): print("collecting report...") async def main(): scheduler = AsyncIOScheduler() scheduler.add_job( collect_report, CronTrigger(day_of_week="mon-fri", hour="3", minute="0") ) scheduler.add_job( collect_report, IntervalTrigger(seconds=30) ) scheduler.start() await asyncio.Event().wait() # 挂住进程 asyncio.run(main())APScheduler内部也用了call_later类似的机制,但它替你解决了很多边界问题:任务可以持久化到数据库,支持错过任务的misfire_grace_time容错,支持任务合并和最大实例数限制。如果项目里调度需求超过“简单周期执行”,我非常建议直接上它。
5.2 我最终保留自研方案的理由
你可能会问:既然APScheduler这么好,为什么我最后还是保留自研调度器?
原因有两个。第一,我的场景极其简单:所有任务都是“固定间隔、不重叠、运行完再等一个周期”,这个模式用call_later递归实现非常干净,代码量不到80行,不需要引入第三方库。第二,我需要高度可控的监控和统计:每次运行耗时、异常详情、任务状态都要写入内部监控系统,AsyncScheduler的stats()接口能直接对接。APScheduler虽然也有事件监听,但要在它的框架里做这种定制,反而更费劲。
所以我的建议是:如果你刚接触异步定时任务,先别急着装框架,用loop.call_later手写一遍,把事件循环的脾气摸清楚。这个过程中积累的体感,会让后面用APScheduler时少踩很多坑。等你确实需要复杂调度了,再换不迟。
5.3 一个容易被忽略的性能建议
最后补充一个性能层面的细节:当定时任务数量很多(几百上千)时,call_later的堆管理依然高效,但每个定时任务都创建Task的成本不可忽视。如果某类任务只是“每隔一段时间检查一个状态”,没必要每次创建Task,可以直接写成同步回调,让回调本身只做轻量判断,真正需要执行异步操作时再ensure_future。
举个例子,我有200个连接需要每10秒检查一次心跳,如果用200个协程任务去做,内存和调度开销都不小。更优的做法是注册一个call_later回调,在回调里用for循环检查所有连接,发现异常才启动一个Task去处理。这种“批处理+按需异步”的模式,能让事件循环轻松上千个定时器。这也是这次重构下来,我个人最想分享的一个工程经验:定时任务的数量,不等于Task的数量,能用回调批量解决的就不要为每个任务单独开协程。
定时异步任务在asyncio里其实是一套很有魅力的机制,call_later作为它的地基,把“到点触发”这件事做得极其干净。如果你正在做类似功能,建议先花半小时跑一下我上面的最小示例,把Handle、cancel()、递归注册这几个概念玩熟,再决定是自研还是接框架。等你真正用起来,会发现定时异步任务并没有想象中那么复杂,复杂的是对事件循环运行规则的理解程度。