写并发代码的时候,你有没有遇到过这种场景:本地测试一切正常,一旦上了压测,数据就开始随机错乱,偶发卡死,偶发崩溃。我最早系统性接触 Synchronization 这个概念,就是在排查一个诡异的线上日志乱序问题——两个线程同时往同一个消息队列里写数据,写进去的内容经常前后颠倒。当时我第一反应是"队列实现有问题",后来才发现,问题根本不在队列,而在于我根本没用任何同步机制。
这篇内容想分享的,就是 Synchronization 里最实用、最常被拿来做例子的那部分:同步原语怎么选、经典同步问题怎么写、以及实际工程里最容易踩的坑。适合正在学操作系统并发章节的初学者,也适合后端开发者想系统捋一遍同步方案的思路。我会把代码、场景、踩坑经历都摆出来,尽量做到可以直接照着改、照着用。
1. 并发问题的本质:不只有"加锁"一种解法
很多人一提到同步,第一反应就是加锁。锁确实重要,但如果只盯着锁,你会发现很多并发问题依然莫名其妙。搞清楚并发问题真正的成因,才知道什么时候该用锁、什么时候锁也救不了你。
1.1 竞态条件是怎么产生的
竞态条件(Race Condition)是所有同步问题的根源。它的定义说起来很绕,但举个例子就特别直观。
假设你和你同事共同维护一个Excel表格,里面记录着项目剩余任务的数字。你看到数字是10,准备改成9;与此同时同事也看到数字是10,准备改成8。结果取决于谁后写,最终数字可能是9也可能是8,但绝对不是一次操作该有的结果。两个线程共享一个变量,各自读取、修改、写回,这三个步骤交错执行,最终结果依赖执行顺序,这就是竞态条件。
在代码里,典型的竞态条件长这样:
cpp复制// 两个线程同时执行这段逻辑
int current = shared_counter; // 读
current = current + 1; // 修改
shared_counter = current; // 写回
如果线程A读了shared_counter等于1,还没写回,线程B也读了shared_counter还是1,两个线程都把值改成2然后写回。本应该加两次变3,结果还是2。这就是最简单也最常见的同步问题。
1.2 同步的三个层次:原子性、可见性、有序性
我见过不少开发者,给变量加了个锁还是出问题,就是因为没有理解同步其实涉及三个层次。
第一是原子性。一个操作或者多个操作必须捆绑在一起执行,不能被打断。"读-改-写"这个复合操作就不是原子的,所以需要锁或者原子变量来保证。
第二是可见性。一个线程修改了共享变量的值,另一个线程不一定能立刻看到。这不只是理论问题,在多核CPU下,各核心有自己的缓存,线程A把变量修改后存在自己的缓存里,线程B读到的可能还是内存里的旧值。很多所谓"加锁了还是有问题"的情况,其实是忘了锁不仅仅用于写,读也必须要同步。
第三是有序性。编译器和CPU为了优化,可能调整指令执行顺序。单线程内看不出来,多线程下就可能导致意外的行为。经典的例子是双线程检查标志位退出:
cpp复制// 线程1
ready = true;
// 线程2
while (!ready) { /* 等待 */ }
如果编译器和CPU对ready的写入进行了重排,线程2可能永远等不到。所以语言层面会提供内存屏障机制,比如C++的atomic、Java的volatile,它们的关键作用之一就是防止重排。
理解这三个层次之后,再去看同步原语,你会发现每种原语其实都是针对特定层次和场景设计的。接下来我逐个拆解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 同步原语逐个拆解:选型之前先弄清楚这些区别
在动手写示例之前,先把常用同步原语的特点梳理清楚。选错原语比不选还难受,比如读多写少的地方用了互斥锁,性能和用读写锁差出一大截;该用条件变量的地方你用自旋锁空转,CPU直接被打满。
2.1 互斥锁:最简单的同步,但不总是最合适
互斥锁(Mutex)是同步的"万金油"。它保证同一时刻只有一个线程进入临界区。C++11之后的写法如下:
cpp复制#include <mutex>
std::mutex mtx;
int shared_counter = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx);
shared_counter++;
}
我几乎从不用裸的mtx.lock()和mtx.unlock(),原因很简单:如果临界区内发生异常,unlock不会执行,锁永远释放不了,其他线程全部卡死。std::lock_guard和std::unique_lock通过RAII机制保证出了作用域一定解锁,这是用生命换来的可靠性。
互斥锁的问题也很明显:它不区分读和写。多个线程同时读取一个只读数据,其实完全安全,但互斥锁会强制它们串行,白白浪费并发能力。
2.2 读写锁:读多写少的场景帮你把并发榨干
读写锁(SharedMutex)允许同时多个线程持有"读锁",但写锁是独占的。写锁没被持有时,读锁可以被多个线程共享;写锁持有期间,谁都不许进。
C++17提供了std::shared_mutex,配合std::shared_lock和std::unique_lock使用:
cpp复制#include <shared_mutex>
class ThreadSafeCache {
public:
std::string get(const std::string& key) {
std::shared_lock<std::shared_mutex> lock(mtx_);
return data_[key];
}
void set(const std::string& key, const std::string& value) {
std::unique_lock<std::shared_mutex> lock(mtx_);
data_[key] = value;
}
private:
mutable std::shared_mutex mtx_;
std::map<std::string, std::string> data_;
};
这个类里,get操作可以并发执行,只有set才需要独占锁。如果你用互斥锁实现同样的缓存,读写全部串行。注意读路径上也要加锁——只给写加锁、读不加锁,同样会触发可见性问题,读到的可能是旧数据或者正在写入的半成品。
读写锁的收益完全取决于读多写少的比例。读操作占比80%以上,效果就很明显;如果读写基本相同甚至写更多,读写锁的额外判断逻辑反而会拖慢速度,这时候老老实实用互斥锁更好。
2.3 信号量与条件变量:它们的真实用途是什么
很多人把信号量(Semaphore)和互斥锁搞混,其实它们解决的问题不太一样。互斥锁是互斥访问,信号量是控制访问数量。比如一个连接池只能允许3个线程同时使用,就可以用计数信号量初始化为3,每个线程进入前acquire,用完后release。
C++20才把信号量纳入标准库,之前用POSIX的sem_t:
c复制#include <semaphore.h>
sem_t sem;
sem_init(&sem, 0, 3); // 初始计数3
sem_wait(&sem); // P操作,计数减1,为0则阻塞
// 使用资源
sem_post(&sem); // V操作,计数加1,唤醒等待者
条件变量(Condition Variable)则是更精细的同步工具。互斥锁解决的问题是"同时只能一个人进去",条件变量解决的问题是"某个条件满足了再干活"。比如生产者消费者队列里,消费者需要等到队列非空才能取数据,用互斥锁轮询也可以,但会疯狂空转浪费CPU,条件变量能让消费者在条件不满足时休眠,生产者放入数据后唤醒它。
条件变量几乎总是和互斥锁配合使用,因为它需要在一个原子操作里检查条件并进入休眠,否则可能出现"生产者已经放入数据,消费者却还没来得及休眠,然后永远等不到唤醒"的经典bug。
2.4 屏障与原子操作:容易被忽视的轻量级同步
屏障(Barrier)用于多线程阶段同步:所有线程都需要等齐了才能进入下一阶段。它和互斥锁、条件变量关注点不同,后者管的是资源访问,屏障管的是执行节奏。比如三个线程各自计算一个分块结果,全部算完后才能合并,就需要屏障。
C++20的std::barrier用法:
cpp复制#include <barrier>
#include <thread>
#include <vector>
std::barrier barrier(3);
void worker(int id) {
// 第一阶段
printf("线程%d完成第一阶段\n", id);
barrier.arrive_and_wait(); // 等所有线程到达
// 第二阶段
printf("线程%d开始第二阶段\n", id);
}
原子操作则是更底层的同步手段。std::atomic可以直接对变量做原子级别的读改写,不需要锁。它的开销比锁小一个数量级,适合简单的计数器、标志位场景。
cpp复制#include <atomic>
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1, std::memory_order_relaxed);
}
记忆顺序参数(memory order)是另一个大坑,新手建议直接使用默认的std::memory_order_seq_cst,它提供最强的顺序一致性保证,代价是有一点性能损失。等你能说清楚acquire/release模型时,再去做这些优化也不迟。
3. 经典同步示例的完整实战:从伪代码到可运行程序
光讲概念不写代码等于白说。这一章的三个示例是我在真实工程里反复用到过的,也是操作系统课程里出现频率最高的三个同步问题。我会给出完整的可运行思路,并解释每一步为什么这么写。
3.1 生产者消费者:条件变量的经典配合
生产者消费者问题可以说是同步编程的Hello World。一个线程生产数据放入队列,一个线程消费数据,队列有大小限制,生产者不能往满队列塞数据,消费者不能从空队列取数据。
我第一次写的时候犯过一个错误:只用互斥锁保护队列,消费者拿不到数据就循环重试。结果压测时CPU占用直接100%,因为消费者线程在空转。后来换成条件变量,情况立刻好转。
完整思路如下:
cpp复制#include <condition_variable>
#include <mutex>
#include <queue>
#include <thread>
#include <iostream>
class MessageQueue {
public:
void push(int value) {
std::unique_lock<std::mutex> lock(mtx_);
// 必须用while而不是if,原因后面说
while (queue_.size() >= max_size_) {
not_full_.wait(lock);
}
queue_.push(value);
not_empty_.notify_one();
}
int pop() {
std::unique_lock<std::mutex> lock(mtx_);
while (queue_.empty()) {
not_empty_.wait(lock);
}
int value = queue_.front();
queue_.pop();
not_full_.notify_one();
return value;
}
private:
std::mutex mtx_;
std::condition_variable not_empty_;
std::condition_variable not_full_;
std::queue<int> queue_;
const size_t max_size_ = 10;
};
这里有几个关键点。
第一,wait(lock)会把锁释放,让其他线程能进临界区。注意被唤醒之后,它会重新获取锁才继续往下执行。也就是说,从wait返回的那一刻开始,调用者又重新持有了互斥锁。
第二,为什么唤醒之后要用while重新检查条件,而不是if?因为存在"虚假唤醒"(spurious wakeup),操作系统可能在没有notify的情况下唤醒等待线程;另外可能存在多个消费者被同时唤醒,其中一个消费者抢到锁把队列清空了,另一个消费者重新拿到锁时如果不检查条件,就直接从空队列里取数据了。用while循环重新检查,是最稳妥的防御性写法。
第三,notify_one只唤醒一个等待线程,在多个消费者场景下唤醒任何一个都行。如果想唤醒所有等待线程,用notify_all。但notify_all会引发"惊群效应",大量线程被唤醒后争抢同一把锁,如果确实只需要一个线程处理,用notify_one更合适。
3.2 读者写者问题:用读写锁实现后的收益对比
读者写者问题描述的是这样一个场景:多个线程读同一个共享数据,读读之间可以并发;但写线程必须独占,写的时候不能有人读,读的时候不能有人写。
我在一个配置中心模块里实现过这个模式:配置数据大概有几百KB,几十个线程频繁读取,只有管理员手动改配置时才会触发写入,写入频率可能几分钟一次。最初用互斥锁,压测QPS上不去,换成读写锁后提升非常明显。
核心代码:
cpp复制#include <shared_mutex>
#include <unordered_map>
#include <string>
class ConfigStore {
public:
std::string get(const std::string& key) {
std::shared_lock<std::shared_mutex> lock(mtx_);
auto it = data_.find(key);
return it == data_.end() ? "" : it->second;
}
void set(const std::string& key, const std::string& value) {
std::unique_lock<std::shared_mutex> lock(mtx_);
data_[key] = value;
}
private:
mutable std::shared_mutex mtx_;
std::unordered_map<std::string, std::string> data_;
};
我做过一个简单的基准测试,线程数为8,读操作占90%,用互斥锁的吞吐量大约是读写锁的1/3左右。线程数越多、读占比越高,差距越大。读写锁的代价是内部需要维护读线程计数,写锁申请时还要等现有读者全部退出,所以写操作延迟会比互斥锁略高。工程上需要权衡的是:你的写操作可以接受多大延迟。
读者写者问题还有一个优先级变体:写线程优先还是读线程优先。std::shared_mutex的实现并不保证公平性,通常倾向于让写者尽快获得锁,避免读者持续涌入导致写者饿死。如果你需要精确的优先级控制,就得用条件变量自己实现,但大多数场景下直接用标准库实现就够了。
3.3 屏障同步:多线程分批计算的落地实现
屏障适用的一种典型场景是并行分块计算。比如一个大数组需要求和,4个线程各算一块,必须全部算完后才能把4个部分和合并。这个"必须全部算完才能进入下一步"的节点就是屏障。
在C++20之前,标准库没有屏障,我常用pthread_barrier_t或者自己用互斥锁加条件变量实现。C++20之后用std::barrier就很方便了。
这里有一个std::barrier和std::atomic配合的示例思路:
cpp复制#include <barrier>
#include <thread>
#include <vector>
#include <numeric>
constexpr int THREAD_COUNT = 4;
constexpr int BATCH_SIZE = 100000;
void parallel_sum(std::vector<int>& data) {
std::vector<long long> partial(THREAD_COUNT, 0);
std::barrier sync_point(THREAD_COUNT);
std::vector<std::thread> threads;
for (int t = 0; t < THREAD_COUNT; ++t) {
threads.emplace_back([&, t]() {
for (int round = 0; round < 10; ++round) {
long long local_sum = 0;
int start = t * BATCH_SIZE + round * BATCH_SIZE * THREAD_COUNT;
int end = start + BATCH_SIZE;
for (int i = start; i < end; ++i) {
local_sum += data[i];
}
partial[t] = local_sum;
sync_point.arrive_and_wait();
}
});
}
for (auto& th : threads) th.join();
}
这个示例里,每个线程循环10轮,每轮算完自己的部分和之后,必须等所有线程到达屏障才能进入下一轮。如果不加屏障,快线程可能在第2轮修改partial[自己]的时候,另一个线程还在读取上一轮的partial数据,结果就会错乱。
需要提醒的是,std::barrier的构造函数参数是需要等待的线程数,这个数字必须和实际参与等待的线程一致,否则会死锁。多一个线程就会有一个永远等待的线程。
4. 同步代码里的隐藏陷阱:我实际踩过并修掉的坑
这一章是我最想写的内容。教科书讲同步,往往给你一个标准答案就结束了。但真实工程里的问题,几乎不会长得那么标准。下面三个坑,每一个我都消耗过一整天以上的排查时间。
4.1 死锁:加锁顺序不统一导致的隐性故障
死锁的经典条件是:两个线程各自持有一把锁,同时等待对方手里的锁,于是互相卡死。理论上很好理解,实际写代码时,它往往藏在看起来非常正常的代码里。
我遇到过一个订单模块死锁。场景是这样的:有两个数据表,订单表和库存表。线程A在事务里先锁订单再锁库存;线程B在另一条代码路径上先锁库存再锁订单。某一次高并发下,A持有订单锁等待库存锁,B持有库存锁等待订单锁,两边都等不到,整个模块的后续请求全部堆积。
排查死锁的过程非常痛苦,因为现场不会直接告诉你"我死锁了",现象只是请求超时、线程池被打满。我当时用了一招:通过线程dump查看每个线程持有的锁和等待的锁,发现两个线程互相等待,瞬间明白了原因。
修复思路有两个方向。第一种是统一加锁顺序,所有人都先锁订单再锁库存,这样永远不会出现循环等待。第二种是用std::scoped_lock一次锁定多个锁:
cpp复制#include <mutex>
std::mutex order_mtx;
std::mutex stock_mtx;
void update_order_and_stock() {
// 同时锁定两把锁,内部按固定顺序加锁,避免死锁
std::scoped_lock lock(order_mtx, stock_mtx);
// 更新订单...
// 更新库存...
}
std::scoped_lock的构造参数是按顺序加锁的,但它通过一种避免死锁的算法在内部处理了加锁顺序问题,比手动加锁安全得多。以后凡是需要同时持有多个锁的地方,我建议直接用std::scoped_lock。
4.2 锁粒度与锁竞争:性能瓶颈的排查链路
锁竞争是另一个隐藏得很深的性能杀手。加锁是对的,但锁的粒度太粗,把本来可以并行的操作全部串行化,性能照样上不去。
我遇到过一个撮合引擎的性能问题:核心账簿对象上有一把大锁,所有读写都在这把锁上排队。刚上线时数据量小,毫无感觉;数据量涨了10倍之后,系统吞吐量直接掉到原来的1/5。用性能分析工具看,超过60%的时间花在锁等待上。
排查链路是这样的:
- 先看系统的监控指标,发现CPU利用率并不高,但请求延迟持续上升,典型的锁等待特征——线程都在等锁而不是在执行。
- 用性能剖析工具抓线程状态,看到大量线程阻塞在某个锁的获取上。
- 定位到锁对应的临界区代码,发现这个临界区里做了一堆耗时操作,包括磁盘I/O和网络请求。
修复方案是缩锁粒度。把大锁拆成多个细粒度锁,比如把全局一本账簿改成按用户维度分片,每个分片有自己的锁,不同用户的操作就可以并行执行。还有一个经验是:锁临界区里绝对不要做I/O操作。I/O(磁盘、网络)耗时往往是内存操作的几个数量级,一个线程在临界区里做I/O,相当于一堆线程陪它干等。
Amdahl定律可以量化锁竞争的影响。如果程序串行部分比例是p,核心数n,加速比上限是1 / (1 - p + p/n)。哪怕p只有5%,在32核机器上加速比上限也就是1 / (0.95 + 0.05/32) ≈ 1.05,基本没有加速效果。锁竞争就是在不断增大p。性能调优的时候,盯着锁等待时间看通常不会错。
4.3 优先级反转与实时性:一轮排查实录
优先级反转是个容易被忽略但后果极严重的问题。它的核心是:低优先级线程持有锁,高优先级线程正在等待这把锁,但此时有个中优先级线程插进来,不断抢占CPU,导致低优先级线程一直得不到运行,锁始终无法释放,高优先级线程就被"饿死"。
我第一次遇到这个问题时,现象是一个高优先级的通信线程时不时延迟几百毫秒。按道理它优先级最高,不应该被抢。排查过程很曲折:
- 先怀疑GC停顿,否了。
- 再怀疑网络延迟,也否了。
- 后来加上线程调度日志,发现高优先级线程在等一把被低优先级线程持有的锁,而那个低优先级线程被一堆中优先级任务挤得没法运行。
修复方法在操作系统层面是优先级继承或优先级置顶,但应用层也可以从设计上缓解:尽量缩短持锁时间,锁临界区里不做任何可能被阻塞的操作;如果某个共享资源必须长时间访问,考虑用无锁数据结构替代。
这个坑给我的启发是:同步不只是"加锁防冲突",在实时性要求高的系统里,锁的调度特性同样重要。
5. 实战选型参考:不同语言、不同场景下的同步方案
最后这部分是给已经理解同步原理的读者一个快速决策参考。虽然前面的示例以C++为主,但同步的核心思路在不同语言里是相通的。Python的GIL、Java的synchronized、Go的channel,本质上都是在解决同样的问题,只是表达方式不同。
5.1 原语选择速查表
下面这个表是我根据实际项目经验整理的,可以直接拿来当参考:
| 场景 | 推荐方案 | 为什么这样选 |
|---|---|---|
| 简单的计数器、标志位 | 原子变量 | 开销最小,避免锁阻塞 |
| 临界区操作短、争抢不激烈 | 互斥锁 | 简单可靠,心智负担低 |
| 读多写少,如配置缓存 | 读写锁 | 并发读不串行,吞吐量大 |
| 生产者消费者队列 | 互斥锁 + 条件变量 | 避免轮询空转,支持等待唤醒 |
| 多线程分阶段计算 | 屏障 | 天然表达"等齐再继续" |
| 控制并发访问数量 | 信号量 | 限制同时执行的线程数 |
| 多个锁必须同时持有时 | std::scoped_lock | 内部规避死锁风险 |
注意,表格只是起点。真正做选型时,还要考虑语言生态。比如Go的channel模型比传统锁在消息传递场景更自然;Java的ReentrantReadWriteLock和StampedLock提供了比C++更丰富的选择;Python里因为GIL的存在,多线程对CPU密集型任务帮助有限,反而multiprocessing更常用,但锁的原语是相通的。
5.2 从互斥到无锁:什么情况下值得尝试无锁编程
无锁编程听起来高级,但不是万金油。我见过一个团队把所有锁都换成无锁队列,结果上线后出现各种诡异的数据错乱,回滚后又恢复正常。无锁编程的正确率,远低于传统同步,因为它极其依赖对内存模型、指令重排、ABA问题的深入理解。
什么情况下值得做无锁?我的判断标准是:
- 锁竞争已经通过profiling确认是主要瓶颈。
- 目标数据结构相对简单,比如单生产者单消费者的有界队列。
- 团队里有人能说清楚CAS(比较并交换)、内存序、ABA问题。
一个经典的无锁场景是单生产者单消费者环形队列。由于生产者和消费者各自只操作自己的指针,配合std::atomic和内存序就能实现无锁。它的关键点是:生产者只修改写指针,消费者只修改读指针,两者的指针更新用release和acquire保证可见性。
cpp复制#include <atomic>
#include <vector>
template <typename T>
class SPSCQueue {
public:
explicit SPSCQueue(size_t capacity)
: buffer_(capacity), capacity_(capacity) {}
bool push(const T& value) {
size_t write_pos = write_pos_.load(std::memory_order_relaxed);
size_t read_pos = read_pos_.load(std::memory_order_acquire);
if (write_pos + 1 == read_pos ||
(write_pos == capacity_ - 1 && read_pos == 0)) {
return false; // 队列满
}
buffer_[write_pos] = value;
write_pos_.store((write_pos + 1) % capacity_,
std::memory_order_release);
return true;
}
bool pop(T& value) {
size_t read_pos = read_pos_.load(std::memory_order_relaxed);
size_t write_pos = write_pos_.load(std::memory_order_acquire);
if (read_pos == write_pos) {
return false; // 队列空
}
value = buffer_[read_pos];
read_pos_.store((read_pos + 1) % capacity_,
std::memory_order_release);
return true;
}
private:
std::vector<T> buffer_;
size_t capacity_;
std::atomic<size_t> write_pos_{0};
std::atomic<size_t> read_pos_{0};
};
这个实现里,release和acquire配对使用:生产者先写入数据,再发布新的写位置;消费者先获取写位置,再读取数据。这样能保证消费者读取到的数据一定是生产者真正写入过的,不会因为内存乱序而读到旧值。
但是你要清楚,无锁并不等于无等待,它只是把阻塞从操作系统层转移到了CPU的CAS自旋上。如果争抢激烈,自旋本身也在消耗CPU。无锁的正确性极难验证,我的经验是:如果项目不是特别需要极致的性能,优先用锁;等真的出现锁竞争瓶颈,再针对性地引入无锁实现。
同步这块内容,边界特别大,从硬件缓存一致性到用户态锁实现,每一层展开都能写一本书。但这几个经典示例和踩坑经历,是几乎所有并发系统都会反复碰到的地方。如果你是在排查一个偶发的并发问题,先别急着怀疑编译器或者CPU玄学,把同步原语的用法对着代码过一遍,把锁粒度、加锁顺序、条件变量写法检查一遍,通常就已经解决一大半了。
我个人在实际项目中还有一个体会:同步代码写完后,一定要刻意做并发压测,而且要在多核机器上跑。很多同步问题在单核机器上是复现不出来的,因为单核下线程切换的时机相对固定,竞态条件很难触发。把线程数调到CPU核数的两倍以上,循环跑长时间压测,才能逼出那些"偶尔才出现一次"的问题。这个习惯,帮我避免过好多次线上事故。
