在单线程世界里,程序的执行顺序是确定的,一条语句执行完才会执行下一条,控制流清晰、结果可预测;但在多线程世界中,情况完全不同。多个线程可能同时访问同一块内存,指令的交错顺序由调度器决定,执行结果不再具备确定性。一旦缺乏正确的同步机制,程序就会陷入数据竞争(data race),而在 C++ 内存模型中,这往往意味着未定义行为。
这篇文章将从 C++ 多线程基础讲起,逐步深入:
- C++ 线程模型
- 互斥锁
std::mutex- 数据竞争与临界区
- 原子操作
std::atomic- 内存模型与 memory order(基础理解)
1. C++ 多线程基础
在 C++11 之前,标准库并没有提供统一的线程接口,多线程开发通常依赖平台相关 API(如 pthread 或 Windows API)。C++11 之后,标准库引入了 <thread>,使得跨平台多线程编程成为语言级能力。
理解 C++ 多线程的第一步,不是同步机制,而是先理解:什么是线程,以及 std::thread 做了什么。
1.1 std::thread
std::thread 的本质是:让一段函数在另一条执行路径上并发执行。
可以把它理解为:
- 主线程负责程序主流程
- 新线程负责并行执行某个任务
- 两条执行路径同时推进
这不是“函数调用”,而是“并发执行”。
1.2 创建一个线程
最基本的线程创建方式如下:
#include <iostream>
#include <thread>
void work() {
std::cout << "新线程正在运行\n";
}
int main() {
std::thread t(work); // 创建并启动线程
t.join(); // 等待线程结束
}
需要注意的几点:
std::thread t(work);在构造时立即启动线程join()用于等待线程执行完成- 如果线程对象销毁前没有
join()或detach(),程序会调用std::terminate()
线程对象并不是“任务本身”,它更像是线程的控制句柄。
1.3 线程的生命周期
一个 std::thread 对象通常经历:
- 构造(线程启动)
- 执行任务
join()或detach()- 销毁
必须记住:每一个线程对象,必须明确“收尾”。
常见安全写法:
if (t.joinable())
t.join();
joinable() 用于判断线程是否仍然可被回收。
1.4 传递参数给线程
线程函数可以接受参数,参数在构造线程时传入:
#include <thread>
#include <iostream>
void work(int value) {
std::cout << "参数: " << value << std::endl;
}
int main() {
std::thread t(work, 42);
t.join();
}
参数会按值传递。如果希望传引用,应使用:
std::ref(obj);
否则默认会发生拷贝。
1.5 使用 Lambda 表达式
现代 C++ 中,更常见的写法是使用 lambda:
#include <thread>
#include <iostream>
int main() {
std::thread t([](){
std::cout << "lambda 线程运行中\n";
});
t.join();
}
这种方式更加简洁,尤其适合局部任务。
1.6 多个线程并发执行
可以同时启动多个线程:
void task(int id) {
std::cout << "线程 " << id << " 执行中\n";
}
int main() {
std::thread t1(task, 1);
std::thread t2(task, 2);
std::thread t3(task, 3);
t1.join();
t2.join();
t3.join();
}
输出顺序通常是不固定的,这体现了并发的一个核心特点:线程的调度顺序不可预测。
操作系统调度器会根据时间片和系统负载安排执行顺序,因此不要依赖线程执行的先后顺序。
1.7 join 与 detach
线程启动后,必须选择以下两种方式之一:
join()
主线程等待子线程执行完成。
优点是逻辑清晰、资源安全,是最常见用法。
detach()
线程与当前控制对象分离,独立运行。
这种方式适合后台任务,但要确保:
- 不访问已销毁对象
- 生命周期设计清晰
初学阶段建议优先使用 join()。
1.8 线程对象的特殊性质
std::thread 有几个必须牢记的规则:
- 不可拷贝(copy deleted)
- 可以移动(move semantics)
- 只能 join 或 detach 一次
例如:
std::thread t1(work);
std::thread t2 = std::move(t1); // 合法
线程所有权可以转移,但不能复制。
小结
std::thread 提供的是一种并发执行能力,而不是同步机制。它允许程序拥有多条执行路径,但并不保证数据安全,也不自动管理资源冲突。
2. 数据竞争(Data Race)
当多个线程开始并发执行时,真正的问题才刚刚出现。
如果多个线程同时访问同一份数据,并且其中至少有一个线程在修改它,那么程序就进入了一个危险状态——这就是所谓的 数据竞争(Data Race)。
来看一个最典型的例子:
#include <thread>
#include <iostream>
int counter = 0;
void add() {
for (int i = 0; i < 100000; ++i)
counter++;
}
int main() {
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
std::cout << counter << std::endl;
}
直觉告诉我们,最终结果应该是 200000。但实际运行时,结果几乎不会等于 200000,而且每次运行可能都不同。
问题出在这一行:
counter++;
在单线程世界中,它看起来只是一次简单的自增操作。但在机器层面,它通常会被拆解为三步:
- 从内存读取
counter - 在寄存器中加一
- 将结果写回内存
如果两个线程交错执行,可能会出现这样的情况:
- 线程 A 读取到 0
- 线程 B 也读取到 0
- 线程 A 写回 1
- 线程 B 写回 1
一次加法被“覆盖”掉了,结果丢失。
更重要的是,在 C++ 的内存模型中,未同步的并发读写属于 未定义行为(Undefined Behavior)。这不仅仅意味着结果可能错误,它意味着编译器和 CPU 有权进行各种优化,程序行为将彻底不可预测。你看到的异常结果,可能只是问题的表象。
数据竞争的本质在于:多个线程对同一内存位置的访问顺序没有被约束。操作系统的调度器会在任意时刻切换线程,CPU 也可能对指令进行乱序执行,而编译器还会进行优化重排。在缺乏同步的情况下,没有任何机制保证执行顺序符合我们的“直觉”。
因此,多线程的第一个核心原则是:只要多个线程同时访问同一份数据,就必须考虑同步问题。
3. 互斥锁 std::mutex:为共享数据建立秩序
在上一节中我们讨论了数据竞争的本质:多个线程在缺乏同步约束的情况下访问同一块内存,程序行为将变得不可预测。在 C++ 内存模型下,这甚至属于未定义行为。
既然问题的根源在于“访问顺序没有被约束”,那么解决思路也就很明确:在访问共享数据时,建立明确的互斥规则。
C++ 标准库为此提供了最基础、也是最重要的同步原语 —— std::mutex。
3.1 互斥的含义
mutex 的全称是 mutual exclusion,即“互斥”。它的语义非常简单:同一时间,只允许一个线程进入某段受保护的代码区域。
这段受保护的代码通常被称为“临界区”(critical section)。当一个线程持有某个 mutex 时,其他试图获取该锁的线程必须等待,直到锁被释放。
换句话说,mutex 并不是保护变量本身,而是为一段代码的执行建立时间上的排他性。
3.2 最基础的用法
回到前一节的数据竞争示例,我们可以通过 std::mutex 来约束访问顺序:
#include <thread>
#include <iostream>
#include <mutex>
int counter = 0;
std::mutex m;
void add() {
for (int i = 0; i < 100000; ++i) {
m.lock();
counter++;
m.unlock();
}
}
int main() {
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
std::cout << counter << std::endl;
}
这里的 lock() 与 unlock() 明确地建立了互斥规则:在任意时刻,只有一个线程可以执行 counter++ 这一段代码。
这种写法能够消除数据竞争,但它存在一个明显的问题:如果在 lock() 与 unlock() 之间发生异常或提前返回,锁将无法释放,程序可能陷入死锁状态。
因此,在现代 C++ 中,手动管理锁并不是推荐方式。
3.3 RAII 风格的锁管理:std::lock_guard
更安全、更符合 C++ 风格的写法是使用 RAII 封装:
void add() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(m);
counter++;
}
}
std::lock_guard 在构造时自动调用 lock(),在析构时自动调用 unlock()。由于析构发生在对象离开作用域时,这意味着锁的持有范围严格等于该对象的作用域范围。
也就是说:std::lock_guard 的作用域,决定了临界区的范围。
例如:
{
std::lock_guard<std::mutex> lock(m);
counter++;
}
这里的花括号明确地界定了锁的生命周期。离开代码块时,锁自动释放。
这种方式避免了忘记解锁、异常中断等问题,是现代 C++ 推荐的基本用法。
3.4 更灵活的锁:std::unique_lock
虽然 std::lock_guard 足够安全,但它的设计非常“克制”——一旦构造就立即加锁,并且无法提前解锁或延迟加锁。
在一些更复杂的场景下,我们需要更灵活的控制能力。这时就需要:
std::unique_lock<std::mutex>
从语义上讲:
lock_guard 是“固定生命周期的锁”,
unique_lock 是“可控生命周期的锁”。
基本用法
void add() {
std::unique_lock<std::mutex> lock(m);
counter++;
}
在最简单场景下,它和 lock_guard 行为一致:构造时加锁,析构时解锁。
但它额外提供了几种能力。
延迟加锁
有时我们希望先创建锁对象,但暂时不获取锁:
std::unique_lock<std::mutex> lock(m, std::defer_lock);
// 此时尚未加锁
do_something();
lock.lock(); // 手动加锁
counter++;
lock_guard 无法做到这一点。
中途解锁
在高并发场景下,我们可能希望:
- 只在真正访问共享数据时持有锁
- 访问完成后立即释放
std::unique_lock<std::mutex> lock(m);
update_shared_data();
lock.unlock(); // 提前释放锁
do_heavy_work(); // 不再占用锁
lock.lock(); // 如有需要可再次获取
这种模式可以显著缩短锁的持有时间,提高并发效率。
与 condition_variable 配合
std::condition_variable 只能与 std::unique_lock 配合使用:
cpp复制代码std::unique_lock<std::mutex> lock(m);
cv.wait(lock);
这是因为 wait() 需要在内部临时释放锁,并在唤醒时重新获取锁。lock_guard 不具备这种可控性。
因此:只要涉及条件变量,必须使用 unique_lock。
3.5 lock_guard 与 unique_lock 的选择
可以用一个简单的决策逻辑:
- 只需简单保护一段代码 →
std::lock_guard - 需要延迟加锁 / 手动解锁 →
std::unique_lock - 需要条件变量 →
std::unique_lock
在不需要额外控制能力时,优先选择 lock_guard,因为:
- 语义更简单
- 开销更小
- 出错空间更少
unique_lock 的灵活性是以稍高复杂度为代价的。
3.6 临界区的边界:锁保护的是“代码”,不是“变量”
一个常见的误解是认为 mutex 在“锁住变量”。事实上,mutex 只是在某个时间区间内,限制其他线程进入同一段代码。
如果存在如下代码:
counter++;
只要这段代码没有被放入受保护的临界区,它仍然是非线程安全的。
正确的心智模型应该是:锁控制的是“谁可以在某个时间段内执行这段代码”。
因此,设计临界区时需要明确两个问题:
- 哪些数据是共享的?
- 哪些代码在访问这些共享数据?
然后仅对必要的代码区域加锁。
3.7 锁的粒度与性能
虽然 mutex 能够保证正确性,但它并不是免费的。
当一个线程持有锁时,其他线程必须阻塞等待。频繁加锁或锁定范围过大,会显著降低并发性能。
例如:
std::lock_guard<std::mutex> lock(m);
// 大量无关计算
// 只有一小部分代码真正访问共享数据
如果锁定范围过大,实际上等价于降低并发度,使程序更接近串行执行。
因此,实践中的一个重要原则是:锁要尽量小、尽量短、只覆盖真正的临界区。
在保证正确性的前提下,缩小锁的粒度,是并发程序性能优化的关键步骤。
3.8 常见风险与注意事项
在使用 std::mutex 时,常见问题包括:
- 忘记加锁,导致数据竞争
- 手动
lock()后忘记unlock(),导致死锁 - 锁范围过大,严重影响性能
- 多个
mutex交叉加锁,导致循环等待
这些问题并不是语法错误,而是设计层面的错误。因此,合理规划锁的作用范围与顺序,是多线程设计的重要部分。
小结
std::mutex 是 C++ 并发编程中最基础的同步工具。它通过建立互斥规则,为共享数据的访问提供确定性顺序,从而消除数据竞争。
如果说 std::thread 提供了“并发执行的能力”,那么 std::mutex 则提供了“并发执行的秩序”。
但锁带来的不仅是安全,也伴随着性能成本。在更高性能要求的场景下,我们往往希望避免阻塞式互斥,这也是 std::atomic 存在的原因。
下一节,我们将讨论:在某些简单场景下,如何通过原子操作,在不使用互斥锁的情况下保证线程安全。
4. 原子操作:std::atomic
在上一章中,我们通过 std::mutex 建立了对共享数据的访问秩序。互斥锁能够彻底消除数据竞争,但它的代价也非常明显:线程可能阻塞、发生上下文切换、降低并发度。
这就引出一个自然的问题:在某些简单场景下,是否可以不使用互斥锁,也能保证线程安全?
答案是可以。
这正是 std::atomic 存在的意义。
4.1 什么是“原子”操作?
“原子”(atomic)在并发语境中的含义是:一个操作要么全部完成,要么完全不发生,中间状态对其他线程不可见。
它不可被打断,也不可被其他线程观察到“执行到一半”的状态。
回到最经典的例子:
counter++;
在普通变量中,它会被拆解为:
- 读取
- 加一
- 写回
这是一个“复合操作”,不是原子的。
而如果我们这样声明:
#include <atomic>
std::atomic<int> counter{0};
那么:
counter++;
就变成了一个真正的原子操作。
多个线程同时执行时,不会发生更新丢失。
4.2 最基础用法
把第二章的数据竞争示例改为原子变量:
#include <thread>
#include <iostream>
#include <atomic>
std::atomic<int> counter{0};
void add() {
for (int i = 0; i < 100000; ++i)
counter++;
}
int main() {
std::thread t1(add);
std::thread t2(add);
t1.join();
t2.join();
std::cout << counter << std::endl;
}
这一次,输出将稳定为 200000。
没有使用 mutex,也没有阻塞等待,但数据是安全的。
4.3 原子操作的本质
std::atomic 并不是“魔法”。它的实现依赖于底层硬件支持。
现代 CPU 提供专门的原子指令,例如:
- x86 的
LOCK前缀 - ARM 的 load-exclusive / store-exclusive 指令
这些指令能够在总线级别保证某次内存操作的不可分割性。
此外,原子操作还依赖一个更重要的机制:CAS(Compare-And-Swap)
4.4 CAS:原子操作的核心
CAS 的逻辑非常简单:
如果当前值 == 期望值
修改为新值
否则
什么都不做
在 C++ 中可以使用:
compare_exchange_strong
compare_exchange_weak
例如:
std::atomic<int> value{0};
int expected = 0;
value.compare_exchange_strong(expected, 1);
如果 value 当前是 0,它会被改为 1;
如果不是 0,则操作失败。
CAS 是构建无锁算法的基础,例如:
- 自旋锁
- 无锁队列
- 无锁栈
4.5 atomic 与 mutex 的区别
从设计层面看:
mutex通过“阻塞其他线程”来保证安全atomic通过“硬件级原子指令”来保证安全
mutex 是悲观策略(先锁住再说);
atomic 是乐观策略(假设冲突不频繁,直接操作)。
它们的适用场景完全不同。
4.6 什么时候使用 atomic?
std::atomic 适用于:
- 简单计数器
- 状态标志位
- 单变量的读写同步
- 轻量级统计
例如:
std::atomic<bool> ready{false};
但它不适用于:
- 复杂数据结构
- 多变量之间的联合修改
- 需要多个操作保持一致性的场景
例如:
x++;
y++;
如果这两个操作必须保持一致,单独使用 atomic 是不够的。
4.7 内存顺序(简单理解)
默认情况下,原子操作使用:
memory_order_seq_cst
这是“顺序一致性”的最强保证。
它意味着:所有线程看到的操作顺序一致。
除此之外,还有:
- memory_order_relaxed
- memory_order_acquire
- memory_order_release
- memory_order_acq_rel
这些属于更高级的内存模型控制。
在大多数应用场景下,默认顺序一致性已经足够安全。除非你明确理解内存模型,否则不建议轻易调整 memory order。
4.8 atomic 不是“更高级的 mutex”
一个常见误解是:atomic 可以替代 mutex。
事实上,它们解决的是不同层级的问题。
atomic 只保证“单个变量操作”的原子性,
mutex 保证“任意代码块”的互斥性。
如果需要保护复杂逻辑或多个变量之间的关系,mutex 才是正确选择。
小结
std::atomic 提供了一种无需阻塞的同步机制,通过硬件级原子指令保证单变量操作的安全性。它比互斥锁更轻量,但能力也更有限。
如果说 mutex 是通过建立秩序来避免冲突,那么 atomic 是通过不可分割的操作来避免冲突。
在简单状态同步场景下,atomic 更高效;在复杂共享逻辑场景下,mutex 更可靠。
下一章,我们将对 C++ 的内存模型进行一个基础层面的理解,解释为什么顺序一致性如此重要,以及 memory order 背后的思想。
5. C++ 内存模型与 memory order
在单线程程序中,我们几乎默认一条规则:代码的执行顺序,就是我们写下的顺序。
但在多线程世界里,这条规则并不成立。
现代编译器会优化代码,现代 CPU 会进行乱序执行,缓存系统也会延迟内存可见性。即使在没有数据竞争的情况下,不同线程看到的执行顺序也可能不同。
这正是 C++11 引入正式内存模型的原因。
5.1 为什么需要内存模型?
考虑一个经典例子:
int data = 0;
bool ready = false;
void producer() {
data = 42;
ready = true;
}
void consumer() {
if (ready)
std::cout << data << std::endl;
}
直觉告诉我们:
- 如果
ready == true - 那么
data一定已经被赋值为 42
但在多线程环境中,这并不保证成立。
原因在于:
- 编译器可能重排指令
- CPU 可能乱序执行
- 写入操作可能还停留在缓存中
在没有同步机制的情况下,consumer 线程可能看到:
- ready == true
- data 仍然是旧值
这就是所谓的“可见性问题”。
5.2 C++ 内存模型的核心目标
C++ 内存模型试图解决两个问题:
- 哪些行为是未定义的(例如数据竞争)
- 哪些同步操作能够建立确定的“先后关系”
其中一个核心概念是:happens-before 关系
如果操作 A happens-before 操作 B,那么 B 一定可以看到 A 的结果。
而建立 happens-before 的方式之一,就是通过原子操作和锁。
5.3 原子操作与顺序一致性
默认情况下,std::atomic 使用的是:
memory_order_seq_cst
它代表“顺序一致性”(sequential consistency)。
顺序一致性保证:
所有线程看到的操作顺序是一致的。
可以简单理解为:
- 所有原子操作像排成一条全局时间线
- 每个线程都按同样的顺序观察它们
这是最强、也是最容易理解的内存语义。
在大多数程序中,保持默认即可。
5.4 放松顺序:memory_order 的存在意义
除了 seq_cst,C++ 还提供了更细粒度的内存顺序控制:
memory_order_relaxedmemory_order_acquirememory_order_releasememory_order_acq_rel
这些控制的并不是“是否原子”,而是:是否建立跨线程的可见性和顺序约束。
例如:
flag.store(true, std::memory_order_release);
if (flag.load(std::memory_order_acquire)) {
// 这里可以安全读取其他数据
}
release-acquire 组合可以建立一种单向的 happens-before 关系。
但需要强调:原子性 ≠ 顺序保证。
即使操作是原子的,如果使用 relaxed 模式,也可能没有建立可见性关系。
5.5 为什么默认不要改 memory_order?
在绝大多数场景中:
- 使用默认
seq_cst - 不要主动降低内存顺序
因为:
- 更弱的顺序更难推理
- 更容易引入隐蔽 bug
- 优化收益通常很有限
内存顺序优化通常只在高性能无锁算法中才有意义。
5.6 mutex 与内存模型
值得注意的是:mutex 本身就建立了 happens-before 关系。
当一个线程:
- 解锁 mutex
而另一个线程:
- 随后成功加锁同一个 mutex
那么:
- 解锁前的所有写操作
- 对加锁后的线程可见
这也是为什么 mutex 可以保证数据一致性。
换句话说:
- mutex 提供的是强同步
- atomic 提供的是可控同步
5.7 一个完整的层次结构
现在可以把整篇文章的内容串起来:
std::thread提供并发执行能力- 数据竞争揭示并发中的不确定性
std::mutex建立互斥顺序std::atomic提供无锁原子操作- 内存模型定义跨线程可见性规则
C++ 并发体系并不是零散的 API,而是一整套分层设计。
小结
C++ 内存模型的核心,不是让程序更快,而是让程序在并发环境下依然可推理。
它明确规定了:
- 哪些行为是未定义的
- 哪些同步操作建立顺序关系
- 原子操作如何影响可见性
理解 memory order,并不是为了在日常代码中频繁使用它,而是为了理解:并发程序的正确性,依赖于明确的顺序与可见性约束。
在多线程世界里,代码的书写顺序并不等于执行顺序。
只有通过同步机制建立 happens-before 关系,程序才真正具备确定性。
