Skip to content

并发架构反模式

反模式不是“某个 API 永远不能用”,而是局部代码看似正常,却把容量、所有权或故障责任转移到了无人管理的位置。本页从可观察症状出发,帮助你在改线程数或队列大小之前先确认模型是否正确。

快速症状索引

现场症状优先检查
队列持续变长,CPU 却不高永久阻塞任务、worker 内等待、外部 I/O 无超时
紧急任务仍然很慢把优先级当抢占或实时保证
请求返回成功,后台却漏处理future 被丢弃、fire-and-forget 无失败观察
退出偶发卡住先 shutdown 后停生产者、任务无停止点、析构顺序错误
偶发崩溃或数据错乱lambda 捕获引用、this 或跨实例资源
内存稳定增长、延迟越来越长用大队列吸收持续过载
任务图在小线程数下卡住dependent wrapper 等待前置、在途依赖链过多
实时循环周期尖刺回调阻塞、运行期分配、单周期消费无预算
监控显示正常但用户失败只看吞吐均值,没看 future、拒绝、超时与业务结果

1. 把永久循环提交到共享线程池

典型写法

cpp
executor.submit_auto([&] {
    while (running) {
        consume(socket.read());
    }
});

为什么出问题

一个永久循环永久占用一个 worker;阻塞 I/O 还可能不响应应用停止。此类任务增多后,线程池状态表现为 active 持续占满、queued 上升,但 CPU 利用率不一定高。shutdown(true) 只能等待,不能安全地终止任意 C++ 函数。

修正

  • 需要 Executor 管理 stop/wake/join 生命周期的长期阻塞循环,使用阻塞 I/O worker;读取后只提交短计算。
  • 软周期维护使用 submit_periodic(),保存并取消 task ID。
  • 有 jitter 预算的循环使用实时任务 Facade。
  • 所有阻塞 I/O 设置超时或可唤醒停止机制。

验证信号

停止生产者后 active 数应在业务预算内下降;关闭测试不能依赖进程强制退出。

2. 在 worker 里同步等待同一个线程池

典型写法

cpp
executor.submit_auto([&] {
    auto child = executor.submit_auto(load_part);
    return combine(child.get());
});

为什么出问题

父任务占住 worker 等 child;如果所有 worker 都执行同样模式,child 只能在队列里等待空闲 worker,形成线程池饥饿。增加线程数只能提高触发门槛,不能修复无界嵌套等待。

修正

  • 在调用方拆分任务并组合结果,避免 worker 内阻塞等待。
  • 数量可控的完成关系使用 TaskHandle 显式表达,并先提交前置任务。
  • 注意当前 dependent wrapper 仍可能在 worker 中等待;控制图规模并以最小线程数压测。
  • 大规模动态 DAG 使用专门图调度器。

验证信号

min_threads / max_threads 暂时设小仍能完成;queued 不会在 active 全满时永久不下降。

3. 把优先级当作抢占或 deadline

典型误解

“标成 CRITICAL 后,控制命令一定会立即执行。”

为什么出问题

submit_priority() 只影响等待队列选择,不能抢占已经运行的任务。若所有 worker 都被长任务或阻塞任务占用,高优先级任务仍要等待;多个 worker 的完成顺序也不由优先级保证。

修正

  • 把长任务切成有界步骤,避免占用全部 worker。
  • 为控制平面保留稀缺优先级,不让所有模块都使用最高级。
  • 有固定周期和 jitter 预算时改用专用实时任务。
  • 正确性顺序使用任务依赖或业务状态机,不使用优先级碰运气。

验证信号

分别测量排队时间与执行时间;在低优先长任务占满 worker 时验证系统的明确降级,而不是只测空载优先级。

4. 捕获引用、裸指针或 this 后立即返回

典型写法

cpp
void Controller::update(const Command& command) {
    executor_.submit_auto([&] { apply(command, state_); });
}

为什么出问题

command 可能在函数返回后销毁,Controller 也可能在任务开始前析构。问题通常只在负载高或关闭竞争时出现,表现为偶发崩溃、损坏数据或测试无法复现。

修正

  • 小型输入按值捕获或 move 进任务。
  • 共享状态使用明确所有权;如果用 shared_ptr,同时设计停止和释放时机,避免用它掩盖永久生命周期。
  • 关闭时先停止新提交,再等待所有捕获业务对象的任务,最后析构对象。
  • 不使用 [&][this] 作为异步 lambda 的无脑默认值。

验证信号

在提交后立即销毁调用方对象的测试中,任务仍具有定义良好的行为;ASan/TSAN 不报告生命周期或数据竞争问题。

5. 丢弃 future,又没有第二条失败路径

典型写法

cpp
executor.submit_auto(write_record);
return Accepted;

为什么出问题

返回 Accepted 只说明调用点没有同步失败,不说明任务最终执行成功。任务异常、软超时或提交拒绝可能只存在于无人消费的 future 与累计状态中,用户看到成功,系统却漏数据。

修正

  • 请求结果需要确认时持有 future,并在业务预算内取值。
  • 真正 fire-and-forget 的任务设置 failure callback/status 与业务关联 ID。
  • 对关键副作用使用持久化 outbox、重试队列或幂等协议;Executor 不是交付保证系统。
  • submit_batch_no_future() 仅用于已设计服务级完成与失败观察的批次。

