博主介绍:程序喵大人
- 35 - 资深C/C++/Rust/Android/iOS客户端开发
- 10年大厂工作经验
- 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
- 《C++20高级编程》《C++23高级编程》等多本书籍著译者
- 更多原创精品文章,首发gzh,见文末
- 👇👇记得订阅专栏,以防走丢👇👇
😉C++基础系列专栏
😃C语言基础系列专栏
🤣C++大佬养成攻略专栏
🤓C++训练营
👉🏻个人网站
在日常的 code review 过程中,我们经常会遇到一些看起来设计得非常精细但实际上隐藏着竞态风险的提交。比如某个改动任务调度代码的 PR,其 diff 记录里既有 std::mutex、又有 atomic 的 relaxed 读写、甚至还有 release/acquire 配对和 compare_exchange_weak。作者在 PR 描述里写了一句"按性能场景挑了最合适的内存序"。
虽然这些选择在字面上看起来都非常合理------用 mutex 保护复杂状态、用 relaxed 计数、用 release/acquire 发布数据、用 CAS 进行状态迁移------但作为审查者,我们最先要关注的并不是这些底层的原子操作,而是应该把这段代码中的共享状态图画出来:哪些变量是多线程访问的、谁在写、谁在读、变量之间有没有不变量、有没有"先检查 X 再修改 Y"这种组合动作。
把这张图画出来之后,我们通常能够更直观地发现隐藏在底层的时序逻辑问题。例如某个被标记为 relaxed 的计数器实际上可能承担了"发布是否就绪"的语义约束,或者某对 release/acquire 没有正确配对到同一个原子变量上,甚至某次 CAS 的失败路径根本没有被合理处理。如果一上来就紧盯着底层的内存序参数,这些更高层面的逻辑缺陷反而非常容易被忽略。
为了帮助大家把前面十二章中拆解过的各种工具语义和边界串联起来,这一章我们将重点探讨在面对一段真实的并发代码时该如何进行选择、审查与验证。读完这一章,我们可能依然无法成为无锁编程的专家,但我们会清晰地知道在何种场景下应当使用互斥锁、何时应该信任默认的 seq_cst、何时可以安全地降级到 relaxed,以及在面对怎样的并发提交时应当坚决予以拒绝。
决策起点不是"用哪个内存序",是"共享状态是什么"
很多关于并发设计的讨论往往会过早地陷入到诸如"这里 relaxed 能不能用"或者"那里 acquire 是不是多余了"等具体内存序的选择上,而忽略了最核心的共享状态分析。在开始动手编写或评审并发逻辑之前,我们通常应当首先明确以下几个根本问题:
哪些数据真正被多线程访问:并非只有被 std::atomic(见《04-std-atomic到底保证了什么》)包装过的才算多线程数据,也不是普通变量就一定是单线程数据,最关键的在于运行时这些数据是如何被读写的。如果一个 atomic 变量仅在 thread_local 作用域中被单线程独占,那它并不属于并发同步的数据源;相反,只要多个线程会并发碰触到同一个普通容器,它就必须被纳入同步保障的范畴。
这些数据之间是否存在特定的不变量:不变量是指"几个变量在逻辑上必须时刻保持某种协调关系"。例如队列的头指针、尾指针与计数器之间,或者账户的余额与冻结金额之间,如果存在这种整体的约束,单独保护每一个原子变量往往是无法保证正确性的,我们需要将它们作为一个整体来进行同步保护。
是否存在"先检查再修改"的组合操作:我们还需要警惕是否存在"先检查再修改"的竞态条件。在《10-CAS-compare-exchange和无锁编程入门》中我们曾深入讨论过这种由于读写分离导致的竞态,因为哪怕各个单步操作都是原子的,它们组合在一起时依然无法保证事务的原子性,这通常需要使用 CAS 操作或者互斥锁将整个过程整合为一个原子事务。
谁负责发布数据,谁负责消费数据:我们要识别出是否存在"数据就绪"的状态变化,理清发布点和消费点是一对一、多对一还是多对多的协作关系,并明确数据在成功发布之后是作为只读快照使用,还是会面临后续的并发修改。
对性能、可读性以及可维护性的要求:我们需要客观评估该并发路径的调用频次、延迟敏感度,以及团队成员对无锁代码的后续维护能力。此外,项目是否需要在 ARM 等弱内存模型的处理器架构上部署运行,也是非常关键的前提。
在理清了上述这五个基本问题之后,内存序的选择通常就会变得自然而然。反之,如果跳过这些核心前提直接挑选内存序,很容易导致要么为了追求所谓的极致性能而使用了过弱的内存序引发 bug,要么盲目地全部采用 seq_cst 造成性能上的无谓浪费。

