Bug: async 事件循环中同步阻塞调用 OSS/S3,导致代理服务间歇性无响应
环境
SkillClaw: main 分支 (commit bf4dc2e)
Python: 3.12
操作系统: Linux 6.8.0-71-generic (x86_64)
存储后端: OSS (阿里云,endpoint oss-cn-hongkong.aliyuncs.com)
启动方式: skillclaw start (前台,非 daemon)
症状
SkillClaw 代理服务间歇性完全无响应:
TCP 连接在 kernel 层面正常建立(三次握手成功),但应用层从不读取——socket 堆积在 ESTABLISHED 和 CLOSE-WAIT 状态
curl --max-time 5 localhost:30000/healthz 超时,exit code 28
一个线程空转占满 50% CPU(用户态死循环,WCHAN=-)
事件循环线程 ps 显示 ep_poll,看起来正常但从不处理事件
其他 Python 线程闲置在 futex_ 等待
Bug 是偶发的——不是每次启动都复现。重启进程通常能恢复(连续 5 次重启全部正常)。说明大概率是事件循环初始化阶段的竞态条件。
根因分析
在 skillclaw/api_server.py 中发现两个问题:
问题 A(确定):async 函数内同步调用 OSS/S3
_pull_skills_from_cloud() 和 _upload_session_data() 是 async 函数,但内部直接在事件循环线程上调用了同步阻塞的 OSS/boto3 操作:
# api_server.py:3147 — 被 _skill_reload_poll_loop 每 30 秒调用一次async def _pull_skills_from_cloud(self, skip_names=None): hub = SkillHub.from_config(self.config) pull_result = hub.pull_skills(self.config.skills_dir) # ← 同步阻塞!OSS HTTP 请求# api_server.py:3051 — session 关闭时调用async def _upload_session_data(self, session_id, turns): ... hub._bucket.put_object(oss_key, content.encode("utf-8")) # ← 同步阻塞!OSS HTTP 请求影响:每次调用阻塞 uvicorn 事件循环的时间等于 OSS HTTP 往返延迟(通常 50-500ms,网络差时更长)。在此期间所有 HTTP 请求都无法处理。
修复(已在本地应用):用 asyncio.to_thread() 将同步调用包裹到线程池:
pull_result = await asyncio.to_thread( hub.pull_skills, self.config.skills_dir, skip_names=skip_names)await asyncio.to_thread( hub._bucket.put_object, oss_key, content.encode("utf-8"))这与 evolve server 已有的模式一致:evolve_server/engines/common.py:67 的 _call_storage()。
问题 B(推测):启动阶段首次立即拉取,与事件循环初始化形成竞态
commit 1f96ec8 让 _skill_reload_poll_loop() 在首次迭代时立即执行拉取(绕过首个 asyncio.sleep())。这意味着一次同步 OSS 调用发生在 uvicorn 启动/lifespan 阶段——恰好是事件循环、io_uring ring、epoll fd 并行初始化的时刻。
以下因素叠加:
first_pull = True → 立即调用 _pull_skills_from_cloud()
该调用在启动期间阻塞事件循环线程
uvloop + libuv 同时初始化两个 io_uring ring(其中一个带 SQPOLL)
……可能制造了一个窗口:事件循环进入不一致状态(例如 libuv 的 uv__backend_timeout() 持续返回 0 → epoll_pwait(timeout=0) → 立即返回 → 死循环)。
佐证:FastAPI lifespan 启动事件中,idle sweeper(interval=15s)和 skill reload 轮询(interval=30s)被启动,而首次 OSS 拉取正阻塞着同一个事件循环。如果时序不巧,事件循环永远无法正确进入 epoll_pwait 休眠。
相关代码路径
文件 | 函数 | 问题
-- | -- | --
api_server.py:3147 | _pull_skills_from_cloud() | async 中同步阻塞调 OSS pull
api_server.py:3051 | _upload_session_data() | async 中同步阻塞调 OSS put
api_server.py:2005 | _skill_reload_poll_loop() | 首次迭代立即拉取,不走 sleep
launcher.py:131 | _run() 启动流程 | server.start() 之前也同步调了 hub.pull_skills()
evolve_server/engines/common.py:67 | _call_storage() | 已正确使用 asyncio.to_thread()
建议修复(供维护者参考)
对所有 async 函数中的同步 hub.pull_skills() / hub._bucket.put_object() 调用统一加 asyncio.to_thread() 包裹(diff 见上文)
考虑将首次 skill 拉取推迟到 uvicorn server 完全启动之后(ready_event 已设置),而非在 lifespan 启动阶段执行
增加启动健康守卫:如果 _pull_skills_from_cloud() 耗时超过 N 秒,记录警告日志并将本次拉取推迟到下一个轮询周期
Bug: async 事件循环中同步阻塞调用 OSS/S3,导致代理服务间歇性无响应
环境
SkillClaw: main 分支 (commit
bf4dc2e)Python: 3.12
操作系统: Linux 6.8.0-71-generic (x86_64)
存储后端: OSS (阿里云,endpoint
oss-cn-hongkong.aliyuncs.com)启动方式:
skillclaw start(前台,非 daemon)症状
SkillClaw 代理服务间歇性完全无响应:
TCP 连接在 kernel 层面正常建立(三次握手成功),但应用层从不读取——socket 堆积在
ESTABLISHED和CLOSE-WAIT状态curl --max-time 5 localhost:30000/healthz超时,exit code 28一个线程空转占满 50% CPU(用户态死循环,
WCHAN=-)事件循环线程
ps显示ep_poll,看起来正常但从不处理事件其他 Python 线程闲置在
futex_等待Bug 是偶发的——不是每次启动都复现。重启进程通常能恢复(连续 5 次重启全部正常)。说明大概率是事件循环初始化阶段的竞态条件。
根因分析
在
skillclaw/api_server.py中发现两个问题:问题 A(确定):async 函数内同步调用 OSS/S3
_pull_skills_from_cloud()和_upload_session_data()是async函数,但内部直接在事件循环线程上调用了同步阻塞的 OSS/boto3 操作:# api_server.py:3147 — 被 _skill_reload_poll_loop 每 30 秒调用一次async def _pull_skills_from_cloud(self, skip_names=None): hub = SkillHub.from_config(self.config) pull_result = hub.pull_skills(self.config.skills_dir) # ← 同步阻塞!OSS HTTP 请求# api_server.py:3051 — session 关闭时调用async def _upload_session_data(self, session_id, turns): ... hub._bucket.put_object(oss_key, content.encode("utf-8")) # ← 同步阻塞!OSS HTTP 请求影响:每次调用阻塞 uvicorn 事件循环的时间等于 OSS HTTP 往返延迟(通常 50-500ms,网络差时更长)。在此期间所有 HTTP 请求都无法处理。
修复(已在本地应用):用
asyncio.to_thread()将同步调用包裹到线程池:这与 evolve server 已有的模式一致:
evolve_server/engines/common.py:67的_call_storage()。问题 B(推测):启动阶段首次立即拉取,与事件循环初始化形成竞态
commit
1f96ec8让_skill_reload_poll_loop()在首次迭代时立即执行拉取(绕过首个asyncio.sleep())。这意味着一次同步 OSS 调用发生在 uvicorn 启动/lifespan 阶段——恰好是事件循环、io_uring ring、epoll fd 并行初始化的时刻。以下因素叠加:
first_pull = True→ 立即调用_pull_skills_from_cloud()该调用在启动期间阻塞事件循环线程
uvloop + libuv 同时初始化两个 io_uring ring(其中一个带
SQPOLL)……可能制造了一个窗口:事件循环进入不一致状态(例如 libuv 的
uv__backend_timeout()持续返回 0 →epoll_pwait(timeout=0)→ 立即返回 → 死循环)。佐证:FastAPI lifespan 启动事件中,idle sweeper(
interval=15s)和 skill reload 轮询(interval=30s)被启动,而首次 OSS 拉取正阻塞着同一个事件循环。如果时序不巧,事件循环永远无法正确进入epoll_pwait休眠。相关代码路径
文件 | 函数 | 问题 -- | -- | -- api_server.py:3147 | _pull_skills_from_cloud() | async 中同步阻塞调 OSS pull api_server.py:3051 | _upload_session_data() | async 中同步阻塞调 OSS put api_server.py:2005 | _skill_reload_poll_loop() | 首次迭代立即拉取,不走 sleep launcher.py:131 | _run() 启动流程 | server.start() 之前也同步调了 hub.pull_skills() evolve_server/engines/common.py:67 | _call_storage() | 已正确使用 asyncio.to_thread()建议修复(供维护者参考)
对所有 async 函数中的同步
hub.pull_skills()/hub._bucket.put_object()调用统一加asyncio.to_thread()包裹(diff 见上文)考虑将首次 skill 拉取推迟到 uvicorn server 完全启动之后(
ready_event已设置),而非在 lifespan 启动阶段执行增加启动健康守卫:如果
_pull_skills_from_cloud()耗时超过 N 秒,记录警告日志并将本次拉取推迟到下一个轮询周期