给线程池任务调用result(timeout=几秒),捕获到TimeoutError后,请求返回了,后台任务却还在执行。这并不矛盾:超时的是这次取结果的等待,不是工作函数的寿命。把两件事混在一起,最容易留下重复写入和退出卡住的问题。
下面只用标准库做本地隔离实验,受检解释器为Python 3.14.3。不连接外部接口,不操作数据库;结论限于Future状态和线程池收尾,不代表任何真实业务已经回滚。
一、先分清:等不及,不等于任务停了
Future.result(timeout)等待任务完成并取回返回值。如果等待期限内仍未完成,它抛出TimeoutError,但不会因此向工作线程注入异常,更不会自动撤销函数已经做过的操作。下一次调用result,仍然可以拿到同一个任务后来产生的结果。
timeout=0很适合验证边界:不为这次读取继续等待。前提是先确定任务确实正在运行,否则任务可能恰好已经结束,实验就会变成拼运气。这里让工作函数发出启动信号,再停在另一个Event上,主线程收到信号后才取结果。
二、cancel要看状态,done不能当成功
cancel尝试取消尚未执行的调用。已经运行或已经结束时,取消失败,返回False;取消成功则返回True。False只说明没取消成,并不能单独证明任务还在运行;判断当前是否执行,要另外看running。实际业务里状态会继续变化,连续查询不是一份原子快照。
done表示成功取消或执行结束,里面既有正常返回,也有异常结束。它像“这一单不再流转”,不是“这一单办成了”。成功取消的Future再调用result会抛CancelledError,工作函数异常结束则由result重抛业务异常。
| 现象 | 实际边界 | 下一步 |
|---|---|---|
| result等待超时 | 本次没拿到结果 | 查询任务结果,别当成已终止 |
| cancel返回False | 运行或结束,未取消 | 结合running和结果判断 |
| cancelled为True | 调用被成功取消 | 处理CancelledError |
| done为True | 取消或执行结束 | 区分返回值与异常 |
| shutdown不等待 | 不阻塞当前收尾调用 | 仍需处理运行中的任务 |

