自己写并发代码也有些年头了,从最开始用线程池做任务分发,到后来啃各种锁和无锁队列,踩过的坑能装满一卡车。线程间共享数据永远是绕不过去的一道坎,哪怕你用现代C++、用Go、用Java,只要涉及多线程,就一定会撞上“数据被多个线程同时碰”这件事。很多人觉得加个锁就完事了,但真等项目跑出诡异数据、偶尔死锁、性能忽高忽低的时候,才明白共享数据这个题目远没有想象中简单。
这篇内容整理自一本经典并发编程书籍第三章的学习归纳,加上我自己大量实操中的验证和补充。帖子会从最核心的竞争条件讲起,把互斥量、锁的层级、条件变量、原子操作、内存序、基于设计的锁无关方案全部过一遍,最后再集中聊聊死锁排查和真实项目里的避坑经验。不管你是刚开始接触并发,还是已经写过不少多线程代码但总觉得没吃透,这篇内容都值得你花上十几分钟认真读一读。
1. 为什么共享数据这么容易出问题:从一次“不可能出错”的计数说起
先看一个最经典的场景:10个线程同时对同一个整数做100万次自增操作,按正常逻辑,最后的结果应该是1000万。但实际跑出来,结果经常是800多万、900多万,甚至更离谱。很多人第一次遇到这个问题时都会觉得是编译器或者CPU出了Bug,实际上问题出在自增操作本身。
自增操作count++在底层并不是一条指令。它被拆成了三步:把count的值读入寄存器、寄存器加一、把新值写回内存。两个线程同时执行这三步时,完全可能出现交错执行:线程A读到了100,还没写回去,线程B也读到了100,两个线程各自加一后都写回101,结果本来应该变成102,最终却只加了1。这就是典型的数据竞争,也就是多个线程无任何同步机制地访问同一块内存,至少有一个线程在做写操作。
更让人头疼的是,C++标准里对数据竞争的定义是非常严格的:只要存在数据竞争,整个程序的行为就是未定义的。也就是说,不仅结果可能是错的,编译器优化后可能让错误以更诡异的方式暴露出来,程序崩溃、死循环、数据被撕裂都不奇怪。不要拿“我加了volatile”来对抗这个问题,volatile在C++里只告诉编译器这个变量可能被外部修改,每次都要从内存中读取,但它并不能保证读-改-写这个序列是原子的,数据竞争照样存在,未定义行为照样发生。
竞态条件则是比数据竞争更宽泛的概念。数据竞争是底层的内存访问问题,而竞态条件描述的是“结果依赖于多个线程的时序安排”这一现象。只有当某个结果取决于哪个线程先到达某段代码时,竞态条件才可能触发问题。在并发编程里,我们说的“把数据放在锁的保护下”,核心目的就是消除这种时序上的不确定性。
这里给出一个简单到不能再简单的复现程序:
#include <atomic> #include <chrono> #include <iostream> #include <thread> #include <vector> int main() { int count = 0; std::vector<std::thread> threads; for (int i = 0; i < 10; ++i) { threads.emplace_back([&count]() { for (int j = 0; j < 1000000; ++j) { ++count; } }); } for (auto& t : threads) t.join(); std::cout << count << std::endl; return 0; }我用GCC -O2在x86-64上跑过很多次,结果从来没得到过1000万。有时候是990多万,有时候是800多万,看起来非常随机。这个随机性能帮我们理解一个关键点:竞态条件属于时序类Bug,低概率出现的错误比稳定复现的错误更危险,因为它几乎没法靠跑几次测试来发现。
注意:写并发代码时,第一原则是“默认共享数据是不安全的”。任何不通过同步机制保护的共享读写,都不要心存侥幸。
2. 保护共享数据的第一道防线:互斥量与锁
互斥量(mutex)名字听起来很高端,本质就是一个厕所门锁。线程进入临界区之前先lock(),试图霸占这片区域;如果已经被别人占着,就老老实实等着;用完以后unlock(),把门打开。这样带来的效果就是:同一时间只允许一个线程执行这段代码,读写不再交错,数据竞争也就被消除了。
2.1 std::mutex的基本用法与lock_guard
C++11里提供了std::mutex,最简单的用法是手动lock/unlock。但手动加锁有一个巨大的安全隐患:如果临界区里抛出异常,unlock()这行代码就不会被执行,锁永远无法释放,其他线程全部卡死。实战中代码越写越复杂,临界区里放一个可能抛异常的函数太常见了。
所以一个人写生产级代码的老手会告诉你:永远不要直接调用lock()和unlock()。正确做法是使用RAII封装,让锁的作用域结束时就自动释放,这就是std::lock_guard存在的意义。
#include <mutex> #include <vector> std::mutex mtx; int shared_count = 0; void safe_increment() { std::lock_guard<std::mutex> lock(mtx); ++shared_count; }lock_guard在构造时调用mtx.lock(),在析构时调用mtx.unlock(),RAII机制保证了无论临界区里发生什么,函数退出时锁一定被释放。这一点怎么强调都不过分,我见过太多线上事故是new抛异常或者条件分支提前return导致死锁,而lock_guard可以从根源上预防这类问题。
2.2 何时用unique_lock以及它的灵活之处
std::unique_lock和lock_guard类似,但它更灵活。它以独占锁的所有权为代价,允许你延迟加锁、手动解锁、尝试加锁,还可以和条件变量配合使用。比如unique_lock可以被移动,可以判断自己是否拥有锁,这些能力在复杂逻辑里非常有用。
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 做一些不涉及共享数据的准备工作 lock.lock(); // 临界区 lock.unlock(); // 临界区之外做其他事如果你的临界区只需要锁保护,没有任何特殊需求,就用lock_guard。如果后面要用到条件变量、需要提前解锁、或者要转移锁的所有权,就用unique_lock。别为了“灵活”而让代码失去简单性,能用lock_guard就不要用unique_lock。
2.3 递归锁:看起来方便,实则是设计缺陷的遮羞布
std::recursive_mutex允许同一个线程在已经持有锁的情况下再次加锁,这样在递归函数里就不用担心自己锁死自己。但我要泼一盆冷水:递归锁几乎总是说明你的锁粒度设计出了问题。
假设你在两个函数A和B里分别用同一个递归锁保护了不同的共享数据,然后A内部调用了B(或B调用了A),这个执行流立刻变得晦涩。更致命的是,锁的“保护范围”跨度大,很难判断什么时候数据真正是安全的。书上归纳的一个核心观点我很认同:如果锁的粒度跨越了整个调用链,你就等于在用一个大锁保护所有东西,这既容易引发死锁,性能也很差。
实际工程中我处理过的递归锁需求,90%都能通过重构消除。比如把需要锁保护的公共部分提取为不依赖外部锁的私有方法,或者调整锁的粒度,让每个函数只锁定自己真正访问的那一小部分数据。剩下的10%,用递归锁确实省事,但务必写清楚注释,说明为什么这里的递归调用是必须的。
2.4 锁的选择与性能观测
标准库提供了好几种互斥量,它们的取舍往往比写代码本身更值得关注。简单的对比可以看下面的表:
| 互斥量类型 | 特点 | 适用场景 |
|---|---|---|
| std::mutex | 最基础,不支持递归 | 绝大多数临界区 |
| std::recursive_mutex | 同线程可重入 | 特殊递归场景,慎用 |
| std::timed_mutex | 支持try_lock_for/try_lock_until | 不允许无限等待的场景 |
| std::shared_mutex | 读写锁 | 大量读、少量写 |
关于shared_mutex我想多说两句。它允许“多个读者同时持有读锁,只有一个写者能持有写锁”,这对读多写少的场景优化非常明显。比如一个配置管理器,几百个线程同时读取配置,写入频率极低,把mutex换成shared_mutex,能显著降低读线程之间的争用。但代价是维护读写锁状态本身有系统开销,如果你的临界区里干活时间极短,这个开销反而可能比mutex还大。这里没有银弹,要在真实场景里用性能工具实测后做决策。
3. 不只是互斥:同步与协作的条件变量
互斥量解决的是“同一时间只有一个人进入厕所”的问题,但现实中还有另一个典型场景:某个线程要等一个条件满足后才继续干活,比如生产者线程要等队列有空位才能放入新任务,消费者线程要等队列有数据才能取走任务。轮询当然能实现,但会白白消耗CPU,而且检查条件也要使用锁,非常难看。条件变量就是解决这类“等待-通知”问题的标准工具。
3.1 wait/notify的经典配合
条件变量的核心用法有两个:等待方调用wait(),通知方调用notify_one()或notify_all()。C++里的std::condition_variable必须配合std::unique_lock使用,因为wait内部需要原子性地“释放锁 + 进入等待状态”,之后再在唤醒时重新拿回锁。
一个典型的生产者-消费者模型长这样:
#include <condition_variable> #include <deque> #include <mutex> std::mutex cv_mtx; std::condition_variable cv; std::deque<int> queue; const int MAX_QUEUE = 10; void producer() { for (int i = 0;; ++i) { std::unique_lock<std::mutex> lock(cv_mtx); cv.wait(lock, [] { return queue.size() < MAX_QUEUE; }); queue.push_back(i); cv.notify_one(); } } void consumer() { while (true) { std::unique_lock<std::mutex> lock(cv_mtx); cv.wait(lock, [] { return !queue.empty(); }); int value = queue.front(); queue.pop_front(); cv.notify_one(); } }注意这里wait的第二个参数是谓词(predicate),它等价于下面这段逻辑:
while (!predicate()) { cv.wait(lock); }也就是说,wait会先检查谓词是否满足;如果不满足,就原子性地释放锁并挂起;被唤醒后,先重新获取锁,再检查谓词。多这一层检查非常关键,我自己写代码时从来不会省略这个谓词,原因我放在下一节详细说。
3.2 虚假唤醒与唤醒丢失:wait前必须检查条件的本质原因
虚假唤醒(spurious wakeup)是操作系统底层实现注定的——线程可能在没有收到notify的情况下被唤醒,无论你是Linux还是Windows都会遇到。原因是某些平台上的信号、中断处理或者线程调度的内部机制会意外唤醒等待中的线程。
如果你直接写cv.wait(lock),不检查条件,那么虚假唤醒时会走出wait继续往下执行,可能队列还是空的,你却直接取front(),结果就是空指针或未定义行为。
唤醒丢失则和另一个场景有关:如果notify发生在wait进入等待状态之前,这个notify就没人接收,线程可能会永远睡下去。带谓词的wait能有效避免这个问题,因为wait之后会检查谓词,如果真值真的满足了,即使notify已经“丢失”,线程也会立刻返回、不会挂起。
这两个问题乍听像是理论,但在真实项目中我都遇到过。有一个消息转发模块在压测时偶尔出现线程“卡死”但进程不崩溃的情况,查了很久才发现是某个地方在wait时省略了谓词,碰到了虚假唤醒;而另一种“偶发延迟变长”的问题,则是因为跨线程的notify时机不巧,唤醒通知丢失了。从那以后,我给自己立了一个规矩:条件变量wait永远带谓词,没有例外。
3.3 notify_one还是notify_all:性能与正确性的平衡
notify_one会唤醒一个等待线程,notify_all会唤醒所有等待线程。在只有单个消费者等待的场景下,notify_one效率最高;但如果可能有多个消费者等待同一个条件,而条件的变化一次能满足多个消费者的需求,那就必须用notify_all,否则可能出现“明明条件满足了,但只有一个人被通知,其他人还在睡”的尴尬。
一个经典误区是:生产者在放入一个元素后总是用notify_all。这虽然能保证唤醒所有等待者,但如果等待者是100个消费者,其中99个醒过来发现队列还是空的(因为它们都抢同一个元素),就又回去睡了,白白引发大量上下文切换和锁竞争。所以正确做法是:只有当你确信一个通知足以覆盖所有被唤醒线程的需求时,才用notify_one;否则用notify_all。
这里也补充一个工程细节:通知不需要一定在锁内进行。你完全可以先释放锁,再调用notify_one()或notify_all()。这能稍微减轻拿到锁但还没wait的线程被不必要的唤醒拖住的概率,因为等待线程一旦被唤醒要先抢锁,而锁还没释放,它就只能继续阻塞等待。我在高并发队列里实际测量过,先解锁再通知比锁内通知在某些场景下能降低5%~10%的锁持有时间,效果虽不夸张,但胜在改动成本极低。
4. 不阻塞的同步:原子操作与内存序
锁能解决所有共享数据问题,但锁不是免费的。每进入一次临界区,都要付出系统调用或至少是一次用户态原子操作的开销,更关键的是锁可能让线程休眠和唤醒,这些调度延迟在高频操作下会被放大。对于某些极其简单的共享数据——比如一个计数器、一个状态标志位、一个指针——我们可以用无锁的方式,也就是原子操作来同步。
4.1 原子类型与读-改-写操作的真正含义
C++11引入了std::atomic<T>,它保证对目标变量的操作是不可分割的。也就是说,std::atomic<int>的load()、store()、fetch_add()、compare_exchange_weak()这些操作都是原子的,不会被其他线程隔断。
最典型的例子就是把上一节的count++改成std::atomic<int>的fetch_add,自增操作就变成了原子操作,不会出现数据竞争。但这里有一个大坑:你仍然需要结合内存序来保证操作的可见性。
下面这份代码虽然用了原子变量,但如果使用错误的内存序,结果依然不符合直觉:
std::atomic<int> count{0}; void increment() { count.fetch_add(1, std::memory_order_relaxed); }在x86上,memory_order_relaxed对单变量的原子性没有影响,fetch_add结果依然是原子的。但它不提供任何跨变量的顺序保证,其他线程看到的可能不是最新的值,或者看到不同线程写入的顺序不一致。
4.2 内存序:能让代码正确、也能让代码疯狂的六个模型
C++定义了六种内存序,可以分成三类:
memory_order_relaxed:只保证原子性和修改顺序一致,不提供跨线程的顺序约束。memory_order_consume/memory_order_acquire/memory_order_release:用于建立“释放-获取”同步关系。memory_order_acq_rel:结合了acquire和release,用于读-改-写操作。memory_order_seq_cst:最强约束,也是默认值,保证所有线程观察到同一个全局顺序。
对大多数开发者来说,我的建议是在没有Profiler指导的前提下,一律使用默认的seq_cst。它最安全,语义也最直观。只有当你真的测定出原子操作成了性能瓶颈,且能清楚论证更弱内存序的正确性,才去换更弱的内存序。
这里给一个最简单的无锁计数器示例:
class AtomicCounter { public: int increment() { return count_.fetch_add(1, std::memory_order_relaxed); } int load() const { return count_.load(std::memory_order_seq_cst); } private: std::atomic<int> count_{0}; };fetch_add用relaxed是成立的,因为单个计数器之间并没有与其他变量的依赖关系;但load用来读取计数器当前值用于对外展示时,用seq_cst能保证看到某一时刻切实存在过的值,不至于在逻辑上引入额外的不一致。
4.3 自旋锁:原子标志的最简单应用
原子操作最经典的应用之一是自旋锁。它不需要系统调用,而是通过忙等的方式反复尝试获取锁。这对于临界区极短、竞争不激烈的场景非常高效。
class SpinLock { public: void lock() { while (flag_.test_and_set(std::memory_order_acquire)) { // 自旋等待 } } void unlock() { flag_.clear(std::memory_order_release); } private: std::atomic_flag flag_ = ATOMIC_FLAG_INIT; };但自旋锁是“吃CPU的怪物”。一旦锁被持有时线程太多,大量线程都在自旋,CPU会被烧到100%。所以在实际项目中,自旋锁只适合临界区极短、线程数量可控、且都在足够多的处理器核心上运行的情况。我在网络库的事件循环线程里用过自旋锁来保护任务队列的头尾指针,效果不错;但如果换成几百个业务线程抢同一把锁,性能立刻崩溃。
4.4 无锁数据结构:看起来美,写起来要命
无锁数据结构是指不使用锁、仅靠原子操作来保证并发安全的数据结构,比如无锁队列、无锁栈。这类结构听起来很吸引人,但真正实现起来极其复杂,需要考虑ABA问题、存取顺序、内存管理等一系列细节,而且还要保证正确性——这是用再多的测试都很难验证的。
我个人的态度一直很明确:除非你是专门的并发库开发者,否则不要从零写一个无锁容器。优先使用经过大规模验证的第三方库,比如Boost.Lockfree、Intel TBB等。就算自己写,也只在核心路径上使用,并且要有极其充分的理由。理由不够,就回去用锁;性能不够,就用更好的锁方案,而不是轻易上无锁。
一个很有价值的经验:无锁代码的正确性远比你想象的难,而它的性能优势也没有你想象的那么大。现代锁的实现已经优化得很好了,很多场景下锁与无锁的性能差距不到20%,而无锁写错的风险却是灾难级别的。
5. 降低共享的架构思维:能不共享,就别共享
锁、条件变量、原子操作,这些东西都是用来保护共享数据的。但很多时候,更聪明的方式是从设计上减少共享数据本身。这个思路是书籍第三章里一个容易被忽略却极有价值的观点:如果你压根不共享,就可以完全不考虑同步难题。
5.1 thread_local:每个线程拥有一份独立副本
thread_local关键字可以声明一个线程局部存储(TLS)变量。每个线程访问它时,得到的是自己的独立副本,彼此之间完全隔离,不存在竞争。
一个很典型的使用场景是线程池里的任务本地缓存。比如每个工作线程在处理一批任务时都需要一个临时缓冲区,如果共用一个全局缓冲区,所有线程都得加锁;但如果每个线程一个缓冲区,就让“每次处理任务时先清空缓冲区再使用”这个流程变得完全线程安全。
thread_local std::vector<int> t_cache; void process_task(const std::vector<int>& data) { t_cache.clear(); // 使用 t_cache 存储中间结果 for (int x : data) { t_cache.push_back(x * 2); } }但使用thread_local也要注意两点:一是每个线程都会持有自己那份副本,线程多了会占额外内存;二是thread_local变量的生命周期是整个线程生命周期,里面如果装了很大的容器,线程空闲时也不释放,内存压力会变大。因此用完最好释放掉或缩小容量。
5.2 只读共享:最便宜的“同步”
如果一块数据在创建之后就不再修改,那它天然是线程安全的。任何线程都可以随意读它,不需要加锁、不需要原子操作。这个思想常用于配置表、字典数据、预计算结果的共享。
实践中的做法是:在启动阶段构造这些数据,发布到工作线程之前确保数据已经完全构造好,并通过一次acquire操作(比如启动线程时的join,或者是把指向数据的指针放入一个原子指针)建立一种同步关系,之后所有线程就可以放心大胆地只读访问。
5.3 消息传递:用“状态拷贝”代替“状态共享”
有时候,把数据从一个线程传给另一个线程时,我们并不仅仅是想让两个线程操作同一个对象,而是传递一个“快照”。这时与其共享对象,不如复制一份再传递。
最常见的实现是使用无锁或有锁的并发队列,把任务对象直接“移动”或“拷贝”过去。这样每个数据同一时刻只被一个线程拥有,天然不需要锁。Actor模型、通道(Channel)、消息队列,本质都是这个思路。比起共享状态的锁方案,消息传递的调试和推理难度要低很多,因为它把交互行为线性化了。
我在很多项目里会优先设计成“单生产者-单消费者”的消息通道,即使底层仍是队列,但因为只有两个线程接触同一个数据结构,锁竞争几乎为零,加上条件变量,性能比多生产者多消费者上大锁高一个量级。
5.4 数据共享粒度与容器的选择
如果你真的需要多个线程共享一个容器,锁的粒度是决定性能和正确性的关键。常用的几个方向:
- 使用
std::shared_mutex保护整个容器,适合读多写少。 - 把大容器拆分成多个“分段”,每个段一把锁,降低竞争。
- 使用专门设计的并发容器(如TBB的concurrent_queue、并发哈希表),它们内部通常已经做了分段锁或无锁实现。
工程上,拆分容器是我非常推荐的折中方案。比如一个全局哈希表,按照key的hash值把桶分成多个子表,每个子表一把锁,这样不同key的访问天然不冲突。这个方案的实现成本不高,但收益非常明显。
6. 死锁、安全性与可靠性:总是令人头疼的工程问题
前面介绍的方案能把数据竞争解决大半,但并发编程另一个大坑——死锁——随时可能把整个进程拖入深渊。死锁的定义是:多个线程各自持有一把锁,同时又在等待对方持有的锁,结果是互相等待,谁也没法继续推进。
6.1 死锁的四个必要条件与一个最简示例
死锁要发生,必须同时满足四个条件:互斥(资源一次只能被一个线程占有)、持有并等待(线程持有资源时又在等待其他资源)、不可剥夺(资源只能由持有人主动释放)、循环等待(多个线程的等待关系形成环)。
最经典的两个锁死锁示例:
std::mutex m1, m2; void thread_a() { std::lock_guard<std::mutex> lock1(m1); std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 增加交错概率 std::lock_guard<std::mutex> lock2(m2); // ... } void thread_b() { std::lock_guard<std::mutex> lock1(m2); std::this_thread::sleep_for(std::chrono::milliseconds(10)); std::lock_guard<std::mutex> lock2(m1); // ... }线程A持有m1等待m2,线程B持有m2等待m1,死锁就发生了。很多死锁bug的可怕之处在于:如果两个线程到达的时间差很小,可能根本不会触发死锁;但只要交错时机碰上了,整个系统就冻结。而且冻结后你还很难定位,因为进程仍然活着,线程却全部卡在等待中。
6.2 解法一:用std::lock一次锁住多个锁
std::lock可以一次锁定多个互斥量,并以一种避免死锁的内部算法来管理锁的顺序。它的一个核心保证是:要么全部锁成功,要么一个都不锁(失败时已获取的锁会被释放),从机制上消除了“部分获取导致循环等待”的死锁条件。
void safe_lock_two_mutex() { std::lock(m1, m2); std::lock_guard<std::mutex> lock1(m1, std::adopt_lock); std::lock_guard<std::mutex> lock2(m2, std::adopt_lock); // 此时安全持有 m1 和 m2 }注意使用std::adopt_lock告诉lock_guard“锁已经获取了,你只管在析构时释放”,避免重复加锁。
6.3 解法二:固定加锁顺序
如果在代码中有多个地方都需要同时加多个锁,最简单有效的规则就是:所有线程都按照相同的顺序加锁。比如所有逻辑都“先锁A,再锁B”,线程A先A后B,线程B也先A后B,循环等待就被打破了。
这个规则看似简单,但在大型代码里执行难度其实很高,因为调用层次深、锁分散在不同的类里,很容易出现“方法X持有锁A时调用了方法Y,Y内部想锁B”这类隐式顺序问题。我建议在代码Review时尤其注意锁的嵌套情况,最好在类注释里写清楚本类锁的使用顺序。
6.4 其他死锁风险:同一个线程“重复加锁”和锁的泄漏
还有一种非常隐蔽的死锁:同一个线程连续两次对同一个非递归的std::mutex加锁。第一次lock成功,第二次lock就会阻塞,但这个互斥量不会因为你“就是同一个线程”就放行,于是白白死锁。这种最常见的原因是加锁和调用其他函数之间没有理清调用层次,自锁而不自知。
排查这类问题时,打开编译器的ThreadSanitizer或者运行时库的死锁检测工具会给力很多。另外,在调试期可以给锁加上“持锁线程信息”日志,打印每次加锁和释放的调用栈,这样复现问题后基本一眼就能看出锁的持有链。
6.5 数据竞争排查实战:别再靠打印日志了
死锁相对好排查,因为卡住的位置容易用gdb抓到栈。数据竞争则麻烦得多,因为错误不一定稳定复现。打印日志方式属于“看运气”,而且日志本身还会改变时序,有时加了日志问题反而不出现了,简直折磨人。
我的推荐工具链有这么几样:
- ThreadSanitizer(TSan):GCC和Clang都支持,编译时加
-fsanitize=thread,运行时会报告数据竞争的准确位置、参与线程的栈。这是面对数据竞争时的第一选择。 - Helgrind(Valgrind套件):能检测锁顺序问题和部分数据竞争,但性能开销很大,适用于中小规模测试。
- 静态分析工具:Clang-Tidy、PVS-Studio等能找出一些显而易见的并发问题,但作为辅助即可,不能替代动态检测。
我自己的经验是:遇到可疑数据竞争,第一步不是看日志,而是先用TSan跑一遍。两三分钟之内往往直接告诉你哪个文件哪一行访问了共享变量没有加锁,比人肉肉眼查找高效太多。
7. 用锁的智慧和工程上的最后几条建议
把多线程交织在一起的文章写到这里,核心知识点基本都说全了。但真正干过项目的人都知道,纸上得来终觉浅。最后我再补几条实战经验,算是对整篇内容的一个自然收尾。
第一条:锁的粒度要尽量小,但也不能太小。临界区太长会严重拖慢其他线程,临界区太短又可能因为频繁加解锁反而增加开销。一个经验准则是:临界区里不要做I/O、不要打日志、不要调用不熟悉的外部回调函数,尽量只做和共享数据强相关的内存操作。必要时要拆锁,把耗时部分移出临界区。
第二条:给共享数据一个明确的“看守者”。在代码结构上建议把共享数据封装成一个独立的类,把锁声明成私有成员,所有对共享数据的访问都通过成员函数完成。这样能保证不会有一处代码“悄悄绕过锁”直接操作裸变量。我见过太多事故是因为某人图方便,在某个角落直接改了共享容器,没有任何锁保护。
第三条:在高并发场景下,优先用无锁队列解耦线程。当一个模块的处理速度跟不上上游的数据产出时,直接共享状态加锁常常会造成大量线程阻塞。反过来,如果在上游和下游之间插入一个有界并发队列,写线程只负责入队,读线程只负责出队,两侧的耦合和锁竞争都会大大降低。队列积压还能天然形成背压(backpressure),防止下游被冲垮。这个模式是我在多个服务端项目里验证过最实用的架构手段之一。
第四条:时刻记住“安全性优先,性能在后”。并发代码一旦有数据竞争,你优先要做的不是优化性能,而是消除竞争。先把正确的锁加上,用TSan跑干净,再考虑能不能缩小临界区、能不能换无锁方案。性能优化必须基于Profiler数据,不能靠猜。
在实际项目里,共享数据的每一次设计选择,本质都是在正确性、性能和可维护性之间做权衡。多线程没有银弹,但如果你能把互斥锁、条件变量、原子操作、内存序、减少共享这些手段理解透彻,并且知道每种方案适合什么场景,那大多数并发问题在你面前都会显得清清楚楚。希望这篇归纳总结能让你少走一些我曾经走过的弯路。