原创

ThreadPoolExecutor 的 with 为何退出很慢:结果超时与线程池收尾

future.result(timeout=1) 已经抛出超时,调用这个函数的人却还在等待。往上看一层,线程池可能放在 with ThreadPoolExecutor(...) 里:等待结果超时结束了那次等待,退出上下文又开始等待线程池工作结束。

这不是超时失效,而是两个等待发生在不同位置。Future 的结果等待、任务是否还能取消、线程池何时收尾,各有自己的状态。把它们统一叫“取消线程池”,很容易写出表面有超时、函数却长时间不能返回的代码。

Python 3.12 下,可以不用随机延时、也不真的挂住进程,构造一个确定的实验:活动任务等待主线程放行,排队任务尚未执行;拥有者调用不等待的 shutdown,然后离开 with,主线程观察上下文仍未结束。

shutdown 返回以后,with 仍然有自己的退出动作

保存为 pool_exit.py:

import threading
from concurrent.futures import ThreadPoolExecutor


def main():
    started = threading.Event()
    release = threading.Event()
    shutdown_returned = threading.Event()
    context_exited = threading.Event()
    shared = {}

    def blocking_work():
        started.set()
        release.wait()
        return "active finished"

    def owner():
        with ThreadPoolExecutor(max_workers=1) as pool:
            active = pool.submit(blocking_work)
            started.wait()
            queued = pool.submit(lambda: "queued ran")
            shared["active"] = active
            shared["queued"] = queued
            pool.shutdown(wait=False, cancel_futures=True)
            shutdown_returned.set()
        context_exited.set()

    thread = threading.Thread(target=owner)
    thread.start()
    try:
        if not shutdown_returned.wait(timeout=3):
            raise AssertionError("shutdown did not return")
        try:
            shared["active"].result(timeout=0)
        except TimeoutError:
            print("result wait timed out; active task is still running")
        else:
            raise AssertionError("blocked task unexpectedly completed")
        assert shared["queued"].cancelled()
        assert not shared["active"].done()
        assert shared["active"].cancel() is False
        assert not context_exited.is_set()
        print("shutdown returned; queued cancelled; active still running")
        print("with block has not exited")
    finally:
        release.set()
        thread.join(timeout=3)
    assert not thread.is_alive()
    assert context_exited.is_set()
    assert shared["active"].result() == "active finished"
    print("active finished; with block exited")


if __name__ == "__main__":
    main()

执行 python3 pool_exit.py,结果等待超时与线程池收尾的状态说明应依次出现。活动任务确实已经进入工作函数,排队任务确实没有运行,因此取消结果不是碰运气:cancel_futures=True 取消尚未开始的工作,正在运行的那一项继续等待。

主线程在 finally 里始终放行并等待拥有者结束,即使某个断言失败也不留下永久阻塞的工作线程。用于解释阻塞的示例本身应该有可控出口,不能让读者运行以后只能强制终止解释器。

实验用timeout=0,是因为活动任务已经确定停在release.wait处,此时只需要一次不等待的状态观察;这不是建议真实请求使用零预算。它让测试无需再等一秒,也能证明result等待已经结束、工作线程仍未结束。随后主线程检查上下文退出标志,才把两个等待的位置关联起来。

拥有者线程退出with以后才设置context_exited,任务函数返回的结果则通过Future取回。这两份证据各指向一个时点,不能只凭主线程没有继续打印就推断卡在某一层。若测试只依赖sleep一段时间以后看看线程是否还在,很容易在负载变化时把调度延迟当成语义结论。

Future 取消的是状态,不是任意中断工作线程

活动 Future 的 cancel() 返回 False,因为工作已经开始。Python 标准线程池没有一项安全接口,可以在任意指令位置把正在运行的普通线程函数拔掉。

要让长任务响应取消,应把停止信号传到实际工作里,并在合适位置检查。等待一个可中断的Event、使用有超时的网络调用、分段处理文件,都比假定取消Future会停止线程更准确。函数卡在不受控的底层阻塞操作时,外部状态改变也未必立即得到执行机会。

result(timeout=...) 更不承担取消责任。它限制调用者等结果的时间,超时以后 Future 可能仍然运行,甚至已经产生部分副作用。调用方选择继续、等待最终结果还是进入恢复流程,需要按业务合同安排。

因此,不能在捕获 TimeoutError 后立即无条件重交同一个写入任务。上一项工作可能还活着,下一项已经又开始修改相同资源。看起来像“重试”,实际可能是两个并行写入者。

cancel_futures 也不负责所有队列

这里取消的是当前Executor中尚未开始运行的Future。任务内部自己创建的线程、另一个线程池提交的工作、远程服务排队的请求,都不在同一项设置的控制范围。

取消排队项以后,调用方仍要决定怎样记录其结果。将 cancelled 统一展示成普通业务失败,会让使用者以为实际工作已经尝试过;将它们算作成功,则更明显地违背状态含义。批处理报告通常需要区分已完成、失败、未开始而取消和结果未知。

线程池关闭以后也不能继续submit。把同一个pool对象留在全局,某次请求超时就shutdown,然后下一次请求继续复用,会在另一个位置才暴露生命周期已经结束。共享线程池的拥有者通常应该是服务组件,而不是单个调用者。

想让函数及时返回,先选好拥有者

短期专用线程池放在with里,适合明确要求函数返回前工作都已收尾的流程。此时结果超时以后仍等退出,并不一定是坏设计:它换来调用者不会拿到一个仍然运行的后台任务。但接口文档要承认这不是严格的函数总时限。

如果必须及时把控制交还调用方,线程池与在途任务就需要由更长寿命的组件管理。返回值可以带任务编号,后续有人收集最终结果、清理资源并在服务退出时等待。不能只删除with,把Future引用丢掉,再把剩下的工作称为“已取消”。

shutdown(wait=False) 也不意味着解释器可以无条件抛下线程立即退出。标准线程池的程序退出规则仍会等待相关工作完成。测试脚本似乎卡在程序最后,常常正是这份没有被管理的工作寿命回到了进程层面。

对于必须严格隔离并终止的任务,可以考虑独立进程或外部工作者,但那会引入进程终止、暂存结果和外部副作用的恢复合同。换执行模型不能省掉业务收尾,只是把停止边界移到另一个可以控制的位置。

回到实验,两个已经发生的事实应分别记住:shutdown调用确实返回了;with的退出确实尚未完成。排障时找到真正阻塞的那一层,才能决定是改变等待策略,还是让工作函数具备可合作结束的能力。

正文到此结束
评论插件初始化中...
Loading...
本文目录