三、完整实验:用Event锁住关键时刻
把下面完整代码保存为sample.py,使用Python 3.9及以上运行。等待异常从concurrent.futures单独导入;它从3.11才成为内置TimeoutError的别名,不能把新版捕获方式直接套给旧版。这里只实跑3.14.3,未测试旧解释器。单worker保证后提交的任务排队;前一个任务不放行,后一个就没有机会先跑掉。检查使用if和异常,不依赖assert,因此开启优化也不会把验收条件删掉。
工作函数最多等十秒,这是实验失败时避免挂住的兜底,不是被测的result超时。会合失败会明确报RuntimeError。每个阻塞点都在finally中释放,再等待线程池回收;控制线程也会join,避免把“主线程走完了”误当成资源清理完成。
from concurrent.futures import ThreadPoolExecutor, CancelledError
from concurrent.futures import TimeoutError as 等待超时
from threading import Event, Thread
import sys
次数 = 0
def 检查(条件, 说明):
global 次数
if not 条件:
raise RuntimeError(说明)
次数 += 1
print(f"通过{次数:02d}:{说明}")
def 会合(事件):
if not 事件.wait(10):
raise RuntimeError("会合失败,非实验中的等待超时")
def 工作(已启动, 放行):
已启动.set()
会合(放行) # 有限等待兜底,不用睡眠猜测线程状态
return "收到信号,正常返回"
def 应抛(类型, 调用):
try:
调用()
except 类型:
return True
return False
# 一:运行中超时、排队取消与协作停止
启动, 停止, 排队已跑 = Event(), Event(), Event()
池 = ThreadPoolExecutor(max_workers=1)
try:
运行 = 池.submit(工作, 启动, 停止)
会合(启动)
排队 = 池.submit(排队已跑.set)
检查(应抛(等待超时, lambda: 运行.result(timeout=0)), "result等待超时")
检查(运行.running() and not 运行.done(), "超时后仍运行且未结束")
检查(运行.cancel() is False, "运行任务cancel返回False")
检查(排队.cancel() is True, "排队任务cancel返回True")
检查(排队.cancelled() and 排队.done(), "取消也是done")
检查(应抛(CancelledError, 排队.result), "取消后result抛CancelledError")
停止.set() # 任务主动响应;不是强杀线程
检查(运行.result(10) == "收到信号,正常返回", "协作停止正常返回")
检查(not 运行.cancelled(), "协作返回不等于Future已取消")
finally:
停止.set()
池.shutdown(wait=True)
检查(not 排队已跑.is_set(), "已取消任务未执行")
# 二:shutdown不等待,只取消尚未开始的任务(Python 3.9+)
启动, 放行, 排队已跑 = Event(), Event(), Event()
池 = ThreadPoolExecutor(max_workers=1)
try:
运行 = 池.submit(工作, 启动, 放行)
会合(启动)
排队 = 池.submit(排队已跑.set)
池.shutdown(wait=False, cancel_futures=True)
检查(运行.running() and not 运行.done(), "shutdown已返回,运行任务仍在")
检查(排队.cancelled(), "shutdown取消排队任务")
检查(应抛(RuntimeError, lambda: 池.submit(str)), "shutdown后拒绝提交")
finally:
放行.set()
池.shutdown(wait=True)
检查(not 排队已跑.is_set(), "shutdown取消的任务未执行")
# 三:在独立控制线程中退出with,记录公开shutdown入口
启动, 放行, 退出入口, 已退出 = Event(), Event(), Event(), Event()
共享, 错误 = [], []
class 观察池(ThreadPoolExecutor):
def shutdown(self, wait=True, *, cancel_futures=False):
共享.append(wait)
退出入口.set()
return super().shutdown(wait=wait, cancel_futures=cancel_futures)
def 控制():
try:
with 观察池(max_workers=1) as 池:
共享.append(池.submit(工作, 启动, 放行))
会合(启动)
已退出.set()
except BaseException as 异常:
错误.append(异常)
退出入口.set()
控制线程 = Thread(target=控制)
控制线程.start()
try:
会合(退出入口)
if 错误:
raise 错误[0]
检查(共享[1] is True, "with调用shutdown默认wait=True")
检查(共享[0].running() and not 已退出.is_set(), "未放行时with尚未退出")
finally:
放行.set()
控制线程.join(15)
检查(not 控制线程.is_alive() and not 错误 and 已退出.is_set(), "放行后with退出,控制线程已回收")
# 四:结束不等于成功,业务也能抛TimeoutError
业务异常 = TimeoutError("业务主动报超时")
def 失败():
raise 业务异常
with ThreadPoolExecutor(max_workers=1) as 池:
正常 = 池.submit(lambda: "完成")
异常结果 = 池.submit(失败)
检查(正常.done() and 正常.result() == "完成", "正常任务返回结果")
检查(异常结果.done() and not 异常结果.cancelled(), "业务异常也是done")
检查(异常结果.exception() is 业务异常, "exception保留业务异常")
检查(应抛(TimeoutError, 异常结果.result), "已结束任务result仍重抛业务TimeoutError")
print(f"合计:{次数}项检查;Python {sys.version.split()[0]};优化级别{sys.flags.optimize}")
四、结果怎么看:协作停止不是取消成功
本机执行中,等待超时之后running仍为True、done为False,运行任务的cancel返回False。排队任务则取消成功,result抛CancelledError,并且它的执行标记始终没有被设置。这里是用会合点控制状态,不是靠短暂sleep猜线程有没有启动。
停止信号被设置后,工作函数自己返回,Future正常完成而非cancelled。这就是协作停止:任务在自己安排的检查点响应信号。示例只有一个等待点;真正的分段计算要在合适边界检查Event,阻塞调用也需要自己的超时或退出机制。仅仅把Event设成True,不会神奇地打断任意函数。
五、shutdown的两个开关,管的不是一件事
wait控制shutdown是否等待任务收尾;cancel_futures控制是否取消尚未开始运行的任务,这个参数从Python 3.9加入。两者不要混读成“立即杀掉全部线程”。实验先占住唯一worker,再提交一个排队任务,调用不等待并取消排队的shutdown后,前者仍在运行,后者已取消。
shutdown之后再submit会报RuntimeError,不能继续往同一个池里补任务。wait=False只是当前方法不等,并不承诺Python程序立刻退出;文档明确说明程序仍要等待未完成的任务。实验最后主动放行,并再次等待收尾,不把遗留线程扔给解释器退出阶段。
六、为什么捕获超时后,with还是没出来
with退出时按wait=True执行shutdown。所以代码可能已经捕获result的等待超时,却在退出上下文时继续等待工作函数结束。这不是超时失效,而是取结果与回收线程池两个独立步骤:前者不等了,后者仍要收尾。
示例把with放在独立控制线程里,用子类记录公开shutdown入口收到的wait参数。主线程看到入口信号后,检查任务仍运行、上下文尚未退出,再放行任务并join。入口信号不是对内部锁的窥探;它和受控任务、最终退出事件一起,验证本例的调用参数与先后关系,不测量退出耗时。
七、别只凭TimeoutError,就决定重试
还有个容易漏掉的同名异常:工作函数自己也能抛TimeoutError。result会重抛它,不能只凭异常类型就断言是等待期限用完了。示例最后让业务函数主动抛出一个异常对象,先等线程池结束,再核对done和exception,确认此时拿到的是业务异常。
超时结果未知时,不要无脑再提交一次有副作用的任务。应先通过任务标识查询最终状态,写入类操作还要考虑幂等和去重。线程退出、Future取消和业务补偿是不同层面的事;这个本地实验没有证明任何真实请求未发送、事务已撤销或重试一定安全。即使调用方已经放弃等待,也应给任务保留可追踪的去向,而不是只留一条超时日志。
八、落地前按这份清单验收
默认、-O和-OO三种模式分别真实运行同一脚本,每次都完成二十项显式检查且退出码为零。三次重放不是六十个独立用例。文章HTML中的完整代码还会被抽出,逐字节核对源脚本后再运行,防止转义或排版悄悄改坏示例。
- 区分等待超时、取消成功、协作返回和业务异常。
- 用启动与释放事件确认运行状态,不靠sleep断言时序。
- 验证排队任务没有执行,退出时释放会合点并回收线程。
- 确认Python版本;cancel_futures需要3.9及以上。
- 有副作用时先确认结果与幂等条件,不拿本实验代替业务验收。
参考资料只支撑相应API语义;PEP用于了解设计背景,现行参数和版本边界以库文档及对应版本文档为准。环境不同可以重放脚本,但不要把本机3.14.3的隔离记录写成生产系统验证结果。
🔕 评论已关闭