接口已经写成异步,为什么整个服务还是会卡?从 I/O 线程到 C++20 协程
一个 Drogon handler 看起来很“异步”:它接收 callback,不会直接返回 HttpResponsePtr。但如果在里面加入一秒钟的同步调用,压测时同一批连接仍会一起变慢:
1 | // 存在隐患:handler 采用回调接口,不代表函数体自动非阻塞。 |
问题不在 callback 的语法,而在执行这段函数的线程。事件驱动服务器通常让少量 I/O 线程轮流处理大量连接;其中一个 handler 睡眠时,当前 I/O 线程负责的其他 socket、定时器和长连接也无法及时推进。
把函数改成 C++20 协程也不会自动修复:
1 | // 同样存在隐患:这里没有任何真正的异步等待点。 |
协程解决的是“异步操作完成后怎样恢复代码”的表达问题,不会把阻塞库改造成非阻塞库,也不会凭空创建工作线程。
本文沿着这个故障展开:先看 I/O loop 为什么会被饿死,再实现一个可运行的有界 worker + event loop,最后把 callback、协程、对象生命周期、背压和取消放回 Drogon Gateway 场景。核心判断只有一句:先确认代码在哪个线程执行、等待时线程能否去做别的事,再讨论接口长得像回调还是 co_await。
1. 一秒钟的阻塞为什么会拖慢其他请求?
Reactor(反应器)模型让事件循环等待 socket、定时器等事件,再调用对应处理函数:
1 | poll/epoll/kqueue 返回就绪事件 |
一个框架可以有多个 I/O loop 线程,但单个 loop 通常仍然串行执行自己队列里的回调。假设服务有 4 个 I/O 线程,某次请求在其中一个线程同步等待 500 ms:
1 | I/O thread 1: [request A sleeps 500 ms................] [B] [C] [timer] |
A 不只增加自己的 500 ms 延迟,还让恰好分配到 thread 1 的 B、C 和定时器排队。这种 head-of-line blocking(队头阻塞)会放大尾延迟;SSE 心跳、上传读写和连接超时也可能受影响。
事件循环 handler 最适合做短小、可预测的工作,例如轻量参数检查、构造异步请求和登记完成回调。以下操作则需要格外警惕:
- 同步数据库、HTTP 或模型 SDK;
sleep、阻塞式锁等待和同步文件 I/O;- 大文件解析、压缩、图片处理或 CPU 密集 rerank;
- 等待另一个线程返回结果;
- 没有上限的循环与序列化。
“轻量”不能只凭函数名判断。一个 token 校验函数如果内部同步访问远端密钥服务,它仍然会阻塞 I/O 线程。
2. 阻塞、非阻塞、异步和并行有什么区别?
这些词经常被混用,但它们回答的问题不同:
| 概念 | 回答的问题 | 典型例子 |
|---|---|---|
| 阻塞(blocking) | 操作未完成时,调用线程是否停在这里等待 | 同步 read、sleep_for |
| 非阻塞(non-blocking) | 当前不能立即完成时,调用是否马上返回 | 非阻塞 socket 返回“稍后再试” |
| 异步(asynchronous) | 完成结果是否在以后通过回调、future 或协程恢复交付 | async HTTP client |
| 并发(concurrency) | 多个任务是否在重叠的时间段推进 | 一个 event loop 管理很多连接 |
| 并行(parallelism) | 多个任务是否在同一时刻使用不同执行资源 | worker 线程同时计算 |
一个单线程 event loop 可以高度并发,却不并行执行 handler。一次异步 I/O 也可能立即完成而不真正挂起。反过来,把阻塞调用提交到 worker pool 会避免阻塞 I/O loop,但 worker 本身仍然在阻塞等待。
所以判断一段代码是否安全,不能只看 API 名字里有没有 async,还要追踪:谁调用它、它是否立即返回、完成通知在哪里执行、期间占用了什么资源。
3. 最小可运行版本:有界 worker 如何把结果交回事件循环?
下面的 C++20 示例不依赖 Drogon,用最少结构模拟网络服务中的两个执行域:
1 | EventLoop:串行处理“连接完成回调” |
程序快速提交 8 个请求。worker 只能并行执行 2 个,等待队列最多容纳 2 个;队列满后,请求会立即得到 503 busy,而不是无限积压。已接受任务完成后把响应投递回 event loop。
1 |
|
在 macOS 或 Linux 上,可使用支持 C++20 的 Clang 编译。线程程序需要 -pthread:
1 | clang++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -pthread \ |
每次运行的响应顺序和 accepted 数量可能不同,因为 worker 取任务与主线程提交任务存在真实竞争;这不是测试失败。必须保持的约束是:
1 | accepted + rejected == 8 |
这比固定某一行输出更符合并发程序的验证方式。
4. 哪一步真正保护了 I/O 线程?
try_submit() 在短临界区内检查队列并移动任务,随后立即返回。模拟计算发生在 worker,而不是 EventLoop::run() 所在线程。任务完成后调用 reply(),它只把一个轻量回调放回 I/O 队列。
数据流如下:
1 | request handler |
队列上限不是性能优化,而是内存与延迟边界。假设入口每秒到达 1000 个请求,worker 每秒只能完成 100 个,无界队列会以每秒约 900 个任务增长。它没有消除过载,只是把显式拒绝变成稍后的超时和内存耗尽。
示例析构执行器时会停止接收新任务、排空已经接受的队列并 join worker。生产服务还必须明确关闭策略:是排空、限时排空还是取消未开始任务;不能在 I/O 线程里长时间 join,否则优雅关闭本身也会阻塞事件循环。
还有一个容易漏掉的边界:执行器捕获了 io_loop 和 reply 的引用,因为 main() 明确保证它们活到所有响应完成。真实请求跨越异步边界时,不要捕获 handler 栈上对象的裸引用。应按值保存小对象,或使用 shared_ptr/框架智能指针表达共享寿命。
5. 回调接口为什么要求“恰好回复一次”?
Drogon controller 的典型回调接口把响应出口交给 handler:
1 | void health( |
一个请求的正常、校验失败、上游错误和超时路径都必须最终完成一次响应。少一次会让请求悬挂,多一次则可能触发框架错误或重复写响应:
1 | // 错误示例:失败时直接 return,callback 永远没有执行。 |
正确方向是立即构造错误响应、调用 callback 并结束当前分支:
1 | if (!authorized(request)) { |
复杂异步链路最好把 response completion 封装成共享状态,并用原子标记或在同一执行器上串行化,确保“上游完成”和“超时定时器”竞争时只有赢家能回复。仅凭代码审查认为两个分支不会同时发生,通常不够可靠。
回调捕获也要表达所有权:
1 | start_async_operation( |
不要写 [&] 后让异步操作持有局部变量引用。handler 返回时栈帧已经销毁,稍后调用便会产生悬空引用。
6. 调用上游 HTTP 时,回调链路怎样保持完整?
下面是 Drogon Gateway 调内部服务的集成骨架。Drogon API 会随版本演进,sendRequest 的签名、超时单位、线程亲和性和可用协程适配器都必须以项目锁定版本的头文件与官方示例验证;这段代码不能替代对应版本的集成测试。
1 |
|
示例按值捕获 client,保证请求完成前客户端对象不被局部作用域销毁。真实 Gateway 通常还会考虑连接复用,而不是每个请求新建客户端;客户端能否跨 I/O loop 共享以及如何绑定 event loop,需要结合 Drogon 版本验证。
5.0 也不能替代完整超时设计。连接失败常映射为 502,主动过载可使用 503,等待上游超时通常是 504;但必须根据具体 ReqResult 区分,而不是把所有非 Ok 都包装成同一个错误。请求体、query、响应 Header 和 hop-by-hop 字段的代理策略也应由专门的 HTTP 代理逻辑处理。
普通完整响应回调同样不适合直接代理 SSE。若 API 只在完整 body 到齐时调用 callback,流式响应会被缓冲;这需要项目版本支持的 streaming API,而不是继续嵌套普通回调。
7. 回调嵌套为什么难维护,协程改变了什么?
一个请求依次执行限流、查询用户和调用上游时,回调容易形成向右缩进的结构:
1 | check_rate_limit(user_id, [=](RateResult rate) { |
问题不是缩进本身,而是错误路径、超时、捕获寿命和“是否已经回复”分散在多个闭包里。
C++20 协程允许函数暂停并在以后恢复。编译器把跨越暂停点的参数、局部状态和恢复位置保存在 coroutine frame(协程帧)中,使异步步骤可以写成顺序结构:
1 | // 伪代码:Result/awaitable 与 Drogon 注册方式由项目封装决定。 |
co_await 大致经历三个阶段:awaitable 判断结果是否已经就绪;若未就绪,保存协程状态并把 continuation(后续执行位置)交给调度方;操作完成后,某个执行器恢复协程并取得结果。
这解释了两个重要边界。
第一,协程是无栈(stackless)的可暂停函数,不等于线程。它通常在遇到真正未完成的 co_await 前同步执行;如果这段代码做一秒 CPU 计算,当前线程仍被占用一秒。
第二,恢复在哪个线程由 awaiter、框架和执行器决定,不是 C++ 关键字保证。跨 co_await 使用 thread-local 状态、线程亲和对象或锁时,必须查清框架约定。尤其不要持有 std::mutex 跨越可能挂起的 co_await:协程暂停期间锁仍被占用,恢复还可能发生在另一线程。
8. 协程怎样与 worker pool 配合?
适合直接 co_await 的通常是框架已经提供异步接口的操作:
1 | 异步 Redis / MySQL client |
需要提交 worker 的通常是:
1 | 同步第三方 SDK |
组合后的请求链可以是:
1 | handler coroutine |
这里的 schedule_on_worker 必须是一个正确的 awaitable:它将任务放入有界执行器,完成时安全恢复协程;队列满时应返回可处理的错误,而不是让协程永久挂起。自己实现它还涉及 coroutine handle 的所有权、取消竞态和内存同步,项目已有执行器或框架适配器可用时应优先复用。
不要把所有小任务都投递线程池。排队、同步和缓存迁移也有成本,轻量解析留在 I/O 线程往往更快。是否“重”应通过受控压测与尾延迟衡量,而不是凭文件行数判断。
9. 超时、取消和背压为什么必须共同设计?
异步只表示“等待期间线程可以做别的事”,不代表资源无限。每个在途请求仍可能占用连接、协程帧、回调状态、队列槽位和上游配额。
超时必须受总 deadline 约束
如果 Redis、数据库和上游各自都允许 5 秒,串行链路最坏可能等待 15 秒。更稳妥的模型是从入口创建总 deadline,每一步只能使用剩余预算:
1 | request deadline = now + 5 s |
超时处理还必须解决竞争:客户端超时响应已经发出后,上游完成回调可能紧接着到达。completion gate 要保证只有一个路径回复,并决定迟到结果是丢弃、缓存还是记录。
取消是协作协议,不是删除一个 callback
客户端断开可以触发取消请求,但上游是否真的停止取决于 API 是否支持取消、任务是否到达安全点以及产品语义。即使不再向客户端写响应,也要释放定时器、订阅和队列状态。LLM/SSE 场景还要区分“关闭显示”和“取消后台生成”。
背压要覆盖每一段队列
只有 worker queue 有上限还不够。HTTP client 连接池、Redis 等待队列、SSE 待发送字节和每用户并发任务都可能成为无界缓冲。过载时可以选择 429、503、断开慢订阅者或降低任务质量,但不能用无限排队掩盖容量不足。
10. 生命周期错误为什么常在请求结束后才爆炸?
异步代码把“使用对象”的时间推迟了。以下捕获在 handler 返回后会悬空:
1 | // 错误示例:local_body 和 request_id 都是栈对象引用。 |
应按值移动需要的数据,或让共享状态拥有明确生命周期:
1 | call_async( |
协程也不是自动安全。按值传入协程的参数会进入协程状态;引用参数仍然只是引用,恢复时被引用对象可能已经销毁。立即调用的 coroutine lambda 还要小心闭包对象本身先于协程结束被销毁。对跨暂停点的数据,优先使用按值参数和所有权清晰的智能指针。
服务关闭又增加一层生命周期:先停止接收新请求,通知取消或停止生产,再等待受控时间排空任务,最后销毁客户端、执行器和 event loop。反过来销毁依赖对象,会让尚未完成的回调访问释放后的内存。
11. 已发布 RAG 与 VisionTrack 怎样划分执行边界?
本节于 2026-10-03 对照两个项目的已发布源码更新。一般执行器建议需要与项目当前实现分开:RAG 并未把所有阻塞操作统一改造成异步客户端,VisionTrack 的上传任务池也不是持久化队列。
11.1 RAG Gateway Stack
1 | Drogon Gateway:外部路由、上传控制、鉴权与限流 |
Gateway 负责传输边界,解析与向量化进入 Celery。普通 Chat 与 Agent 使用稳定的消息 / trace 标识和 Last-Event-ID 恢复同一次运行;恢复状态不是跨进程持久化事件日志。进一步异步化 SSE 客户端或调整线程池属于后续演进,需要并发测量支持。
11.2 VisionTrack / YOLO Tracking
1 | Drogon yolo_api 进程 |
实时源在异步配置与所需调度器可用时进行非阻塞模型调用;本地文件、关闭异步或缺少调度器时有同步回退。上传视频的 worker、实时流和共享模型请求池都是进程内资源,重启后不能恢复任务。
需要观察队列深度、实际刷新率、过期结果、帧丢弃、尾延迟和资源清理。上传池满返回 503,实时注册容量满或 ID 冲突返回 409;协程语法本身不会改变这些容量约束。
实现入口:RAG 架构与限制、VisionTrack 调用链。
12. 常见误区:换成异步语法为什么没有解决问题?
误区一:handler 有 callback,所以不会阻塞
callback 只是完成响应的接口。callback 之前的同步代码仍在当前调用线程执行,阻塞调用仍会饿死 event loop。
误区二:写了 co_await,函数就会去另一个线程
协程是否挂起、在哪里恢复都由 awaitable 和执行器决定。sleep_for、同步查询和 CPU 循环不会因外层是协程而改变性质。
误区三:多开 I/O 线程可以掩盖阻塞
更多线程可能延后症状,却增加上下文切换和资源消耗;只要每个 loop 都可能被长任务占用,尾延迟问题仍存在。线程数应结合框架、CPU 和压测选择。
误区四:线程池只要能执行任务就够了
无界队列会在过载时吞噬内存并放大等待时间。执行器必须有容量、拒绝策略、异常边界和关闭语义。
误区五:异步错误只要最终记录日志即可
请求仍需要恰好一次完成。成功、失败、超时、取消和队列拒绝必须汇聚到同一个 completion 规则。
误区六:[&] 更省复制,适合回调
同步算法中成立的引用寿命,不会自动延伸到异步完成时。跨异步边界捕获引用前必须证明其 owner 活得足够久。
误区七:超时后上游自然就停止了
本地停止等待与远端取消是两件事。没有取消协议时,上游可能继续消耗计算;即使能取消,也要处理完成与取消同时到达的竞争。
13. 回调、协程和线程池分别适合什么?
| 工具 | 适合场景 | 不应期待它解决 |
|---|---|---|
| 回调 | 步骤少、框架原生接口、完成路径简单 | 自动管理复杂错误与对象寿命 |
| C++20 协程 | 多步异步 I/O、希望集中控制流与错误处理 | 自动并行、自动取消、把阻塞 API 变异步 |
| 有界线程池 | 隔离同步阻塞库或可并行 CPU 工作 | 无限吞吐、替代 I/O 多路复用 |
| 独立任务系统 | 长任务、跨连接恢复、进程故障隔离 | 低成本的每请求微小操作 |
简单健康检查用 callback 已经足够,没必要为了统一风格改成协程。需要连续等待 Redis、数据库和 HTTP 的链路,协程通常更容易维护。同步 PDF 解析无法直接异步化时,用有界 worker 是务实选择;任务持续数分钟并要求断线恢复时,则应考虑独立 worker 和持久化状态,而不是占住 Web 请求对象。
14. 怎样验证线程模型没有隐藏阻塞?
除了功能测试,还应设计能暴露调度与过载问题的验证:
- 同一个 I/O loop 上发起一个慢请求与多个健康检查,确认慢任务不会拖住健康检查;
- 填满 worker 队列,确认新请求立即走约定的 429/503,而不是无限等待;
- 让上游完成与超时定时器同时触发,验证 callback 只调用一次;
- 客户端断开后检查上游任务、定时器和共享状态是否按策略释放;
- 在 AddressSanitizer、ThreadSanitizer 适用的独立测试中覆盖悬空捕获和数据竞争;
- 关闭服务时验证停止接入、排空或取消、join 与资源销毁顺序;
- 记录 p50、p99、队列等待、执行时间和拒绝率,不能只看平均吞吐。
并发测试的输出顺序通常不稳定,应断言不变量,例如“每个请求恰好一个终止结果”和“队列深度永不超过上限”,而不是依赖某个线程先打印。
15. 总结
开头的接口虽然使用 Drogon callback,仍然会卡住服务,因为一秒钟的同步等待发生在 I/O 线程。把它机械改写成协程也无效:没有真正的异步 awaitable,线程仍停在原地。
工程中最重要的结论是:
- I/O loop 只承担短小处理和异步编排,阻塞或 CPU 重活进入有界 worker;
- callback、协程和线程池是不同维度的工具,语法异步不等于执行非阻塞;
- 每个请求必须在成功、错误、超时、取消和拒绝之间恰好完成一次;
- 跨异步边界必须显式管理对象、协程帧和执行器的生命周期;
- 超时、取消与背压要覆盖完整链路,而不是只给某个 HTTP 调用设置一个数字。
最直接的实践建议是:审查 handler 时,在每个可能等待或高耗时的调用旁标出“当前线程、是否挂起、恢复线程、容量上限和取消方式”。这五个问题答不清楚,再漂亮的异步接口也可能只是同步阻塞的外壳。