云计算百科
云计算领域专业知识百科平台

深入理解C++ 处理多线程的各种手段

# C++ 多线程同步手段全景

原子操作只是最底层的一块拼图。要解决多线程问题,本质上是在处理两件事:**共享可变状态的保护**和**线程间的执行顺序/通知**。围绕这两点,C++ 标准库(以及一些架构层面的方法)提供了多种工具,各有适用场景。

—

## 一、互斥量族:保护临界区(最常用)

这是最主流、最直观的方案。核心思想是把"可能冲突的代码段"包起来,**同一时刻只允许一个线程进入**。

| 类型 | 用途 |
| :— | :— |
| `std::mutex` | 最基础的互斥锁,不可重入 |
| `std::recursive_mutex` | 允许同一线程重复加锁(慎用,往往说明设计有问题) |
| `std::timed_mutex` | 支持 `try_lock_for` / `try_lock_until` 超时 |
| `std::shared_mutex`(C++17) | **读写锁**:多个读者可并发,写者独占 |
| `std::shared_timed_mutex`(C++14) | 带超时的读写锁 |

```cpp
std::mutex mtx;
int shared_data = 0;

void worker() {
    std::lock_guard<std::mutex> lock(mtx);   // RAII,离开作用域自动解锁
    ++shared_data;
}
```

**关键点**:用 `lock_guard` / `unique_lock` / `scoped_lock` 这类 RAII 包装,**不要手写 `lock()/unlock()`**,否则异常路径极易漏解锁。

**读写锁适合"读多写少"场景**(如配置表、缓存),`std::shared_lock` 读、`std::unique_lock` 写。

—

## 二、条件变量:等待 / 通知

互斥量只解决"互斥",不解决"**什么时候该干活**"。条件变量让线程**睡眠等待某个条件成立**,避免忙等。

```cpp
std::mutex mtx;
std::condition_variable cv;
std::queue<int> q;

// 消费者
void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return !q.empty(); });  // 谓词版本,自动处理虚假唤醒
    int v = q.front(); q.pop();
}

// 生产者
void producer(int v) {
    {
        std::lock_guard<std::mutex> lock(mtx);
        q.push(v);
    }
    cv.notify_one();
}
```

**必须注意**:
– 一定要用**带谓词的 `wait`**,否则可能被**虚假唤醒**(spurious wakeup)。
– `notify_one/notify_all` 尽量在**释放锁之后**调用,减少被唤醒线程立即又阻塞在锁上的开销。
– 条件变量必须和互斥量配合,它本身不提供原子性。

这是"生产者-消费者"、"任务队列"等模式的标配。

—

## 三、C++20 新同步原语:信号量 / 闩锁 / 屏障

C++20 补齐了几个 C 时代缺失、之前只能靠平台 API 或手写实现的原语:

| 类型 | 语义 | 典型用途 |
| :— | :— | :— |
| `std::counting_semaphore<N>` | 计数信号量,`acquire` 减一、`release` 加一,减到 0 阻塞 | 限流(如控制同时访问某资源的线程数) |
| `std::binary_semaphore` | 计数为 1 的信号量 | 简单的通知/互斥 |
| `std::latch` | **一次性**计数器,倒数到 0 后所有等待者放行 | 主线程等待 N 个子线程完成初始化 |
| `std::barrier` | **可复用**屏障,所有线程到达后一起继续 | 分阶段并行计算(如每轮迭代都要对齐) |

```cpp
std::binary_semaphore sem{0};

// 线程 A:准备数据后释放信号
sem.release();

// 线程 B:等待数据就绪
sem.acquire();
```

信号量在很多场景能**替代条件变量 + 互斥量**的组合,代码更短。

—

## 四、future / promise / async:任务与结果传递

这一族不直接"同步内存访问",而是把**"一个线程产生结果,另一个线程取结果"**这个交互抽象出来,**数据传递天然是安全的**(结果通过共享状态传递,内部已同步)。

```cpp
// 方式 1:async + future
auto fut = std::async(std::launch::async, [] {
    return compute();
});
int result = fut.get();   // 阻塞直到结果可用

// 方式 2:promise 手动塞值
std::promise<int> prom;
std::future<int> f = prom.get_future();

std::thread t([&] { prom.set_value(42); });
int v = f.get();
t.join();
```

**关键点**:
– `std::async` 不指定 `launch::async` 时,行为可能是**延迟执行**(deferred),并不一定开新线程,容易踩坑。
– `std::future` 的 `get()` **只能调一次**(会移动内部状态)。需要多次取值用 `std::shared_future`。
– 这是**架构级的解耦**:调用者不关心结果怎么算出来的,只关心什么时候能拿到,比手动锁更不容易出错。

—

## 五、`thread_local`:从根上消除共享

如果每个线程需要一份**独立**的数据(如线程上下文、随机数生成器、缓存),用 `thread_local` 直接让每个线程拥有自己的副本,**根本不存在共享,也就不需要任何同步**。

```cpp
thread_local int tls_counter = 0;   // 每个线程各有一份

void worker() {
    ++tls_counter;   // 无竞争,无需加锁
}
```

