分类
技术

C++ 多线程与原子操作

在单线程世界里,程序的执行顺序是确定的,一条语句执行完才会执行下一条,控制流清晰、结果可预测;但在多线程世界中,情况完全不同。多个线程可能同时访问同一块内存,指令的交错顺序由调度器决定,执行结果不再具备确定性。一旦缺乏正确的同步机制,程序就会陷入数据竞争(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 对象通常经历:

  1. 构造(线程启动)
  2. 执行任务
  3. join()detach()
  4. 销毁

必须记住:每一个线程对象,必须明确“收尾”。

常见安全写法:

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++;

在单线程世界中,它看起来只是一次简单的自增操作。但在机器层面,它通常会被拆解为三步:

  1. 从内存读取 counter
  2. 在寄存器中加一
  3. 将结果写回内存

如果两个线程交错执行,可能会出现这样的情况:

  • 线程 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++;

只要这段代码没有被放入受保护的临界区,它仍然是非线程安全的。

正确的心智模型应该是:锁控制的是“谁可以在某个时间段内执行这段代码”。

因此,设计临界区时需要明确两个问题:

  1. 哪些数据是共享的?
  2. 哪些代码在访问这些共享数据?

然后仅对必要的代码区域加锁。

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++;

在普通变量中,它会被拆解为:

  1. 读取
  2. 加一
  3. 写回

这是一个“复合操作”,不是原子的。

而如果我们这样声明:

#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++ 内存模型试图解决两个问题:

  1. 哪些行为是未定义的(例如数据竞争)
  2. 哪些同步操作能够建立确定的“先后关系”

其中一个核心概念是: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_relaxed
  • memory_order_acquire
  • memory_order_release
  • memory_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 关系,程序才真正具备确定性。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

在此处输入验证码 : *

Reload Image