验证信号

故意让任务抛异常或队列拒绝时,业务指标、告警和日志都能关联到输入;不能只看到 Executor 总失败数。

6. 用大队列掩盖持续过载

典型做法

队列满后把 queue_capacity 从一千调到十万,短期不再拒绝,但端到端延迟持续增长。

为什么出问题

当平均到达率长期高于处理率,任何有限队列最终都会满。扩大队列只是让失败更晚发生,并可能处理已经过时的数据、增加内存占用和关闭时间。

修正

  • 先测到达率、服务时间、queued 与端到端年龄。
  • 必须处理每条数据时实施上游限流或扩容。
  • 只关心最新状态时使用 LatestMailbox,不要排队旧版本。
  • 允许丢弃时在业务入口明确 drop policy 并计数。
  • 为提交拒绝和等待超时设计降级,不假设它们永不发生。

验证信号

持续过载测试中,内存和延迟有上界;拒绝、覆盖或降级行为与业务协议一致且可观察。

7. 混用 Executor 实例和资源

典型误用

  • 用实例 A 创建 TaskHandle,交给实例 B 的 submit_after()
  • 缓存 manager 持有的 realtime/GPU executor 指针,跨越 shutdown 使用。
  • 某个组件关闭共享单例,其他组件继续提交。

为什么出问题

句柄、注册表和直接执行器指针都附着于创建它们的 manager。跨实例没有共同任务图或生命周期;直接指针也不拥有目标对象。

修正

  • 在接口中显式传入同一个 Executor 引用,不让模块自行选择单例或独立实例。
  • 句柄只在本次任务图和原 Executor 生命周期内流转。
  • 直接指针仅在高级局部代码中短期使用,并受 manager 生命周期保护。
  • 共享单例只有应用 owner 能 shutdown。

验证信号

组件测试使用独立 Executor 时不会意外访问全局实例;关闭一个隔离实例不影响其他实例。

8. 关闭顺序反了

典型顺序

text
销毁业务对象 → shutdown Executor → 停止定时器和设备回调

为什么出问题

上游仍可能提交新任务,已排队任务又捕获了刚销毁的对象。关闭过程会出现提交竞争、悬空访问或永远等不到生产者停止。

修正

text
停止对外接收
→ 停止定时器、设备回调和其他生产者
→ 取消软周期任务
→ 停实时上游,再停实时任务
→ 有界排空普通任务
→ shutdown Executor
→ 销毁任务依赖对象与日志设施

关闭后的 Executor 不能重新初始化。组件需要重启时重建独立运行时及其业务状态。

验证信号

在关闭过程中持续制造提交竞争,调用方得到明确拒绝;排空有超时快照,且业务对象析构发生在相关任务完成之后。

9. 把软超时当作强制取消

典型误解

“设置 task_timeout_ms = 100 后,任何任务都会在 100 ms 被终止。”

为什么出问题

当前软超时检查的是任务开始前的排队时间;任务开始运行后不会被外部强制中断。wait_for_completion_ex() 超时也只说明尚未全部完成。

修正

  • 网络、文件和设备 I/O 使用各自超时。
  • CPU 长任务分段检查 stop_token、原子停止标志或业务 deadline。
  • 超时后明确结果是否可丢弃、是否允许任务继续产生副作用。
  • 不把 shutdown(false) 当作安全杀线程。

验证信号

构造一个已经开始且超过预算的任务,系统不会误报“已取消”;调用方能区分排队超时、等待超时和业务取消。

10. 在实时回调里做阻塞、分配和无界 drain

典型写法

实时周期内等待 mutex、写同步日志、动态扩容容器,或一次处理队列中所有积压命令。

为什么出问题

任何无界工作都会进入周期时间,造成 jitter 和 cycle_timeout_count 尖刺。提高线程优先级不能消除锁竞争、缺页、分配器停顿或无限消费。

修正

  • callback 只执行固定上界的控制计算。
  • max_tasks_per_cycleRealtimeChannel::drain_for_cycle() 限制消费预算。
  • 预分配资源,把日志和复杂诊断转交非实时线程。
  • 观察 priority、affinity、memory lock 和 timer slack 是否实际应用,而不是只看 start 成功。

验证信号

报告 p95/p99/max jitter、周期超时和 drop,而不只看平均周期;在队列积压、权限不足和日志后端变慢时重复测试。

评审时问这八个问题

  1. 这段工作有界完成吗?若没有,谁拥有它的停止协议?
  2. 任务输入由谁拥有,异步执行期间是否仍有效?
  3. 谁消费 future、task ID、handle 或推送返回值?
  4. 生产速度超过消费能力时,数据应排队、拒绝、覆盖还是丢弃?
  5. 超时代表排队太久、等待太久,还是业务已经取消?
  6. 正确性是否错误依赖优先级、执行速度或碰巧的完成顺序?
  7. 关闭时先停谁、等多久、超时后接受什么后果?
  8. 线上能否从状态和事件区分任务异常、拒绝、背压、实时 drop 与调优回退?

如果其中任何一个答案是“到时候看日志”,接入还没有形成可靠协议。需要逐步改造现有代码时阅读从现有线程代码迁移;准备上线时使用生产接入检查清单