Python 超时为什么不准:用 monotonic 管预算,用 UTC 记日志
一个轮询脚本约定“最多等 10 秒”,上线后却偶尔等待更久。排查时发现两个问题:代码用系统时间判断超时;每次重试又重新计算一次截止时间。前者会受系统校时影响,后者则把总预算变成了每轮预算。
计时和记录事件发生时间有不同的用途。本文围绕一个等待就绪的函数,说明怎样选择时钟、传递剩余预算,以及如何在不修改电脑时间的情况下验证边界行为。
一、先区分“现在几点”和“经过多久”
time.time() 给出与 Unix 时间相关的当前时刻,适合转换为日期。系统时钟被向后调整时,两次读取相减可能出现负数。
time.monotonic() 用于测量时间间隔,不受系统时钟更新影响。它的起点没有日历含义,应使用两次读取的差值。不要把返回值当作事件日期,也不要把它保存成可供其他机器比较的业务时间戳。
下面是一组人为构造的输入,用来说明选择时钟的影响:
wall_start, wall_end = 1000.0, 995.0
mono_start, mono_end = 10.0, 12.0
assert wall_end - wall_start == -5.0
assert mono_end - mono_start == 2.0
这不是对操作系统校时的实测,而是一个可重复的模型:当墙上时钟回拨,基于它计算的等待时间会失真。实际业务代码应选用语义合适的时钟,而不是简单把负耗时截成零。
二、截止时间只算一次
如果在循环内部反复执行 deadline = clock() + timeout,每一轮都获得新的预算;只要一直重试,就可能一直等下去。
更容易审查的做法是:进入函数时计算一次 deadline,之后每轮读取剩余时间,检查函数的执行时间也计入同一份预算。
import math
import time
def wait_until(check, timeout, interval=0.1, *,
clock=time.monotonic, sleeper=time.sleep):
if not math.isfinite(timeout) or timeout <= 0:
raise ValueError("timeout must be finite and positive")
if not math.isfinite(interval) or interval <= 0:
raise ValueError("interval must be finite and positive")
deadline = clock() + timeout
while True:
if deadline - clock() <= 0:
raise TimeoutError("readiness deadline expired")
ready = check()
remaining = deadline - clock()
if remaining <= 0:
raise TimeoutError("readiness deadline expired")
if ready:
return
sleeper(min(interval, remaining))
这里定义了明确的边界:预算必须是有限正数;到达截止时刻算超时;检查函数超过截止时间才返回 True,也拒绝该迟到结果。最后一次等待会被剩余预算截短。例如预算 1 秒、间隔 0.75 秒,理论等待序列就是 0.75 秒与 0.25 秒,而非连续等待两次 0.75 秒。
三、计时检查不能强制中断阻塞调用
这段函数适用于快速返回的 check()。如果 check 内部卡在网络读取上,外层只能在它返回后再发现超时。即使拒绝迟到的结果,也不能让已经耗掉的时间恢复,更不能撤销 check 已产生的副作用。
因此,涉及网络或其他阻塞操作时,底层调用也需要合理的超时或取消机制。多个阶段应共享剩余预算,不能在连接、读取、重试等阶段各自重新分配完整的 10 秒。外层 deadline 与底层超时应一起设计。
time.sleep() 也可能因调度而等待得更久。截短 sleep 参数只能控制计划等待时间,无法把普通 Python 循环变成严格的实时系统。如果业务涉及电脑休眠,还需要核对目标平台的时钟行为,不应仅凭“单调”二字推断休眠计时方式。
四、用假时钟验证,不要修改电脑时间
函数把 clock 和 sleeper 作为可替换依赖。测试时可以让 sleeper 推进一个内存中的计数器,整个验证过程不需要真的等待数秒,也不会修改系统时钟。
本文在 Python 3.13.11 中验证了六组行为:模拟墙上时钟回拨;条件在预算内变为就绪;一直未就绪时按统一截止时间失败;最后一次 sleep 被剩余时间截短;检查函数迟到返回时被拒绝;零、负数、无穷大和 NaN 预算被拒绝。
其中,模拟慢检查时让假时钟直接前进 2 秒,再返回 True。预算只有 1 秒,函数应在检查返回后抛出 TimeoutError。这个测试同时揭示了函数的能力边界:它识别迟到,无法抢占正在运行的检查函数。
五、日志用带时区的时间表达事件
排查故障时仍然需要知道“事件发生于哪个时刻”。日志可以记录明确的 UTC 时间,同时单独记录以单调时钟计算的耗时。示例中的本地时间用固定的 UTC+8 偏移表达:
from datetime import datetime, timedelta, timezone
local = datetime(2026, 10, 16, 8, 0,
tzinfo=timezone(timedelta(hours=8)))
utc = local.astimezone(timezone.utc)
assert utc.isoformat() == "2026-10-16T00:00:00+00:00"
assert utc.timestamp() == local.timestamp()
event_time = datetime.now(timezone.utc).isoformat()
astimezone() 在转换表示方式时保留同一时刻。直接对这个本地时间调用 replace(tzinfo=timezone.utc),只是换上 UTC 标签,会把它解释成另一个时刻。真实地区的历史时区或夏令时规则应另行处理,固定偏移不等于完整的地区时区。
统一使用 UTC 便于对齐日志,但不能保证多台机器的时钟已经同步。需要定位跨服务因果关系时,还应保留请求标识和链路信息。对单个进程内的超时判断,则继续使用单调时钟及同一份截止预算。
参考:Python 官方标准库文档 time、datetime。示例与边界测试为本文自行组织,内容经 AI 辅助整理;测试未修改系统时钟,未访问外部业务服务。
- 点赞
- 收藏
- 关注作者
评论(0)