多变量不变量优先 mutex
当并发设计涉及多个共享变量之间的一致性关系时,我们推荐的第一选择通常是 std::mutex 而不是各种原子操作的组合。因为 std::atomic(见《04-std-atomic到底保证了什么》)只能确保单个变量自身的单次读写是不可分割的,而多个不同的原子变量之间并没有天然的"原子组合"关系,很容易被其他线程观测到破坏了一致性的中间状态:
cpp
counter.fetch_add(1, ...);
list.push_back(...);
为了确保"计数器和列表元素数始终一致"这个不变量在并发环境中不被打破,我们必须将这两次写入合并到一个临界区里,而临界区的天然管理工具正是互斥锁。我们可以看一段非常经典的阻塞队列实现:
cpp
#include <condition_variable>
#include <mutex>
#include <queue>
template <typename T>
class BlockingQueue {
public:
void Push(T value) {
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(value));
}
cv_.notify_one();
}
T Pop() {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
return value;
}
private:
std::mutex mutex_;
std::condition_variable cv_;
std::queue<T> queue_;
};
这段代码的同步语义非常清晰,所有对 queue_ 的状态修改都在 mutex_ 的保护之下发生,并且通过条件变量配合谓词形式优雅地规避了虚假唤醒问题。在真实的业务场景中,我们应当极力避免一上来就推倒锁设计去追求所谓的"无锁队列",主要基于以下几点工程考量:
- 现代操作系统的互斥锁在没有竞争的场景下执行速度极快,例如 std::mutex::lock 在无竞争时本质上只是一次简单的 CAS 原子操作,其开销可以忽略不计。
- 真正的性能瓶颈往往并不在互斥锁本身,而是取决于我们在临界区中执行了哪些操作。如果临界区执行时间极短且锁竞争很低,使用锁就已经完全足够了。
- 如果贸然改为无锁队列,就必须处理复杂的 ABA 问题、内存回收机制以及缓存行伪共享等问题,其实现复杂度是呈指数级上升的,而最终能获得的性能提升却往往非常有限。
在工程开发中,遵循"先用 mutex 编写正确且清晰的代码,然后通过 profiler 工具量化性能瓶颈,最后评估是否值得用无锁结构改写"的路线通常是最稳妥的做法,而一上来就盲目追求无锁往往会耗费大量的开发和调试成本,却无法换来相匹配的业务收益。除了阻塞队列以外,以下几种典型的工程场景同样更适合使用互斥锁来进行同步:
- 复合缓存对象:例如将一个 map、对应的 LRU 双向链表以及各个元素的引用计数作为整体同步更新的场景。
- 连接管理器:包含状态机转换、Socket 句柄、待处理请求队列以及安全关闭标志的复杂组合体。
- 就地修改的配置中心:当配置项需要被原地实时读写和修改,而不是通过替换指针来实现更新时。
- 标准的 STL 容器同步:由于 std::vector、std::map 等容器自身不具备并发安全性,对其进行并发操作时必须配合锁保护。
在具体使用互斥锁的工程实践中,有几个非常关键的原则需要遵循:首先是永远使用 std::lock_guard、std::unique_lock 或 std::scoped_lock 等 RAII 容器来管理锁的生命周期,以避免在异常抛出或分支跳转时遗漏 unlock 操作从而引发死锁;其次是让临界区的执行时间尽可能短,避免在临界区内进行 I/O 操作、调用可能阻塞的外部函数或获取其他锁,从而防止锁竞争迅速恶化;最后是合理控制锁的粒度,在设计复杂的细粒度锁时必须有明确的加锁顺序协议以防止死锁的发生。

