openlang

asyncio.gather 在 3.9 上,一个任务失败了,另一个还在跑,还把文件写了

客户的服务器卡在 3.9,所以没有 TaskGroup,我对两个命名任务用了 gather。我在文档里读到一个抛异常时其他的会被取消,对吧?pier-9 的 measurements 接口今天返回 503,打印了 "score failed",但 weights 还活着,然后照样写了 weights_cache.json。main.py 里那个半秒的 sleep 只是为了让最后的检查排在写入之后。

async def score(pier):
    m = asyncio.create_task(fetch_measurements(pier), name="measurements")
    w = asyncio.create_task(fetch_weights(pier), name="weights")
    measurements, weights = await asyncio.gather(m, w)
    return combine(measurements, weights)
$ python3 main.py
score failed: measurements: upstream 503 for pier-9
in flight after failure: ['weights']
weights written to weights_cache.json for pier-9
in flight at end: []

为什么失败之后 weights 还在跑?

从英语翻译查看原文

后续讨论 11

顺着彼此的依据读下去
  1. 固定链接

    文档里没这么说。默认 return_exceptions=False 时,gather 会把第一个异常传给 await 它的那一方。其他可等待对象不会被取消,会继续运行。只有 gather 本身被取消时,它们才会被取消。既然任务是你自己创建的,weights 基本上就是一个没人管的自由运行任务。

    在 3.9 上你得自己取消,像下面这样写。

    try:
        measurements, weights = await asyncio.gather(m, w)
    except Exception:
        for t in (m, w):
            t.cancel()
        await asyncio.gather(m, w, return_exceptions=True)
        raise

    或者用 asyncio.wait(..., return_when=asyncio.FIRST_COMPLETED),然后取消 pending 里剩下的所有任务。不管哪种方式,都要 await 被取消的任务,让它们真正结束后再检查。

    然后把那个半秒的 sleep 去掉。

    从英语翻译查看原文
  2. 固定链接

    谢谢,这样就说得通了,我显然是看错文档了,对吧?我先试你的第一个版本。之前在一个客户的站点上我就被类似的问题坑过,一个被取消的任务还是把文件写出来了,因为取消是在写入已经开始之后才生效的,所以我也会检查文件是不是真的没了。

    从英语翻译查看原文
  3. 固定链接

    gather 不会取消另一个,只是把第一个异常丢给你,weights 照样继续跑。你看到的就是这个。文档写得有点含糊,烦。

    所以自己在 except 里把 m 和 w 取消掉,再 await 一遍。go 里的 errgroup 会帮你做这个,python 得手动来。

    从英语翻译查看原文
  4. 固定链接

    简短的回答是,默认 return_exceptions=False 的 gather 只会传播第一个异常,不会取消其他的兄弟任务。只有 gather 的 future 本身被取消时才会发生取消,而这里没人这么做。所以 weights 会一直跑完,和你观察到的一样。

    "in flight" 具体报告的是什么?如果它来自 asyncio.all_tasks(),那测的就是对的东西。如果来自你自己的记录,我会先验证这一点。

    TaskGroup 我记得是 3.12 才有的,所以 3.9 上必须手动取消。取消之后,先 await 这些任务,再检查文件……

    从英语翻译查看原文
  5. 固定链接

    对,是同步的,对吧?fetch_weights 里就是最后一个 await 之后的一个普通 open() 加 json.dump,写入过程本身没有 await。这一点对取消很重要。取消只能落在 await 点上,所以如果 weights 还在等网络,写入根本不会开始,如果已经过了那一步,写入就会完整写完。不管哪种,我都会在运行前删掉文件,运行后再检查。

    从英语翻译查看原文
  6. 固定链接

    有道理,json.dump 中途崩溃的话会留下一个写了一半的文件,然后下次运行就会把它当成真正的缓存来读,对吧?所以我分几步来做。

    1. 写到同一目录下的 weights_cache.json.tmp,因为 os.replace 只有在同一个文件系统内才是原子的。
    2. 如果缓存真的很重要,关闭文件之前先 flush,再对文件调用 os.fsync。
    3. os.replace("weights_cache.json.tmp", "weights_cache.json")。

    这样对取消检查也有帮助,因为只有写入完整完成,最终的文件名才会存在。所以取消之后我只要检查 weights_cache.json 不在就行,如果留下了 .tmp,就说明写入开始过但从没被重命名。

    不过 open 和 replace 之间本来就没有 await,取消不可能在中间生效,所以这主要是防崩溃和进程被杀的情况。

    从英语翻译查看原文
  7. 固定链接

    main.py 里那个半秒的 sleep 只是为了让最后的检查排在写入之后。

    能把 bug 显出来的 sleep 多半也藏着第二个 bug。fetch_weights 里有没有哪里用了 run_in_executor 或 to_thread,比如 HTTP 调用或者写文件?取消任务并不会停掉一个已经在跑的线程,所以文件可能在你取消之后才落盘。在相信这个修复之前最好先搞清楚。

    从英语翻译查看原文
  8. 固定链接

    fetch_weights 里有没有在哪里用到 run_in_executor 或 to_thread,比如 HTTP 调用或者写文件?

    写文件没有,就是直接在协程里用普通的 open() 和 json.dump,我上面说过了。HTTP 调用的话,我不是 100% 确定客户端库底层是怎么做的,所以先 grep 这两个名字。然后修完取消之后,我会再留着那个 sleep 跑一次,对吧?如果文件还是出现了,那就是线程。

    从英语翻译查看原文

同一个问题的其他角度

  1. 已取消 context 但出错后 worker goroutine 仍然存活(总是 workers + 2)

    大家好, 我在写一个小型 Go CLI,叫 shotcheck,用来检查文件夹里每张 PNG 是否符合 App Store 对应位置要求的精确尺寸。一个 worker 池(默认 8 个)从 jobs 里读文件名,检查后把 Result 发到无缓冲的…

    回复 4最近活动 go
  2. 把过滤函数标成 async,卡顿一点没变

    ledgerscan 在 400k 条记录上做搜索。我把 run 改成了 async,在 main 里 await 它,本以为 100 ms 的心跳 Timer 会一直跳。结果先是几下 tick,然后打出 searching "rent"...,接着…

    回复 2最近活动 dart
  3. isolate 都结束了,对 ReceivePort 的 await for 却一直不结束

    大家好,我写了个小的 dart 工具,用来汇总温度 CSV,每个文件起一个 isolate,这样大文件不用互相等。我在 WSL 里这样跑 dart run bin/summarize.dart data/north.csv data/south.cs…

    回复 2最近活动 dart