1. 定时
异步程序经常需要暂时等待:一秒后重试请求、三十秒后判定超时、每隔一段时间刷新缓存,或者在指定时间执行一次回调。这些需求看起来都是「过一段时间再运行」,但实际包含三类不同问题:
| 需求 | 常用方式 |
|---|---|
| 当前协程暂停一段时间 | await asyncio.sleep() |
| 将普通函数安排到未来执行 | loop.call_later()、loop.call_at() |
| 限制异步操作的最长等待时间 | asyncio.timeout()、asyncio.wait_for() |
asyncio 不会为每个定时器创建一个线程。事件循环会记录定时器的截止时间,计算自己最多能等待多久,并在时间到达后把对应的 Task 或回调放入可运行队列。
定时器到期只表示工作已经具备运行条件,不表示代码一定会在那个时刻精确执行。如果事件循环当时正在运行耗时的同步代码,或者还有其他就绪任务需要处理,这项工作只能等事件循环再次取得控制权。
01import asyncio02import time0304async def main():05started_at = time.perf_counter()06await asyncio.sleep(1)0708elapsed = time.perf_counter() - started_at09print(f"实际等待:{elapsed:.3f} 秒")1011asyncio.run(main())
输出通常在一秒附近,并可能因为调度开销略大于一秒。这里的一秒应该理解为目标延迟,而不是实时系统提供的精确执行承诺。
2. sleep
asyncio.sleep(delay) 是应用代码中最常用的延迟方式。它会暂停当前 Task,并允许事件循环继续推进其他 Task:
01import asyncio0203async def remind(name, delay):04print(f"{name} 开始等待")05await asyncio.sleep(delay)06print(f"{name} 继续执行")0708async def main():09await asyncio.gather(10remind("任务 A", 1),11remind("任务 B", 0.2),12)1314asyncio.run(main())
两个任务会先后开始等待。任务 B 的时间先到,因此通常先恢复;任务 A 在这段时间里并没有占用一个线程空等。
sleep() 接收的延迟单位是秒,可以使用整数或小数。它还可以接收一个 result 参数,并在等待结束后返回这个值:
1import asyncio23async def main():4result = await asyncio.sleep(0.1, result="继续执行")5print(result)67asyncio.run(main())
这种返回值用法并不常见,大多数代码只把 sleep() 当作等待点。
主动让出执行权
await asyncio.sleep(0) 不会进行有意义的时间延迟,但仍然会暂停当前 Task,让事件循环有机会推进其他已就绪任务:
01import asyncio0203async def calculate():04total = 00506for number in range(1_000_000):07total += number0809if number % 100_000 == 0:10await asyncio.sleep(0)1112return total1314asyncio.run(calculate())
这可以避免一段较长的 Python 循环始终占住事件循环,但它不会让 CPU 计算变快,也不会把计算变成并行任务。CPU 密集型工作通常应该拆分、优化,或者交给其他进程,而不是不断插入 sleep(0)。
阻塞等待
time.sleep() 是同步阻塞函数。在事件循环线程中调用它时,不只是当前协程暂停,整个事件循环都无法调度其他 Task:
01import asyncio02import time0304async def bad_task(name):05print(f"{name} 开始")06time.sleep(1)07print(f"{name} 完成")0809async def main():10started_at = time.perf_counter()1112await asyncio.gather(13bad_task("任务 A"),14bad_task("任务 B"),15)1617print(f"总耗时:{time.perf_counter() - started_at:.1f} 秒")1819asyncio.run(main())
这两个任务的总耗时接近两秒,因为任务 A 调用 time.sleep() 后,任务 B 连开始执行的机会都没有。把它改成 await asyncio.sleep(1) 后,两个 Task 可以在同一段时间内等待,总耗时会接近一秒。
同样需要注意:没有 await 的长循环、大量 JSON 解析或其他耗时同步函数,也会像 time.sleep() 一样拖延所有定时器。
3. 回调
如果需要在未来执行一个普通函数,可以使用事件循环的 call_later():
01import asyncio0203def show_message(message):04print(message)0506async def main():07loop = asyncio.get_running_loop()08loop.call_later(1.5, show_message, "一段时间后执行")09print("回调已经注册")1011await asyncio.sleep(2)1213asyncio.run(main())
call_later(delay, callback, *args) 的参数可以这样理解:
| 参数 | 含义 |
|---|---|
delay | 从现在开始等待多少秒 |
callback | 到期后调用的普通函数 |
*args | 调用回调时传入的位置参数 |
回调只会被调用一次。如果多个回调被安排在完全相同的时间点,官方并不保证它们之间的执行顺序,因此业务逻辑不应依赖这个顺序。
call_later() 返回的是 TimerHandle,不是 Task。它不能被 await,也不会替调用者保存回调的返回值。回调发生异常时,异常会交给事件循环的异常处理机制,而不是像 await task 那样直接抛回当前协程。因此,需要结果、异常传播和取消链路时,通常应该使用协程与 Task。
这些底层调度方法不能直接传递关键字参数。需要关键字参数时,可以使用 functools.partial():
01import asyncio02from functools import partial0304def show_message(message, *, prefix):05print(f"{prefix} {message}")0607async def main():08loop = asyncio.get_running_loop()09callback = partial(show_message, "缓存已刷新", prefix="[定时器]")10loop.call_later(0.5, callback)1112await asyncio.sleep(1)1314asyncio.run(main())
相比匿名函数,partial() 通常能在调试信息中提供更清楚的回调描述。
TimerHandle
call_later() 返回一个 TimerHandle,它代表刚刚注册的定时回调:
01import asyncio0203def show_message():04print("这条消息不会出现")0506async def main():07loop = asyncio.get_running_loop()08handle = loop.call_later(2, show_message)0910print(handle.when() - loop.time())1112handle.cancel()13print(handle.cancelled()) # True1415await asyncio.sleep(2.2)1617asyncio.run(main())
cancel() 可以取消尚未执行的回调,重复取消不会产生额外影响;cancelled() 用于检查回调是否被取消;when() 返回回调原本计划执行的事件循环时间。
如果回调已经执行,再调用 cancel() 无法撤销已经发生的操作。取消 TimerHandle 也只会取消这一次回调,不会自动取消回调中另外创建的 Task。
4. 时间
call_later() 使用相对延迟,而 call_at() 使用事件循环时钟上的绝对时间点:
01import asyncio0203def show_message():04print("到达计划时间")0506async def main():07loop = asyncio.get_running_loop()08deadline = loop.time() + 10910handle = loop.call_at(deadline, show_message)11print(handle.when() == deadline) # True1213await asyncio.sleep(1.2)1415asyncio.run(main())
loop.time() 返回事件循环内部使用的单调时钟。它适合计算「过去了多久」或「还剩多久」,但这个数字不是 Unix 时间戳,也不能转换成日历日期。
如果传给 call_at() 的时间已经过去,回调会在事件循环下一次有机会调度时尽快执行。出于性能考虑,定时回调理论上可能在单调时钟一个分辨率范围内略早触发;事件循环繁忙时则可能明显延后。因此,不要用它实现依赖毫秒级精度的实时控制。
单调时钟的重要特点是不会因为用户修改系统时间、网络校时或夏令时变化而突然跳跃。因此,计算延迟和截止时间时,应使用同一种时钟:
01import asyncio0203async def main():04loop = asyncio.get_running_loop()05started_at = loop.time()0607await asyncio.sleep(0.5)0809elapsed = loop.time() - started_at10print(f"经过了 {elapsed:.1f} 秒")1112asyncio.run(main())
不要把 time.time()、datetime.now().timestamp() 得到的墙上时钟时间直接传给 call_at()。它们和 loop.time() 不使用同一个时间基准。
如果需求是「今天 18:00 执行」,可以先用 datetime 计算它距离现在还有多少秒,再使用 call_later():
01import asyncio02from datetime import datetime, timedelta0304def show_message():05print("到达目标时间")0607async def main():08loop = asyncio.get_running_loop()0910target = datetime.now() + timedelta(seconds=1)11delay = max(0, (target - datetime.now()).total_seconds())1213loop.call_later(delay, show_message)14await asyncio.sleep(delay + 0.2)1516asyncio.run(main())
这种方式适合进程存活期间的一次短期安排。需要跨进程重启保存、按时区运行、错过后补偿或在多台服务器中只执行一次的日历任务,应使用持久化调度器或任务队列。
5. 协程
call_later() 和 call_at() 的 callback 必须是普通可调用对象。直接传入异步函数并不会自动等待它:
01import asyncio0203async def send_reminder():04print("发送提醒")0506async def main():07loop = asyncio.get_running_loop()0809# 错误:到期后只会创建协程对象,没有代码负责 await 它10# loop.call_later(1, send_reminder)1112await asyncio.sleep(1.2)1314asyncio.run(main())
异步函数被普通回调调用时只会返回协程对象,这个对象不会自行运行,还可能产生 coroutine was never awaited 警告。
应用代码需要延迟执行协程时,更自然的方式是创建一个负责等待的协程,再把它包装成 Task:
01import asyncio0203async def send_reminder(message):04await asyncio.sleep(0.1)05print(message)0607async def run_later(delay, async_function, *args):08await asyncio.sleep(delay)09return await async_function(*args)1011async def main():12task = asyncio.create_task(13run_later(1, send_reminder, "该休息一下了"),14name="delayed-reminder",15)1617print("提醒任务已经创建")18await task1920asyncio.run(main())
这样得到的是正常 Task,可以等待结果、接收异常,也可以在提醒尚未执行时取消整条调用链。
底层库确实需要从定时回调启动协程时,可以让普通回调调用 create_task(),但仍然要保存 Task 引用并处理异常。普通业务代码优先使用上面的 sleep() 加 Task 结构,会更容易管理生命周期。
6. 超时
延迟解决的是「多久以后继续」,超时解决的是「最多允许等待多久」。从 Python 3.11 开始,可以使用 asyncio.timeout() 给一段异步代码设置时间限制:
01import asyncio0203async def fetch():04await asyncio.sleep(2)05return "数据"0607async def main():08try:09async with asyncio.timeout(1):10result = await fetch()11print(result)12except TimeoutError:13print("请求超时")1415asyncio.run(main())
超时发生时,asyncio.timeout() 会取消当前 Task,在上下文管理器内部处理 CancelledError,并把它转换成 TimeoutError。因此,TimeoutError 要在 async with 外部捕获。
一个超时范围可以包含多个异步操作,限制它们共同消耗的总时间:
01import asyncio0203async def step(name, delay):04await asyncio.sleep(delay)05print(f"{name} 完成")0607async def main():08try:09async with asyncio.timeout(1):10await step("第一步", 0.6)11await step("第二步", 0.6)12except TimeoutError:13print("整个流程超时")1415asyncio.run(main())
每一步单独看都没有超过一秒,但两步合计超过一秒,因此整个代码块会超时。这很适合限制一次接口请求或一条 Agent 执行链的总耗时。
如果已经计算出事件循环时钟上的截止时间,可以使用 asyncio.timeout_at():
01import asyncio0203async def main():04loop = asyncio.get_running_loop()05deadline = loop.time() + 10607try:08async with asyncio.timeout_at(deadline):09await asyncio.sleep(2)10except TimeoutError:11print("超过截止时间")1213asyncio.run(main())
只想限制一个可等待对象时,也可以使用 asyncio.wait_for():
01import asyncio0203async def fetch():04await asyncio.sleep(2)05return "数据"0607async def main():08try:09result = await asyncio.wait_for(fetch(), timeout=1)10print(result)11except TimeoutError:12print("请求超时")1314asyncio.run(main())
| 方式 | 适合场景 |
|---|---|
asyncio.timeout() | 限制一个异步代码块的总时间 |
asyncio.timeout_at() | 已经有基于 loop.time() 的绝对截止时间 |
asyncio.wait_for() | 限制单个可等待对象 |
wait_for() 超时后会取消被等待对象,并等待它完成取消流程,所以函数实际返回的时间可能略晚于设置的超时时间。asyncio.timeout() 同样依赖协作式取消;被调用协程应该在 finally 中及时释放连接、文件和临时状态,不要吞掉 CancelledError。
从 Python 3.11 开始,这些 API 抛出内置的 TimeoutError。旧代码中常见的 asyncio.TimeoutError 已经不再是需要单独使用的异常类型。
7. 周期
asyncio 没有一个自动重复执行协程的 setInterval()。简单的周期任务通常使用循环和 asyncio.sleep() 实现:
01import asyncio0203async def check_status():04print("开始检查")05await asyncio.sleep(0.3)06print("检查完成")0708async def poll():09for _ in range(3):10await check_status()11await asyncio.sleep(1)1213asyncio.run(poll())
这种方式是在一次检查完成后,再等待一秒。假设检查本身需要 0.3 秒,两次开始时间的间隔就接近 1.3 秒。这种调度方式叫固定延迟,适合不希望任务重叠的轮询。
如果希望任务尽量按照事件循环时钟上的固定时间点开始,就要根据目标时间计算每次等待长度:
01import asyncio0203async def check_status():04print("检查状态")05await asyncio.sleep(0.3)0607async def poll(interval, count):08loop = asyncio.get_running_loop()09next_run = loop.time()1011for index in range(count):12await check_status()1314if index == count - 1:15break1617next_run += interval18delay = max(0, next_run - loop.time())19await asyncio.sleep(delay)2021asyncio.run(poll(interval=1, count=3))
这段代码会把任务耗时从下一次等待时间中扣除,因此开始时间更接近每秒一次。它仍然不是实时调度:如果某次检查超过一个周期,delay 会变成零,下一轮会尽快执行;它不会自动并行启动漏掉的任务。
两种方式没有绝对优劣:
| 方式 | 开始间隔 | 适合场景 |
|---|---|---|
| 固定延迟 | 任务耗时加等待时间 | 轮询、重试、不允许重叠的工作 |
| 固定频率 | 尽量对齐预定时间点 | 心跳、采样、界面刷新节奏 |
如果任务执行时间可能超过周期,需要明确选择跳过错过的周期、立即补跑,还是允许多个实例重叠。不要让这个行为由偶然的执行速度决定。
停止周期任务
无限循环应该放在 Task 中,并通过取消来停止。协程使用 finally 完成清理:
01import asyncio0203async def heartbeat():04try:05while True:06print("发送心跳")07await asyncio.sleep(1)08finally:09print("关闭心跳连接")1011async def main():12task = asyncio.create_task(heartbeat(), name="heartbeat")1314await asyncio.sleep(2.5)15task.cancel()1617try:18await task19except asyncio.CancelledError:20print("心跳任务已停止")2122asyncio.run(main())
取消 Task 会中断它当前正在等待的 sleep()。调用方仍然要 await task,这样才能确认清理已经完成,并取走取消状态或其他异常。
周期任务中的异常也不能忽略。未处理异常会让这个 Task 直接结束,后续周期不再执行;如果业务需要重试,应只捕获明确可以恢复的异常,记录失败并应用退避策略,而不是用一个宽泛的 except 隐藏所有问题。
8. 边界
事件循环定时器适合进程内、允许随进程一起结束的短期调度,例如:
- 等待一段时间后重试;
- 限制网络请求或模型调用的最长耗时;
- 每个服务进程各自维护心跳;
- 定期刷新当前进程的内存缓存。
它不适合承担必须可靠执行的业务任务。进程重启、服务扩缩容或部署更新时,内存中的 TimerHandle 和 Task 都会丢失。
在 FastAPI 中还要考虑 worker 数量。如果启动四个 worker,每个进程都有自己的事件循环;在应用启动阶段创建一个周期 Task,通常会得到四份相同任务。对于「全系统只能执行一次」的账单生成、定时通知或数据归档,应使用具备持久化、分布式锁、失败重试和监控能力的任务队列或调度系统。
最后,定时器也不是解决阻塞问题的工具。回调到期后如果执行了耗时同步代码,仍然会阻塞事件循环。同步 IO 可以考虑 asyncio.to_thread(),CPU 密集型计算则更适合进程池或独立 worker。