单个状态位保留默认 seq_cst
如果某个共享的状态仅仅由一个单独的原子变量来承载,它不涉及与其他变量之间的一致性关系,也没有发布其他普通数据的职责,那么直接保留默认的 std::memory_order_seq_cst 往往是最好的选择,而不需要刻意地去进行降级优化。最典型的场景就是线程的退出或停止标志:
cpp
#include <atomic>
std::atomic<bool> stop_requested{false};
void RequestStop() {
stop_requested.store(true);
}
void WorkerLoop() {
while (!stop_requested.load()) {
DoOneUnitOfWork();
}
}
在这里,stop_requested 是一个完全孤立的布尔状态,主线程在某个时刻将其设置为 true,而工作线程在循环中读取它以决定是否安全退出,此过程中并不伴随其他关联数据的可见性需求。虽然默认的 seq_cst 会强制所有该类型的操作在全局范围内排定一个单一的执行顺序,对于这种单纯的标志位场景确实有些大材小用,但在工程开发中直接使用它仍然具有很高的合理性,这主要是基于以下几点考虑:
- 代码的语义和意图非常清晰:任何人在看到默认的 store 和 load 时,都不需要花费额外的精力去推理复杂的 release/acquire 配对关系,从而降低了代码的理解成本。
- 在大多数场景下,其性能差异完全可以忽略:除非该 load 操作在极高频的热点循环中被每秒调用数百万次,否则 seq_cst 带来的微小指令开销在实际业务中通常是无法被测量出来的。
- 保留了更强的安全性:如果未来业务发生变更,需要在设置该标志位之前写入一些其他共享数据,默认的 seq_cst 自带的 release/acquire 效果可以避免因为内存序强度不够而引入隐晦的时序 bug。
在什么情况下我们才应该考虑将默认的内存序降级呢?通常需要同时满足以下两个前置条件:
- 有明确的性能瓶颈数据支撑:通过 profiler 工具证明该原子操作确实处于核心的热点路径上,且降级之后确实能带来可测量的系统开销减少。
- 同步意图十分单一且明确:这个原子操作在业务中仅用于传递单一的数值或状态,完全不承担协调其他关联数据可见性的发布语义。
不过这里需要特别警惕一种反面情况:如果该标志位在修改的同时还隐式地承担了发布其他关联数据的职责,例如主线程必须先写入清理信息再设置停止标志,并要求工作线程在看到停止标志后能够读到最新的清理信息,那么此时绝不能将其降级为 relaxed,必须保证使用 release/acquire 级别的内存序来实现可见性传递。

旁路统计和唯一编号用 relaxed
在 C++ 内存序中,std::memory_order_relaxed 的适用场景是非常局限的,但对于诸如旁路统计或者唯一编号分配等场景,它却能提供非常高的执行效率:
cpp
#include <atomic>
#include <cstdint>
std::atomic<std::uint64_t> dropped_logs{0};
std::atomic<std::uint64_t> next_request_id{1};
void DropLog() {
dropped_logs.fetch_add(1, std::memory_order_relaxed);
}
std::uint64_t AllocateRequestId() {
return next_request_id.fetch_add(1, std::memory_order_relaxed);
}
如果我们仔细分析,就会发现这两个场景具有非常相似的并发特征:
- 原子变量本身就包含了全部的状态信息:其数值就是需要传递的唯一内容,不存在"当此变量就绪时,其他相关联的数据也必须同步可见"的逻辑语义。
- 业务上对实时可见性的容忍度较高:在性能统计中,监控线程在某一时刻读取到的统计数据允许存在微小的时滞;而在 ID 分配中,我们只要求分配出的编号具有全局唯一性,其分配的先后顺序并不影响后续的业务流程。
- 该变量不直接参与底层的流程控制:不会有其他线程在等待"当计数器达到某个特定值后才能执行下一步",只要不涉及这种跨线程的控制流同步,relaxed 就是安全的。
在《07-memory_order_relaxed能用在哪里》中我们详细拆解过 relaxed 的安全边界。在审查代码时,如果发现有开发者尝试读取一个被标记为 relaxed 的变量,并将其作为条件判断的依据去读取其他非原子变量,我们就应该立刻警觉,因为 relaxed 并不具备跨变量的 happens-before 传递语义,这几乎必然会导致读取到未同步的数据状态。
另一个经常在 code review 中遇到的隐患是,有开发者在未进行充分验证的情况下,将代码中所有看起来仅仅是计数功能的 fetch_add 操作都替换为 memory_order_relaxed,并且在 x86 平台上测试时由于该架构本身强内存模型的特性,隐藏了由于可见性缺失引入的时序缺陷。一旦这段代码被部署到 ARM 等弱内存模型的处理器架构上,那些在逻辑上其实扮演了发布角色的 relaxed 写入就会因为指令重排而引发数据竞争。因此,任何针对 relaxed 内存序的改动,都必须在弱内存模型物理设备或高并发压测环境下进行充分验证,如果在 CI 流程中缺乏这类测试节点,应当对此类改动保持审慎态度。

