| name | concurrency-audit |
| description | 新增或修改原子操作、异步定时器、无锁通道、跨线程状态同步、CAS 竞争相关代码时触发。 |
Skill: 并发原语审计
在每线程单执行器的协程架构中,大部分协程天然串行,不需要传统同步原语。但跨线程状态同步、资源限额、异步定时器、协程间信号等场景仍然依赖原子操作和异步通知。这些原语虽然"允许使用",但用错了比不用更危险——错误通常是偶发的、难以复现的、且在特定负载模式下才暴露。
本 skill 与 coroutine-audit 互补:coroutine-audit 管"禁止什么"(禁止阻塞、禁止互斥锁),本 skill 管"允许但要用对"(原子序要用对、定时器要管理好、通道语义要理解)。
触发条件
- 新增
std::atomic 成员或修改原子操作的内存序
- 新增异步定时器或修改定时器模式
- 新增无锁通道或修改通道容量/使用方式
- 修改 CAS(compare-and-swap)相关逻辑
- 修改跨线程状态传递方式
- 新增
co_spawn(尤其是涉及共享状态的协程)
核心原理
内存序不是"越强越好"
很多开发者习惯所有原子操作都用 memory_order_seq_cst(最强序),觉得"安全"。但在协程架构中:
- 同执行器上的协程天然串行——同一
io_context 上的两个协程永远不会并发执行。对同一执行器上的状态同步,relaxed 足够
- 跨执行器的同步才需要更强的序——一个 worker 上的协程写状态,另一个 worker 上的协程读状态,才需要
acq_rel 保证因果性
- 统计/计数场景对因果性无要求——流量计数器、连接数统计等"最终一致即可"的场景,
relaxed 是正确选择。过强的序不仅无意义,还会引入不必要的 CPU 屏障开销
TOCTOU 是无锁编程的本质难题
"检查然后操作"(Time Of Check To Time Of Use)问题存在于所有 CAS 场景中:
- 检查时值是 A,操作时值已变为 B,CAS 失败——这是正常行为,需要重试或降级
- 检查时值是 A,操作时值已变为 B 又变回 A——经典 ABA 问题,CAS 成功但语义错误
在资源限额场景中,TOCTOU 导致的"偶尔多接受一个连接"通常可接受。但如果限额是安全硬约束(如合规要求),则需要更强的机制。
审计清单
1. 原子操作内存序选择
| 检查项 | 说明 |
|---|
| 同步模式用 acq_rel | 跨线程的状态同步(如"停止"标志位的发布-订阅、任务队列的入队通知)需要 release(写端)+ acquire(读端)配对。release 保证写端之前的所有写操作对读端可见,acquire 保证读端之后的所有读操作看到最新值 |
| 统计用 relaxed | 计数器、仪表盘等纯统计场景只需要原子性(不撕裂),不需要因果序。relaxed 保证读写的原子性但不保证顺序——对"最终一致即可"的统计完全正确 |
| 标志位用 release/acquire | 单向通知(如"已关闭"、"已初始化")用 release 写入、acquire 读取。比 acq_rel 更精确:写端只保证"之前的写可见",不需要同步读端的状态 |
| 避免 seq_cst 滥用 | seq_cst 是最强序,隐含全局全序。除非需要"所有线程看到相同的操作顺序"(如全局初始化屏障),否则不应使用。过度使用 seq_cst 引入不必要的内存屏障,在 ARM 等弱序架构上性能影响显著 |
| 类型选择正确 | atomic<bool> 和 atomic<uint32_t> 是否选对了类型?用 uint32_t 存 0/1 是反模式,应直接用 atomic<bool>。用 bool 存多状态(0/1/2)是溢出风险 |
2. 异步定时器生命周期
| 检查项 | 说明 |
|---|
| cancel-then-destroy 顺序 | 定时器销毁前是否先取消?未取消的定时器在销毁后仍可能触发回调,导致访问已销毁的对象。正确模式:先 cancel() 投递取消请求,再销毁定时器对象。在单线程执行器上,cancel 投递的 handler 会在当前协程挂起后执行——如果定时器对象在此之前被销毁,需要确保 handler 中的 self 保活 |
| 共享定时器的双投递语义 | 两个协程共享一个定时器时,第二个 expires_after() 会取消第一个的等待。功能上正确(隐式取消),但依赖执行器的隐式取消保证,重构时容易引入 bug。如果两个协程需要独立的超时,应使用独立的定时器 |
| 取消路径的异常安全 | 定时器取消后 async_wait 返回 operation_aborted 错误码。调用方是否正确处理了这个错误码?如果用 redirect_error 吞掉了错误码,需要确认取消是预期行为而非意外 |
| 定时器回调中的对象存活 | 定时器的 async_wait 回调中引用的对象是否保证在回调触发时仍然存活?如果对象可能先于定时器销毁,需要用 self 捕获保活或在销毁前显式取消定时器 |
3. TOCTOU 与 CAS 防御
| 检查项 | 说明 |
|---|
| CAS 失败的降级路径 | CAS 失败后是否有合理的降级?重试(CAS 循环)适用于低竞争场景;异步等待(co_await 信号)适用于高竞争场景。无限重试的 CAS 循环在竞争激烈时退化为忙等待——参见 coroutine-audit 的禁止清单 |
| 限额检查的近似性 | 连接数限额的 CAS 检查在高并发下可能偶尔超出限额(N 个线程同时读到相同计数器值,全部通过检查)。如果严格限额是硬约束,需要更强的同步(如全局计数器 + 独占检查)。如果近似限额可接受,在审计报告中显式标注"近似限额"并说明超出的最大值 |
| ABA 问题的可能性 | 值从 A → B → A 时 CAS 成功但语义错误。典型场景:指针从 P1 → P2 → P1(P1 被回收后重新分配)。对于简单的计数器(只增减不回环),ABA 不是问题。对于指针/索引的 CAS,需要额外机制(如版本号、epoch) |
| exchange 的幂等性 | 用 exchange 设置"已执行一次"标志时,第二次调用是否安全?exchange 返回旧值但不阻止重复调用。如果重复调用有副作用(如重复通知、重复释放),需要在调用前先检查返回值 |
4. 通道与信号语义
| 检查项 | 说明 |
|---|
| 通道容量与背压 | 无界通道不会阻塞发送者,但消费者跟不上时内存无限增长。有界通道在满时阻塞发送者,提供背压但可能导致死锁。选择容量时需要评估消费者的处理速度和峰值缓冲需求 |
| try_send 的丢弃风险 | 非阻塞的 try_send 在通道满时静默丢弃消息。丢弃是否可接受?如果消息是错误通知,丢弃可能导致下游挂起。如果下游同时有其他感知机制(如 transport 关闭),丢弃可接受 |
| 关闭后的消费终止 | 通道关闭后,正在消费的循环是否能正确终止?标准模式是"关闭返回空/错误"。如果关闭后仍尝试 send,需要确认行为是静默失败还是触发断言 |
| 单写者保证 | 通道是否为单写者?如果不是,多个写者的并发发送是否线程安全?标准无锁通道通常支持多写者,但自定义通道可能不支持 |
5. 单线程保证的合理利用
| 检查项 | 说明 |
|---|
| 同执行器无竞争 | 同一 io_context 上的协程天然串行,不构成数据竞争。同执行器上的共享状态不需要原子操作——普通成员变量足够。但需要文档化"此状态仅在 X 执行器上访问"的约定,防止未来维护者误跨线程使用 |
| strand 的必要性 | 如果多个执行器需要访问同一状态,是否使用了 strand(序列化包装器)?strand 保证所有通过它投递的操作串行执行,是"多执行器单状态"的标准解决方案。但 strand 引入额外的调度开销,应仅在必要时使用 |
| 跨线程派发的正确性 | 使用 post(target_executor, handler) 跨线程派发任务时,handler 中访问的对象是否属于目标执行器?跨线程派发后,原线程上的对象状态可能已经变化 |
审计流程
- 枚举并发原语:列出变更涉及的所有
atomic、定时器、通道、co_spawn 调用
- 绘制线程边界:标注每个并发原语涉及哪些执行器/线程。同执行器内的访问天然安全,跨执行器的访问需要验证同步
- 验证内存序:对每个跨执行器的原子操作,验证内存序是否匹配同步需求(统计 → relaxed,状态同步 → acq_rel,标志 → release/acquire)
- 验证定时器生命周期:对每个定时器,追踪"谁创建、谁取消、谁销毁"的完整链路
- 验证 CAS 降级:对每个 CAS 循环,验证失败后的降级路径(重试 vs 异步等待 vs 放弃)
- 验证通道语义:对每个通道,验证容量选择的合理性、关闭后的消费终止、丢弃风险的可接受性
常见反模式(禁止)
内存序过强
counter_.fetch_add(1, std::memory_order_seq_cst);
flag_.store(true, std::memory_order_seq_cst);
counter_.fetch_add(1, std::memory_order_relaxed);
flag_.store(true, std::memory_order_release);
定时器未取消就销毁
void on_close()
{
timer_.reset();
}
void on_close()
{
timer_.cancel();
timer_.reset();
}
CAS 无限循环
while (true)
{
auto old = counter_.load();
if (counter_.compare_exchange_weak(old, old - 1))
{
break;
}
}
while (!acquired && active())
{
auto old = window_.load(std::memory_order_acquire);
if (old >= needed && window_.compare_exchange_weak(old, old - needed))
{
acquired = true;
break;
}
co_await signal_->async_wait();
}
try_send 丢弃导致挂起
void on_error(error_code ec)
{
channel_.try_send(ec);
}
void on_error(error_code ec)
{
channel_.try_send(ec);
transport_->close();
}
跨线程修改共享上下文
void setup(connection& conn)
{
conn.shared_context()->set_callback(my_callback);
}
void setup(connection& conn)
{
conn.set_callback(my_callback);
}
交叉引用
coroutine-audit 覆盖了禁止阻塞调用、禁止互斥锁、协程纯度维度
co-lifecycle-audit 覆盖了 shared_ptr 循环引用、co_await 后引用失效维度
error-chain-audit 覆盖了异步任务的错误处理和异常安全维度
crypto-audit 覆盖了常量时间比较、时序侧信道防御维度
probe-audit 覆盖了响应时间分布一致性维度