**适用**:日志上下文、线程专属内存池、`errno` 式状态、随机数引擎。  
**注意**:`thread_local` 对象在**线程退出时析构**,如果它的析构依赖其他全局对象,小心析构顺序问题。

这是"**不共享**"思路的代表——**最好的同步是不需要同步**。

—

## 六、`std::call_once` / `once_flag`:一次性初始化

单例、全局初始化等场景,需要保证**某段代码只执行一次**(哪怕多线程同时触发):

```cpp
std::once_flag flag;
Singleton* instance = nullptr;

Singleton* get_instance() {
    std::call_once(flag, [] {
        instance = new Singleton();
    });
    return instance;
}
```

相比"双重检查锁定 + 原子",`call_once` **更简单且正确**(内部已处理好内存序),推荐使用。C++11 起函数内 `static` 局部变量的初始化**本身就是线程安全**的,所以单例也可以直接写:

```cpp
Singleton& get_instance() {
    static Singleton inst;   // C++11 保证只初始化一次且线程安全
    return inst;
}
```

—

## 七、死锁避免:`std::lock` / `scoped_lock` / 锁层级

多把锁的场景,最怕**死锁**(A 持有锁1等锁2,B 持有锁2等锁1)。标准库提供两个手段:

1. **`std::scoped_lock`(C++17)/ `std::lock`**:一次原子地锁多个互斥量,内部用**避免死锁的算法**(如按地址排序后加锁)。
   ```cpp
   std::scoped_lock lock(mtx1, mtx2);   // 安全地同时锁两把
   ```
2. **固定加锁顺序**:给锁定义层级,所有线程**按同一顺序**加锁,从设计上消除环形等待。

此外,`std::unique_lock` 支持 `std::defer_lock`(先构造不加锁)、`try_lock` 等,方便实现更灵活的策略。

—

## 八、无共享 / 消息传递(架构层面)

前面都是"共享内存 + 同步"。另一条路是**尽量避免共享**:

– **线程私有数据 + 消息队列**:线程之间只通过 `std::queue`(配互斥量+条件变量,或 C++20 信号量)传消息,而不直接读写对方的数据。这是 Actor 模型的核心。
– **不可变数据(immutable)**:一旦创建就不再修改,多线程随便读,天然安全。
– **函数式风格 / 纯函数**:无副作用,可以放心并行(如 `std::for_each(std::execution::par, …)`)。
– **`std::execution` 并行算法(C++17)**:把循环交给库去并行,你只写"要做什么",不碰线程和锁。

无共享设计的**同步成本最低、可扩展性最好**,但需要调整数据组织方式。

—

## 九、其他补充

– **`std::atomic_flag`**:最轻量的原子布尔,是唯一**保证无锁**的原子类型(`std::atomic` 的其他类型不保证),常用来实现自旋锁。
– **`std::atomic<T*>`**:无锁栈、无锁队列等数据结构的基石(虽然属于原子操作范畴,但常和锁方案做对比)。
– **平台 API**:`pthread_mutex`、Windows `CriticalSection`、`SRWLock` 等,标准库底层就是它们,一般不需要直接用。
– **协程(C++20 `co_await`)**:不是"同步"工具,但能在单线程内用**挂起/恢复**替代线程切换,减少对锁和线程的依赖,是另一种并发模型。

—

## 总结对比

| 方法 | 解决什么 | 优点 | 注意事项 |
| :— | :— | :— | :— |
| **互斥量族** | 临界区互斥 | 通用、直观 | 注意死锁、用 RAII |
| **读写锁** | 读多写少 | 读并发 | 写饥饿、复杂度 |
| **条件变量** | 等待/通知 | 避免忙等 | 必须带谓词、防虚假唤醒 |
| **信号量/闩锁/屏障(C++20)** | 限流/汇合/对齐 | 语义清晰、可替代锁+CV | 依赖 C++20 |
| **future/promise/async** | 结果传递 | 解耦、不易出错 | async 的 deferred 陷阱、get 只一次 |
| **thread_local** | 消除共享 | 零同步开销 | 析构顺序、内存占用 |
| **call_once / static** | 一次性初始化 | 简单正确 | 初始化函数别抛异常 |
| **scoped_lock / 锁层级** | 防死锁 | 一次多锁安全 | 仅限互斥量 |
| **消息传递 / 不可变** | 架构层面去共享 | 可扩展性最好 | 需要改设计 |

**选择思路**:
1. 能**不共享**就不共享(`thread_local`、消息传递、不可变数据);
2. 需要共享就**优先用高层抽象**(`future`、并行算法、信号量),它们比手写锁更不容易出错;
3. 必须保护临界区时用 **`mutex` + RAII**,读写不均时考虑 `shared_mutex`;
4. 需要等待条件用 **`condition_variable`**(C++20 可用 `atomic::wait` 或信号量);
5. 多锁场景务必用 **`scoped_lock`** 或固定加锁顺序防死锁。

原子操作是"**最细粒度的同步**",而上面这些是"**不同粒度、不同抽象层次的同步**"——越往上越安全、越易维护,越往下性能潜力越大但越容易写错。**能用高层就不用底层**,是并发编程的普遍原则。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 深入理解C++ 处理多线程的各种手段
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!