一个线程只有一个事件循环:"同步入口"全在抢所有权

1 阅读7分钟

一个线程只有一个事件循环:"同步入口"全在抢所有权

业务跑完,评测 7/7 达标,端到端全通。

然后崩在 finally 里的那一行。

这篇讲三颗"收尾雷",但重点不在怎么修——修法都是一行的事。重点在:它们该按什么顺序修,而这个顺序和直觉相反。


一、三颗雷,两个病

先摆出来:

编号形态报错
EC-026协程里手写 loop.run_until_complete()This event loop is already running
EC-007协程里调 pydantic-ai 的 run_sync()同上,栈在 SDK 里
EC-028close_models(models) 多传一个参takes 0 positional arguments but 1 was given

前两颗是同一个病:抢事件循环的所有权。第三颗是签名不匹配。

但它们值得单独说的原因,不在病根,在代价。


二、所有权的规则只有一条

同一线程同一时刻,只能有一个正在运行的 loop,而且只有它的所有者能驱动它。

你写 asyncio.run(main()) 的那一刻,asyncio.run 就是所有者。main() 和它 await 的一切,都跑在这个正在转的循环里。

这时候你再写:

loop = asyncio.get_running_loop()
loop.run_until_complete(some_task())

run_until_complete 的语义是"启动这个循环,跑到某协程结束"。循环已经在跑,所以它第一步就执行 _check_running()——这不是异常处理,是一条断言:循环不能已经在跑。

于是:

RuntimeError: This event loop is already running

顺带一个细节:崩的同时,Python 还会在 stderr 打一句 coroutine 'inner' was never awaited。

因为它把协程对象造出来了,却永远没机会执行。这行警告的意思是"这个协程根本没跑",不是"跑失败了"。 排查时这两者差别很大。


三、真正让人踩坑的是名字

如果只是自己写错 run_until_complete,那还好——错误明显。

麻烦的是第二种:SDK 替你写的。

result = agent.run_sync("推荐一款适合干性皮肤的产品")

报错栈:

  File ".../pydantic_ai/agent/abstract.py", line 793, in run_sync
    return _utils.run_until_complete(...)
  ...
RuntimeError: This event loop is already running

run_sync 读起来像"同步跑一下,省得写 await"。实际语义是"我要自己起一个循环"。

pydantic-ai 的两套入口:

入口性质适用
agent.run_sync(x)内部自建循环顶层脚本,没有循环时
await agent.run(x)纯异步async 函数内部

所以规则可以写成一句:

只要入口是 asyncio.run(main()),脚本里所有带 _sync 后缀的快捷入口都是地雷。

它们不是"同步版",是"抢所有权版"。同理还有 asyncio.get_event_loop().run_until_complete(...)、loop.run_forever()。


四、判断只需要一个布尔值

在写任何"起循环"的调用之前,先确认自己在哪:

import asyncio

def in_loop() -> bool:
    try:
        asyncio.get_running_loop()
        return True
    except RuntimeError:
        return False

实跑:

模块顶层(没有 loop)   : False
协程内部(loop 正在跑) : True

拿到这个值,能做什么就定了:

你在哪能用不能用
Falseasyncio.run(coro)await(没有循环)
Trueawait coro一切"起循环"的调用

get_running_loop() 在没循环时抛 RuntimeError。它不是异常,是一句"现在有没有人在开这台车"的回答。


五、最贵的那颗,报错最不吓人

第三颗雷:close_models() 定义是 0 参数,你传了 models 字典。

TypeError: close_models() takes 0 positional arguments but 1 was given

看起来是最"低级"的一个——数错参数而已。但它落在 finally 里,性质就变了。

如果 try 里已经有异常在往外冒,finally 又抛一个,屏幕上你只会看到最后一个。

最小复现:

def f():
    try:
        raise ValueError("真正的原因:模型返回了空内容")
    finally:
        raise TypeError("收尾时又错了一次")

实跑:

屏幕上看到的 : TypeError: 收尾时又错了一次
被盖掉的     : ValueError: 真正的原因:模型返回了空内容

Python 没丢信息——原异常在 e.__context__ 上,终端也会打 During handling of the above exception, another exception occurred。

但人的眼睛只看最后一行。

于是你会花半小时修 TypeError,改完重跑,才看到那个真正让脚本崩的 ValueError。一行收尾代码,付了两次排查成本。


六、所以严重度要倒过来排

四颗雷(加上上一篇那颗无害的收尾噪音):

