qinghuan
← 回到知识探索

后端现场 / 动手实验

页面已经报超时,后台为什么还在干活?

让一个真实工作线程继续运行,观察停止等待、请求取消和工作停止之间的差别。

展开本文目录
  1. 把三个时刻分开记
  2. 等到线程真正开始,再让等待超时
  3. 能停止,要靠执行路径愿意配合
  4. 停止后续工作,不会自动撤销已经完成的事
  5. 记住这三点
  6. 别把例子用过头
  7. 自己试一小步
  8. 查原始资料
  9. 找一份资料继续练
开始前,会这些就够了
  • 认识请求与响应;能运行一个 Python 文件
遇到不熟悉的词,先看这 3 个解释
幂等
在约定的范围内,把同一个请求做一次或重复做多次,对目标业务产生的预期效果相同;它用来防止重复执行把一件事变成额外多办几次。
超时
调用方为一次等待设置上限,超过时间还没拿到所需结果,就结束这次等待并报告超时;这样能限制资源占用,但并没有自动确认对方最终做到了哪一步。
取消请求
取消是向正在执行的工作发出停止意愿,再由相应代码或运行环境响应。调用方停止等待、服务端收到信号、真正停止后续动作,是需要分别观察的步骤。
打开完整术语小抄 ↗

你点了导出,页面显示超时,过一会儿后台却仍生成了文件。先别急着判断“超时没有生效”:页面停止等结果与执行者停止工作,本来就可能是两个动作。

这与“请求超时后是否重复下单”相关,但本篇把焦点放在执行本身。下面用真正启动的 Python 工作线程观察,而不是靠睡眠长短猜哪件事先发生。

把三个时刻分开记

调用超时后,先分清楚究竟发生了哪件事:

时刻发生了什么
调用者不再等待调用者结束了等待
发出取消请求调用者请求工作方停止
工作方实际停止工作方收到信号、检查状态,并停止工作

这三件事可能先后发生,也可能某个 API 只完成其中一步。

函数名里写着 timeout 或 cancel,仍要读清它究竟控制谁。不再等待、请求取消、实际停止,不能混为一谈。

取消信号到了,处理函数仍需配合

例如,gRPC 可以将取消通知传给服务端。但官方指南说明,应用自己的处理函数仍需配合停止。

  • 长期计算:如果处理函数从不检查取消状态,不能期待框架替它安全地中断每一条业务指令。
  • 继续调用下游:还要核对当前语言的实现是否会自动传播取消,不能只看上游已经发出了信号。

等到线程真正开始,再让等待超时

程序用 started 确认工作已经开始,用 release 把它暂时停在一个可控位置。主线程尝试立刻获取结果,发现还没完成便超时。随后检查工作仍在运行,再对这个已经运行的 Future 调用 cancel。

最后显式放行工作,再取回结果。Python 这个接口不能用 cancel 取消已经运行的调用,所以这里会得到 False。它不代表所有语言和 API 都有相同规则;程序验证的是 concurrent.futures 的这一项约定。

小实验 · python
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from threading import Event

started, release = Event(), Event()

def work():
    started.set()
    if not release.wait(5):
        raise RuntimeError("demo release was not received")
    return "finished"

with ThreadPoolExecutor(max_workers=1) as pool:
    future = pool.submit(work)
    try:
        if not started.wait(5):
            raise RuntimeError("worker did not start")
        try:
            future.result(timeout=0)
        except TimeoutError:
            print("caller stopped waiting")
        print("worker still running:", future.running())
        print("cancel running work:", future.cancel())
        release.set()
        print("later result:", future.result(timeout=5))
    finally:
        release.set()

怎样运行:Python 3.10+ 标准库;保存为 timeout_cancel.py,运行 python timeout_cancel.py。

运行后对照结果
caller stopped waiting
worker still running: True
cancel running work: False
later result: finished

能停止,要靠执行路径愿意配合

假设导出按批次处理记录,可以在每批开始前检查取消标记,停止领取下一批,并在清理阶段释放自己借用的资源。检查间隔决定有多快能响应;若正在等待一个不响应取消的外部接口,还得核对那个接口自身的超时与退出办法。

本例的 release 只是让实验可重复的门闩,没有实现这种分批取消协议。若要扩展,可以加入“取消已请求”和“工作已结束”两个独立状态,再分别观察它们,避免把通知发出当作全部工作已经停止。

停止后续工作,不会自动撤销已经完成的事

假设任务已经提交一笔数据库修改,再收到取消信号。停止后续步骤不等于那次提交被撤销。需要恢复业务状态时,应先确认哪些动作已完成,再按业务设计补偿或查询;别对外直接宣称“取消成功,所以没有任何效果”。

程序退出时的等待也要分开理解。本例使用线程池的 with 语句,会在离开时等待工作收尾。主线程某次取结果超时,不表示线程池立即消失。演示中的五秒是避免实验异常时无限卡住的保护等待,不是实测耗时。

记住这三点

  • 停止等待、发出取消与执行停止,是可以分别观察的动作。
  • 运行中的工作如何响应取消,取决于 API 和业务代码的配合。
  • 发出取消请求,不代表已提交的数据修改或已经完成的操作会自动撤销。

别把例子用过头

  • Python Future.cancel 的运行中返回 False 不能推导为所有取消 API 的规则。
  • 程序只涉及本地线程,没有启动 gRPC 服务或检验远程传播。
  • 放行门闩用于控制实验,不等于实现了可取消任务框架。

自己试一小步

把工作改成三批,每批前检查一个取消标记,应如何证明它没有开始后面的批次?

  1. 分别记录取消发出、每批开始、每批完成和工作退出四类事件。
  2. 在第一批之后发出取消,并用门闩固定这一时刻,避免用随机睡眠猜顺序。
  3. 再把取消移动到最后一批完成之后,检查同一份记录能够证明什么。

做到这些,就算完成

  • 没有把“收到取消”与“撤回此前所有效果”混为一谈。
  • 异常路径也会释放门闩并等自己创建的线程退出。
  • 记录足以判断哪些批次开始过,哪些仍无法确定。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • gRPC:Cancellation (新标签页)↗gRPC · 英文

    Overview、Cancelling an RPC:取消信号、应用处理函数配合、下游传播随语言不同。

    已阅读指南正文与语言支持表;只解释机制,没有在本次运行远程 gRPC。

  • Python 3.10:concurrent.futures (新标签页)↗Python · 英文

    Future.cancel、Future.result 与 Executor.shutdown:运行中取消限制、等待超时与上下文退出。

    固定为 Python 3.10 文档;实测 Python 3.14.0,未声称遍历所有版本。

找一份资料继续练

先选一项,按下面的范围继续学;不必从头读完整门课程。