安全发布用 release/acquire
当我们需要在一个线程中准备好一组较为复杂的数据,并安全地传递给其他消费者线程时,使用 release/acquire 内存序对是最经典且高效的同步机制。比如我们在实现读多写少的配置快照更新、特性开关切换或路由表热加载时,通常都会采用这种模式:
cpp
#include <atomic>
#include <memory>
#include <string>
struct Config {
std::string endpoint;
int timeout_ms = 0;
int retry_count = 0;
};
std::atomic<std::shared_ptr<const Config>> current_config;
void PublishConfig(std::shared_ptr<const Config> new_config) {
current_config.store(std::move(new_config), std::memory_order_release);
}
std::shared_ptr<const Config> LoadConfig() {
return current_config.load(std::memory_order_acquire);
}
这种实现方案在实际工程中具有非常好的健壮性,主要体现在以下几个方面:
- 数据本身作为只读快照发布:通过对结构体使用 const 进行修饰,能够确保该配置信息在成功发布之后不会被任何消费者线程意外修改。若要更新配置,只需重新构造新对象并原子化地替换指针。
- 对象的生命周期由智能指针自动管理:由于使用了带有引用计数的智能指针,即使配置发生了热更新,正在使用旧配置的消费者线程依然能够安全地完成其读取逻辑,而不用担心内存过早释放。
- 同步的边界和逻辑极其清晰:仅仅依靠 release 写入和 acquire 读取的一对原子操作,就建立起了清晰的 happens-before 关系,使得代码的正确性非常易于推理和审计。
由于 std::atomic<std::shared_ptr> 是从 C20 开始才正式标准化的,在较早的 C17 或更低的项目中,我们通常需要使用 std::atomic_load 和 std::atomic_store 这类非成员重载函数来对智能指针进行原子操作,或者回退到使用互斥锁来保证其读写的线程安全性,而不应尝试通过裸指针强转等非标准手段自行设计无锁的智能指针替换逻辑,否则极易引入严重的未定义行为。
当然,release/acquire 也有其明确的适用边界。如果共享的对象需要在各线程间进行原地修改而非整体替换,或者存在多个生产者线程同时进行并发发布,仅靠 release/acquire 是无法保证数据一致性的。这类场景下,我们需要引入互斥锁或者利用 CAS 操作将发布动作串行化,在《08-release/acquire如何完成一次安全发布》中,我们也曾对多生产者的并发复杂性进行了详细的分析。