编号报错有多吓人影响结果盖住别的错
EC-001 generator didn't stop很吓人(四十行红字)否否
EC-026 手写套娃一般是否
EC-007 SDK 套娃一般是否
EC-028 签名不匹配最不吓人(一行)是是

按"吓人程度"排,EC-001 第一,EC-028 最后。 按"该先修哪个"排,正好反过来。

判断标准一句话:它会不会改变我对结果的判断?

  • 不会(EC-001)→ 记下来,别动
  • 会,但只影响自己(EC-026/007)→ 修掉,代价可控
  • 会,而且让我误判别处(EC-028)→ 优先修,它在污染排查过程

我在这上面栽过:先花两小时追那屏四十行的红字,最后发现它无害;而真正让脚本崩的那行 TypeError,因为它"看起来太简单",被我放到最后。


七、怎么给"退出"补一个测试

收尾难测,但不是不能测。关键是:不要在进程内测,起一个真进程。

import subprocess, sys

def test_exit_clean():
    r = subprocess.run([sys.executable, "main.py"],
                       capture_output=True, text=True, timeout=60)
    assert r.returncode == 0, r.stderr[-800:]
    assert "Traceback" not in r.stderr, r.stderr[-800:]

两条断言,覆盖了这颗雷的两种表现:

  • 退出码不是 0 → 崩了(EC-026 / EC-007 / EC-028)
  • stderr 里有 Traceback 但退出码是 0 → 收尾噪音(EC-001),记下来别改

为什么必须在子进程里跑?

因为"退出"这个动作,在测试进程里测不到。测试框架自己持有事件循环,你的脚本从没真正"退出"过。

这也是为什么这类问题特别容易漏——它不是"没写测试",是测试这种形式天然覆盖不到退出路径。

代价是慢:要起进程、要真调模型。所以我不放进每次 CI,只在两个时机跑:

  1. 改过收尾代码之后
  2. 上线前

这两次的价值,比每天跑一遍高。

八、三问定位

三颗雷压成三问,按顺序:

顺序问自己判据
1这个错是在收尾阶段吗?业务输出是否已完整打印。是 → 查异步/收尾层
2报的是 This event loop is already running?先找自己写的 run_until_complete,再找 SDK 的 *_sync
3报的是 takes 0 positional arguments?先数参数;在 finally 里立刻看 __context__

第一问决定"去哪找",第二问决定"是不是所有权",第三问决定"这个错是不是真的"。

三问都不用调试器,看着输出就能答。

九、有人说:nest_asyncio 不就解决了

能解决,但我不建议。

它的原理是给循环打补丁,允许重入。等于承认"我要在同一台发动机上装第二个起动机"。

短期你会看到它"能跑了"。长期它会以更难查的方式坏:任务被抢占、超时计时错乱、取消传播异常。而这些问题的表现,和"套娃"完全不像,你会当成新问题重新查一遍。

改调用结构只要一行,打补丁要一直维护。


十、两个边界

一、多线程里规则要改写。

每个线程可以各有一个循环,"谁拥有"变成"哪个线程拥有"。跨线程 await 不允许,得用 run_coroutine_threadsafe。

二、Web 服务里没有"退出"这个信号。

uvicorn / gunicorn 自己持有循环,报错时机从"脚本退出"变成"某个请求处理时"。in_loop() 依然有用,但"跑完看退出干不干净"这个动作没了,只能靠日志和中间件。


十一、最后

三颗雷修法都很短:

  1. 入口只留一个 asyncio.run,内部全 await
  2. 带 _sync 的调用先问"我在不在循环里"
  3. finally 里的收尾包一层 try/except,记日志不抛出

第三条是最后加的,也最重要——它不防止出错,它防止你丢线索。

因为前面几百行业务代码一行没错,这是好事。但"没错"不等于"没问题":如果收尾把真正的错误盖住,你连自己没错这件事都证明不了。

一个脚本可信,不是因为它跑出了正确结果,是因为它退出的时候没藏东西。

顺带一句:如果你手上正好有个脚本,跑完了会刷红字——先别改。把退出码和 stderr 最后三行记下来,再决定动不动它。大多数时候它不影响任何东西,而改动本身有风险。


这是「AgentScope 2.0 实战踩坑地图」的第 3 篇。上一篇讲"成功"也会指错方向;这一篇讲"收尾"会盖住错误。下一篇:同一套 DeepSeek,接四种框架四个坑。