并发架构反模式
反模式不是“某个 API 永远不能用”,而是局部代码看似正常,却把容量、所有权或故障责任转移到了无人管理的位置。本页从可观察症状出发,帮助你在改线程数或队列大小之前先确认模型是否正确。
快速症状索引
| 现场症状 | 优先检查 |
|---|---|
| 队列持续变长,CPU 却不高 | 永久阻塞任务、worker 内等待、外部 I/O 无超时 |
| 紧急任务仍然很慢 | 把优先级当抢占或实时保证 |
| 请求返回成功,后台却漏处理 | future 被丢弃、fire-and-forget 无失败观察 |
| 退出偶发卡住 | 先 shutdown 后停生产者、任务无停止点、析构顺序错误 |
| 偶发崩溃或数据错乱 | lambda 捕获引用、this 或跨实例资源 |
| 内存稳定增长、延迟越来越长 | 用大队列吸收持续过载 |
| 任务图在小线程数下卡住 | dependent wrapper 等待前置、在途依赖链过多 |
| 实时循环周期尖刺 | 回调阻塞、运行期分配、单周期消费无预算 |
| 监控显示正常但用户失败 | 只看吞吐均值,没看 future、拒绝、超时与业务结果 |
1. 把永久循环提交到共享线程池
典型写法
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 里同步等待同一个线程池
典型写法
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 后立即返回
典型写法
void Controller::update(const Command& command) {
executor_.submit_auto([&] { apply(command, state_); });
}为什么出问题
command 可能在函数返回后销毁,Controller 也可能在任务开始前析构。问题通常只在负载高或关闭竞争时出现,表现为偶发崩溃、损坏数据或测试无法复现。
修正
- 小型输入按值捕获或 move 进任务。
- 共享状态使用明确所有权;如果用
shared_ptr,同时设计停止和释放时机,避免用它掩盖永久生命周期。 - 关闭时先停止新提交,再等待所有捕获业务对象的任务,最后析构对象。
- 不使用
[&]或[this]作为异步 lambda 的无脑默认值。
验证信号
在提交后立即销毁调用方对象的测试中,任务仍具有定义良好的行为;ASan/TSAN 不报告生命周期或数据竞争问题。
5. 丢弃 future,又没有第二条失败路径
典型写法
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. 关闭顺序反了
典型顺序
销毁业务对象 → shutdown Executor → 停止定时器和设备回调为什么出问题
上游仍可能提交新任务,已排队任务又捕获了刚销毁的对象。关闭过程会出现提交竞争、悬空访问或永远等不到生产者停止。
修正
停止对外接收
→ 停止定时器、设备回调和其他生产者
→ 取消软周期任务
→ 停实时上游,再停实时任务
→ 有界排空普通任务
→ 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_cycle或RealtimeChannel::drain_for_cycle()限制消费预算。 - 预分配资源,把日志和复杂诊断转交非实时线程。
- 观察 priority、affinity、memory lock 和 timer slack 是否实际应用,而不是只看 start 成功。
验证信号
报告 p95/p99/max jitter、周期超时和 drop,而不只看平均周期;在队列积压、权限不足和日志后端变慢时重复测试。
评审时问这八个问题
- 这段工作有界完成吗?若没有,谁拥有它的停止协议?
- 任务输入由谁拥有,异步执行期间是否仍有效?
- 谁消费 future、task ID、handle 或推送返回值?
- 生产速度超过消费能力时,数据应排队、拒绝、覆盖还是丢弃?
- 超时代表排队太久、等待太久,还是业务已经取消?
- 正确性是否错误依赖优先级、执行速度或碰巧的完成顺序?
- 关闭时先停谁、等多久、超时后接受什么后果?
- 线上能否从状态和事件区分任务异常、拒绝、背压、实时 drop 与调优回退?
如果其中任何一个答案是“到时候看日志”,接入还没有形成可靠协议。需要逐步改造现有代码时阅读从现有线程代码迁移;准备上线时使用生产接入检查清单。