条件更新用 CAS,但别轻易手写复杂无锁结构
在状态机的状态转移或资源占用竞争中,只有当前状态为特定值时才允许进行修改,这正是比较并交换(CAS)操作最典型的应用场景:
cpp
#include <atomic>
enum class TaskState { Pending = 0, Running = 1, Done = 2, Cancelled = 3 };
std::atomic<TaskState> state{TaskState::Pending};
bool TryStartRunning() {
TaskState expected = TaskState::Pending;
return state.compare_exchange_strong(expected, TaskState::Running);
}
bool TryCancel() {
TaskState expected = TaskState::Pending;
return state.compare_exchange_strong(expected, TaskState::Cancelled);
}
在这种设计中,如果有两个线程分别尝试启动和取消任务,CAS 的原子性质能够保证最多只有一方可以迁移成功,而失败的一方能够感知到当前已经被修改后的真实状态,并安全地返回。这种基于 CAS 的状态机迁移模式广泛应用于线程池、连接管理以及状态生命周期控制等工程组件中。
需要注意的是,CAS 在实际工程中的难点并不在于单变量状态的原子迁移,而在于当我们试图使用它来实现复杂的无锁数据结构(如无锁队列或无锁哈希表)时,所必须面对的一系列底层并发难题:
- ABA 问题:在《10-CAS-compare-exchange和无锁编程入门》中我们分析过,由于 CAS 无法感知变量在中间过程中的反复变化,因而必须引入标签指针(Tagged Pointer)或危险指针(Hazard Pointer)等机制进行生命周期追踪。
- 对象的安全回收:当某个线程获取到某个节点指针并开始读取时,必须有机制保证在读取结束前该节点不会被其他并发删除的线程销毁,这通常需要引入复杂的垃圾回收或纪元同步(Epoch-based Reclamation)算法。
- CPU 空转与缓存线乒乓:在高竞争场景下,大量的 CAS 失败会导致线程在循环中反复尝试,这不仅会耗费大量的 CPU 资源,还会在不同核心间引发剧烈的缓存同步开销,进而严重拖慢系统整体的响应延迟。
对于绝大多数业务开发而言,针对这些复杂的无锁数据结构,最合理且安全的建议就是优先选用成熟且经过工业界广泛验证的开源并发库,例如 boost::lockfree、folly::MPMCQueue 或 Intel TBB 中的并发容器,而不是尝试自己手写。这些成熟库在设计上付出了巨大的开发和调试成本,仅在内存回收与 ABA 处理等方面的优化就极为繁琐,自己手写很难在正确性与稳定性上达到相同的工业水准。
在选用这些无锁库时,我们也需要仔细分析具体的业务负载特征,因为不同的库在实现上都做出了各自的权衡。例如有些队列在多生产者多消费者场景下拥有极高的吞吐量但不提供严格的 FIFO 顺序保证,而有些则需要预先分配固定容量。如果未能结合实际负载进行合理的基准测试,误用无锁容器甚至可能导致其整体吞吐量和延迟不如一个加了互斥锁的普通 std::queue。在实际开发中,只有当我们正在编写极其底层的操作系统内核、高性能数据库引擎,或者通过 profiler 明确验证出并发性能瓶颈由于现有库的特定局限性导致,且团队拥有足够的并发专家进行长期维护时,才应该考虑自行研发无锁数据结构,而这两种场景在普通的软件开发中是极为罕见的。

fence 和 false sharing 是底层审查层
在《11-fence和barrier是什么》中讨论的内存栅栏(Fence)以及在《12-CPU-cache-line和false-sharing》中分析的伪共享(False Sharing),在整个并发设计中应被归类为底层审查层。它们主要是作为性能调优的手段存在,而不是编写日常业务逻辑的常用工具。
对于内存栅栏(Fence),在编写日常并发代码时建议默认避免使用,直接采用显式的 store(release) 和 load(acquire) 能够提供更好的代码可读性与可维护性。只有当我们在实现非常底层的并发同步原语,且确有必要将内存屏障与实际的原子访问分离开来以换取极致的性能时,才考虑引入栅栏,并且必须配以极其详尽的注释以说明其配对关系和设计意图。如果在一般的业务 code review 中发现了栅栏操作,我们首先应该评估的是能否将其重构为更为清晰的原子可见性传递,而不是深陷在其繁琐的时序逻辑中。
而对于伪共享(False Sharing),我们在项目初期编写并发逻辑时同样不需要过早关注。当代码的功能正确性与逻辑结构稳定之后,如果通过性能分析工具观察到核心热点原子操作存在由于跨 CPU 核心缓存行失效导致的性能损耗,我们才应当通过重排字段布局或使用 C++17 中的 alignas(std::hardware_destructive_interference_size) 声明来消除冲突,且任何此类改动都必须伴随着严密的基准测试数据支持。
这两类底层机制的共同特点是它们更偏向于性能优化而非功能正确性,任何栅栏或内存对齐的修饰都无法纠正由于本身并发协议设计缺陷而导致的竞态和数据损坏。
一份能直接用的 review 清单
为了能够将上述各项设计和审查原则落实到具体的研发流程中,我们梳理出了一份在 code review 时可供参考的检查清单:
共享状态定义
- 这段改动中包含哪些跨线程访问的变量?它们分别在哪些线程中执行写入和读取?
- 是否存在某些普通(非原子)变量在无锁或无同步机制的保护下被多个线程并发访问?如果存在,这即为典型的 data race,必须立即修复。
- 如果存在多个彼此关联的共享变量,它们之间是否存在特定的逻辑不变量?如果存在,整个操作序列是否由同一个互斥锁或者原子事务整体保护?
原子变量的职责
- 每个被声明为 std::atomic 的变量具体承担什么同步职责?它是简单的计数、状态控制、还是用于传递数据的发布点?
- 该原子变量的写入操作是否起到了发布其他普通数据可见性的作用?如果是,其内存序必须至少提升至 release/acquire 级别。
- 被标记为 relaxed 的原子操作是否仅用于其自身数值即为全部逻辑信息的场景(如纯粹的监控计数器、唯一 ID 生成)?是否存在滥用 relaxed 进行时序控制的隐患?
配对和同步链
- 每一对用于可见性传递的 release 和 acquire 操作,是否正确作用于同一个原子变量实例上?
- 消费者线程在读取原子变量时,是否存在因为提前读取到默认初始值而导致同步链未能建立、从而发生读取异常的可能?
- 被发布的所有非原子关联数据,是否在执行 release store 之前就已经完全完成了写入操作?
CAS 循环
- 在比较并交换(CAS)失败的退回路径上,被原子操作自动更新的 expected 变量是否在下一轮循环中被正确重试?
- CAS 重试循环是否存在特定的终止或退出演算法,是否存在由于外部业务状态长期冲突而导致线程无限自旋死锁的可能?
- 在循环中使用比较并交换时,是否选用了允许伪失败的 compare_exchange_weak 以提升性能?而在单次条件分支判断中是否正确使用了 compare_exchange_strong?
生命周期和指针
- 当通过原子指针发布新的数据对象时,旧对象的释放生命周期是否由智能指针、危险指针或其他内存安全回收机制统一管理?
- 消费者在成功读取到原子指针后,是否能确保该对象在生命周期结束前不会被其他并发写入的线程意外销毁?
内存栅栏
- 如果代码中使用了显式的内存栅栏,其是否正确与另一端的原子变量配合建立了 happens-before 关系?其在写入操作之前或读取操作之后的位置是否准确?
性能与优化
- 对于可能存在频繁并发写入的高频原子变量,其内存布局是否经过了对齐优化,以避免与其他热点字段落在同一缓存行引起缓存行失效?
- 所有为了追求性能而降低内存序级别的优化提交,是否都具备在目标真实环境下的 benchmark 基准测试数据作为依据?
- 在高并发高竞争的环境下,CAS 的自旋自锁重试是否会由于冲突率过高而导致严重的 CPU 空转浪费?
架构与整体设计
- 这部分并发逻辑如果改为使用更为直观的互斥锁实现,其正确性与团队后续的可维护性是否会有明显的改善?
- 这段代码的并发同步协议和意图,能否让一个不熟悉该业务背景的工程师在不查看文档的情况下快速推导并看懂?
走完这 20 个核心问题,我们对绝大多数并发改动就能够建立起非常到位的工程直觉。虽然在简单的修改中我们可能只需要验证其中的前几项,但养成这种层次分明的评审思考习惯,能够极大减少线上并发故障的发生。
验证靠推理、工具和真实负载一起做
在实际的并发系统开发中,保障代码的正确性从来无法仅仅依靠单一的测试手段,而是需要将静态的代码推理、动态的自动化工具分析以及接近真实的负载压力测试这三者结合起来共同验证。
严密的代码推理是保证并发安全的基础。我们需要能够清晰地在代码中画出完整的 happens-before 关系链,并确保每一次 release store 都能在逻辑上找到对应的 acquire load,如果在纸笔推演中无法清晰地解释其同步逻辑,代码往往就已经存在缺陷。
而在自动化分析工具的辅助上,我们通常会借助 ThreadSanitizer(TSan)进行动态检测,它在捕捉基础的数据竞争(Data Race)方面非常敏锐,但对于高阶的逻辑同步错误(例如内存序配对错误)则很难发现,这要求我们不能仅仅依赖自动化工具的测试结果。
同时,针对性的压力测试能够有效增加并发竞争出现的概率,帮助我们在开发阶段暴露出那些在低负载环境下由于调度时序而被掩盖的同步缺陷。但压测在本质上只是通过增大并发密度来提高问题复现率,并不等同于从理论上证明了代码绝对正确。
除此之外,在目标物理平台上的硬件测试极为关键,因为 x86 平台本身强内存模型的硬件特性,容易屏蔽由于内存序降级或遗漏产生的可见性问题,而在 ARM 等弱内存模型处理器上运行则会立刻暴露时序缺陷,这要求我们必须将真实的硬件平台测试引入集成流程。在学术与极其严苛的工程开发中,我们也可以使用如 CDSChecker 或 Relacy 这类形式化验证工具来枚举小规模核心算法的所有可能的指令交错,但这通常只适合关键路径上的局部验证。
最后,所有的性能改动都必须依赖科学的 benchmark 数据支撑。任何为了追求性能而对内存序进行的降级,如果没有在特定硬件平台上的多样本对比测试,其行为都无异于在给系统埋下难以排查的并发隐患。
工具选择的一张决策图
为了方便我们在工程现场快速查阅与梳理思路,我们可以将上述的分析决策流程整理成一张简明的决策图:
cpp
开始:需要在多线程之间协调一段状态
├─ 这段状态包含多个变量、且变量之间有不变量?
│ └─ 是 ─→ 使用 std::mutex 保护整段临界区
│
├─ 状态只是一个独立的布尔/整数标志位,无附带数据?
│ └─ 是 ─→ 使用 std::atomic<T>,保留默认 seq_cst
│
├─ 状态是一个独立计数器/单调 ID,无发布语义?
│ └─ 是 ─→ 使用 std::atomic<T>,配合 fetch_add(relaxed)
│
├─ 一个线程发布一组只读数据给其他线程消费?
│ └─ 是 ─→ 使用 std::atomic<std::shared_ptr<const T>>,配合 release/acquire
│
├─ 状态机的条件迁移、多个线程竞争同一个槽位?
│ └─ 是 ─→ 使用 compare_exchange 并在循环中执行
│
└─ 上述都不满足,但你确定需要无锁数据结构?
└─ 优先选用成熟的并发库(如 folly、TBB、boost::lockfree),不要自己手写
这张图能够覆盖我们日常开发中 90% 以上的工程场景。而对于剩下那 10% 的边缘情况(例如基于 RCU、危险指针的超低延迟实现或高度定制化的无锁结构),它们通常属于系统底层基础设施的范畴,需要并发专家团队进行长期的攻关与维护,并不建议在常规的业务开发中随意推广。

