Skip to content

Bug: async 事件循环中同步阻塞调用 OSS/S3,导致代理服务间歇性无响应 #52

Description

@4sa1ary9

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 代理服务间歇性完全无响应

  1. TCP 连接在 kernel 层面正常建立(三次握手成功),但应用层从不读取——socket 堆积在 ESTABLISHEDCLOSE-WAIT 状态

  2. curl --max-time 5 localhost:30000/healthz 超时,exit code 28

  3. 一个线程空转占满 50% CPU(用户态死循环,WCHAN=-

  4. 事件循环线程 ps 显示 ep_poll,看起来正常但从不处理事件

  5. 其他 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 并行初始化的时刻。

以下因素叠加:

  1. first_pull = True → 立即调用 _pull_skills_from_cloud()

  2. 该调用在启动期间阻塞事件循环线程

  3. 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()

建议修复(供维护者参考)

  1. 对所有 async 函数中的同步 hub.pull_skills() / hub._bucket.put_object() 调用统一加 asyncio.to_thread() 包裹(diff 见上文)

  2. 考虑将首次 skill 拉取推迟到 uvicorn server 完全启动之后ready_event 已设置),而非在 lifespan 启动阶段执行

  3. 增加启动健康守卫:如果 _pull_skills_from_cloud() 耗时超过 N 秒,记录警告日志并将本次拉取推迟到下一个轮询周期

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions