先看现象。异步服务用 asyncio.Semaphore(50) 限制并发,跑十分钟后所有请求都卡在 acquire,日志没有异常,semaphore._value 是 0,重启进程才恢复,上游是 FastAPI。
原因不止一个,得分开看。异常分支没有 release,信号量泄漏。另一种情况是用了 async with 但内部又手动 acquire。
少数情况下是超时取消时 CancelledError 跳过了 finally。
不容易想到的是,信号量在模块级创建,跨事件循环复用。
先打印信号量的当前值
python -c "import asyncio;s=asyncio.Semaphore(50);print(s._value)"
再往下改成 async with 保证异常也能释放
python -c "import asyncio;print(asyncio.Semaphore.__aenter__)"
接着在取消场景下用 shield 包住释放逻辑
python -c "import asyncio;print(asyncio.shield(asyncio.sleep(0)))"
压测并周期性打印剩余许可
python stress.py --concurrency 80 --duration 600 --report-sem
手上没有现成的复现脚本,就照着线上请求手工拼了一个。
怎么算真的好了,看的是同一批现象。压测 10 分钟后 _value 仍在 0 到 50 之间波动,没有出现全部卡死,取消的请求也能让许可归还。
补一句。Semaphore 的许可必须成对释放,任何绕过 finally 的路径都会泄漏;模块级信号量还会绑定到第一个事件循环,测试里最容易踩。报错文案容易误导,真正的原因往往在相邻那一层的日志里。
