C++多线程同步与并发问题详解
立即解锁
发布时间: 2025-08-22 00:43:55 阅读量: 2 订阅数: 16 


深入解析C++标准库:从入门到精通
### C++ 多线程同步与并发问题详解
#### 1. 并发问题概述
在多线程编程中,多线程的使用几乎总是与并发数据访问相结合。很少有多个线程彼此独立运行,线程之间可能相互提供处理后的数据,或者为其他进程的启动准备前提条件。这使得多线程编程变得棘手,许多情况可能会出错。
在讨论线程同步和并发数据访问的不同方法之前,我们必须先理解问题所在。以下是一些可以用于线程同步的技术:
- 互斥锁(Mutexes)和锁(Locks),包括 `call_once()`
- 条件变量(Condition variables)
- 原子操作(Atomics)
#### 2. 并发数据访问的规则与风险
在深入探讨并发问题的细节之前,我们需要了解一个重要规则:多个线程在不同步的情况下并发访问相同数据的唯一安全方式是所有线程都只读取数据。这里的“相同数据”指的是使用相同内存位置的数据。自 C++11 起,除了位域(bitfield)外,每个变量都保证有自己的内存位置。但如果两个或更多线程并发访问相同的变量、对象或其成员,并且至少有一个线程进行修改,而不进行同步,就会陷入严重的麻烦,这在 C++ 中被称为数据竞争(data race)。
在 C++11 标准中,数据竞争被定义为“不同线程中的两个冲突操作,其中至少一个不是原子操作,且两者之间没有先后顺序”。数据竞争总是会导致未定义行为。
#### 3. 并发数据访问问题的原因
C++ 作为一种编程语言,是对不同平台和硬件的抽象,它规定了语句和操作的效果,而不是对应的汇编代码。因此,C++ 标准描述的是“做什么”,而不是“怎么做”。
一般来说,行为的定义并不精确到只有一种实现方式,甚至有些行为是未定义的。例如,函数调用中参数的计算顺序是未指定的。根据所谓的“as-if”规则,每个编译器都可以对代码进行优化,只要程序的外部可见行为保持不变。
为了给编译器和硬件足够的自由来优化代码,C++ 通常不会提供一些我们可能期望的保证。因为在所有情况下应用这些保证会导致性能损失过大。
#### 4. 并发数据访问可能出现的问题
在 C++ 中,并发数据访问可能会出现以下问题:
- **未同步的数据访问**:当两个并行运行的线程读写相同的数据时,无法确定哪个语句会先执行。例如以下代码:
```cpp
if (val >= 0) {
f(val);
}
else {
f(-val);
}
```
在单线程环境中,这段代码可以正常工作。但在多线程环境中,如果多个线程可以访问 `val`,`val` 的值可能在 `if` 语句和 `f()` 调用之间发生变化,从而导致传递给 `f()` 的是一个负值。
同样,以下代码也可能存在问题:
```cpp
std::vector<int> v;
if (!v.empty()) {
std::cout << v.front() << std::endl;
}
```
如果 `v` 在多个线程之间共享,在调用 `empty()` 和 `front()` 之间,`v` 可能会变为空,从而导致未定义行为。
需要注意的是,除非另有说明,C++ 标准库函数通常不支持对同一数据结构同时进行写操作或与写操作并发的读操作。不过,C++ 标准库也提供了一些关于线程安全的保证,例如:
- 可以并发访问同一容器的不同元素(除了 `vector<bool>`)。
- 并发访问字符串流、文件流或流缓冲区会导致未定义行为,但格式化输入输出到与 C I/O 同步的标准流是可能的,尽管可能会导致字符交错。
| 标准库情况 | 线程安全情况 |
| --- | --- |
| 并发访问同一容器不同元素(除 `vector<bool>`) | 可行 |
| 并发访问字符串流、文件流或流缓冲区 | 未定义行为 |
| 格式化输入输出到与 C I/O 同步的标准流 | 可能字符交错 |
- **半写入的数据**:当一个线程读取另一个线程正在修改的数据时,读取线程可能会在写入过程中读取数据,从而既不是旧值也不是新值。例如:
```cpp
long long x = 0;
// 线程 1
x = -1;
// 线程 2
std::cout << x;
```
线程 2 输出的 `x` 值可能是:
- 0(如果线程 1 还未赋值 -1)
- -1(如果线程 1 已经赋值 -1)
- 任何其他值(如果线程 2 在线程 1 赋值 -1 的过程中读取)
对于基本数据类型,如 `int` 或 `bool`,标准也不保证读写是原子操作。对于更复杂的数据结构,如 `std::list<>`,程序员需要确保在一个线程插入或删除元素时,其他线程不会修改它,否则可能会使用不一致的列表状态。
- **语句重排序**:语句和操作可能会被重排序,使得每个单线程的行为是正确的,但所有线程组合起来时,预期的行为会被破坏。例如:
```cpp
long data;
bool readyFlag = false;
// 提供数据的线程
data = 42;
readyFlag = true;
// 消费数据的线程
while (!readyFlag) {
;
}
foo(data);
```
虽然我们可能认为消费线程在 `data` 为 42 时才会调用 `foo()`,但实际上编译器和/或硬件可能会对语句进行重排序,使得实际执行的顺序变为:
```cpp
readyFlag = true;
data = 42;
```
这种重排序是允许的,因为 C++ 规则只要求生成代码在单个线程内的可观察行为是正确的。
#### 5. 解决并发数据访问问题的特性
为了解决并发数据访问的三个主要问题,我们需要以下概念:
- **原子性**:对变量或一系列语句的读写访问是排他的且无中断的,这样一个线程就不会读取到另一个线程造成的中间状态。
- **顺序**:需要一些方法来保证特定语句或一组特定语句的顺序。
C++ 标准库提供了不同的方法来处理这些概念,使程序在并发访问方面受益于额外的保证:
- **使用 futures 和 promises**:它们保证了原子性和顺序,设置共享状态的结果(返回值或异常)保证在处理该结果之前发生,这意味着读写访问不会同时发生。
- **使用互斥锁和锁**:用于处理临界区或受保护区域,通过阻塞所有使用第二个锁的访问,直到第一个锁在同一资源上被释放,从而提供原子性。
- **使用条件变量**:允许一个线程有效地等待另一个线程控制的某个谓词变为真,有助于处理多个线程的顺序。
- **使用原子数据类型**:确保对变量或对象的每次访问都是原子的,同时原子类型上的操作顺序保持稳定。
- **使用原子数据类型的低级接口**:允许专家放宽原子语句的顺序或使用手动内存访问屏障(所谓的 fences)。
这些特性从高级到低级排列,高级特性如 futures 和 promises 或互斥锁和锁易于使用且风险小,低级特性如原子操作及其低级接口可能提供更好的性能,但误用的风险显著增加。
#### 6. 互斥锁和锁的使用
互斥锁(Mutex)是一种帮助控制对资源并发访问的对象,通过提供对资源的排他访问来实现。要获得对资源的排他访问,相应的线程需要锁定互斥锁,这会阻止其他线程锁定该互斥锁,直到第一个线程解锁。
##### 6.1 使用互斥锁和锁的简单示例
假设我们要保护对一个对象 `val` 的并发访问:
```cpp
int val;
std::mutex valMutex;
// 线程 1
valMutex.lock();
if (val >= 0) {
f(val);
}
else {
f(-val);
}
valMutex.unlock();
// 线程 2
valMutex.lock();
++val;
valMutex.unlock();
```
所有可能进行并发访问的地方都必须使用相同的互斥锁,包括读写访问。但这种简单的方法可能会变得复杂,例如需要确保异常结束排他访问时也能解锁相应的互斥锁,否则资源可能会被永久锁定。
为了处理这些问题,C++ 标准库提供了 `std::lock_guard` 类,它遵循 RAII 原则(资源获取即初始化),在构造时锁定互斥锁,在析构时自动解锁:
```cpp
int val;
std::mutex valMutex;
std::lock_guard<std::mutex> lg(valMutex);
if (val >= 0) {
f(val);
}
else {
f(-val);
}
```
为了减少锁的持有时间,我们可以使用显式的花括号:
```cpp
{
std::lock_guard<std::mutex> lg(valMutex);
if (val >= 0) {
f(val);
}
else {
f(-val);
}
}
```
##### 6.2 一个完整的互斥锁和锁使用示例
```cpp
// concurrency/mutex1.cpp
#include <future>
#include <mutex>
#include <iostream>
#include <string>
std::mutex printMutex;
void print (const std::string& s)
{
std::lock_guard<std::mutex> l(printMutex);
for (char c : s) {
std::cout.put(c);
}
std::cout << std::endl;
}
int main()
{
auto f1 = std::async (std::launch::async,
print, "Hello from a first thread");
auto f2 = std::async (std::launch::async,
print, "Hello from a second thread");
print("Hello from the main thread");
}
```
如果没有锁,输出可能会出现字符交错的情况,使用锁后可以确保每个 `print()` 调用独占写入其字符。
##### 6.3 递归锁
有时需要递归锁定的能力,例如在包含互斥锁的活动对象或监视器中,每个公共方法都会获取锁以保护数据竞争。但如果一个公共成员函数调用另一个也获取相同锁的公共成员函数,可能会导致死锁。
使用 `std::recursive_mutex` 可以解决这个问题,它允许同一线程多次锁定,并在最后一次对应的 `unlock()` 调用时释放锁。例如:
```cpp
class DatabaseAccess
{
private:
std::recursive_mutex dbMutex;
public:
void insertData (...)
{
std::lock_guard<std::recursive_mutex> lg(dbMutex);
// ...
}
void createTableAndinsertData (...)
{
std::lock_gua
```
0
0
复制全文
相关推荐