写在系列最后
从最开始《01-多线程读写同一个变量为什么会出错》中分析的 counter++ 数据竞争,到这一章最终整理出的工程决策检查清单,我们整个教程系列所围绕探讨的核心目的其实非常明确,那就是如何让并发程序既能保证功能正确,又易于长期维护。
为了能够系统地拆解这个物理和逻辑交织的复杂主题,我们深入探究了多线程同步的基石,排除了 volatile 的常见误区,剖析了内存模型关于可见性与重排的规则,并逐一分析了从 relaxed、release/acquire 到默认 seq_cst 的同步强度边界,同时也探讨了 CAS、内存栅栏以及缓存伪共享等性能调优机制。每一种并发工具都有其最合适的使用场景,但也都有其清晰的局限性。
在真实的工程开发中,一位经验丰富的工程师,其核心优势并不在于背诵了多少底层的内存序语义,而在于能够清晰地判断出在何时应该停止优化------比如评估出某段逻辑使用互斥锁已经完全足够,或者发布动作使用 release/acquire 已经能完美解决,而不再去为了极微小的性能收益而承担引入难以维护的代码和隐蔽 bug 的风险。
并发设计的首要原则是代码的可维护性。一段虽然具有极高吞吐量却偶尔会发生死锁或内存损坏的无锁队列,在工业生产中的实际价值远比不上一段运行平稳且通俗易懂的加锁队列。因此,性能的优化应当基于真实的系统监控,而代码的正确性必须通过完备的研发规范与测试体系来托底。
希望本教程的内容能够帮助大家在未来的开发和代码评审中,建立起更加科学、系统的并发判断逻辑,而不是凭借直觉或猜测进行盲目的内存序选择。通过扎实地掌握互斥锁、原子操作与内存布局等底层同步机制的安全边界,我们才能够在并发编程的道路上走得更加稳健与长远。
码字不易,欢迎大家点赞,关注,评论,谢谢!