# 多线程数据竞争?一文搞定互斥锁与原子操作
在多线程编程中,数据竞争(Data Race)是最常见也最难排查的问题之一。当多个线程同时访问同一内存位置,且至少有一个线程在执行写操作时,程序的行为就变得不可预测。轻则计算结果错误,重则程序崩溃。本文将深入剖析数据竞争的根源,并详细介绍互斥锁和原子操作两种解决方案,帮助读者写出线程安全的代码。
## 一、数据竞争的本质:并发访问失控
### 1.1 一个典型的数据竞争示例
考虑下面这段看似简单的代码:
```c
#include
#include
#define LOOP_COUNT 1000000
int counter = 0;
void* thread_func(void* arg) {
for (int i = 0; i < LOOP_COUNT; i++) {
counter++; // 看似简单的自增操作
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("期望值: %d, 实际值: %d\n", 2 * LOOP_COUNT, counter);
return 0;
}
```
运行这段程序,你会发现实际值往往小于期望值,而且每次运行结果都不同。原因在于`counter++`并不是原子操作,它在底层对应着三条指令:
```assembly
mov eax, [counter] ; 1. 从内存读取到寄存器
add eax, 1 ; 2. 寄存器加1
mov [counter], eax ; 3. 写回内存
```
当两个线程同时执行这三条指令时,可能发生以下交错:
```
线程1: 读取counter=0 → 寄存器=0
线程2: 读取counter=0 → 寄存器=0
线程1: 寄存器加1 → 1
线程2: 寄存器加1 → 1
线程1: 写回counter=1
线程2: 写回counter=1
```
两次自增操作,counter只增加了1,这就是数据竞争导致的更新丢失。
### 1.2 数据竞争的条件
数据竞争的发生需要同时满足三个条件:
1. **多个线程**:至少两个线程并发执行
2. **共享内存**:线程访问同一内存位置
3. **非原子操作**:至少一个线程执行写操作,且操作不是原子的
## 二、互斥锁:保护临界区的经典方案
### 2.1 互斥锁的基本原理
互斥锁(Mutex)通过确保同一时间只有一个线程进入临界区,从根本上避免了数据竞争。POSIX线程库提供了标准的互斥锁接口。
### 2.2 使用互斥锁解决数据竞争
```c
#include
#include
#define LOOP_COUNT 1000000
int counter = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void* thread_func(void* arg) {
for (int i = 0; i < LOOP_COUNT; i++) {
pthread_mutex_lock(&mutex);
counter++;
pthread_mutex_unlock(&mutex);
}
return NULL;
}<"g3.s6k3.org.cn"><"q7.s6k3.org.cn"><"x3.s6k3.org.cn">
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("期望值: %d, 实际值: %d\n", 2 * LOOP_COUNT, counter);
pthread_mutex_destroy(&mutex);
return 0;
}
```
现在程序每次运行都会得到正确结果:2000000。
### 2.3 互斥锁的使用要点
**加锁粒度控制**:
- 细粒度锁:只保护关键操作,提高并发度
- 粗粒度锁:保护整个代码段,简化逻辑,但可能降低性能
**避免死锁**:
```c
// 错误示例:可能死锁
void* thread1(void* arg) {
pthread_mutex_lock(&mutex_a);
pthread_mutex_lock(&mutex_b);
// 操作
pthread_mutex_unlock(&mutex_b);
pthread_mutex_unlock(&mutex_a);
}
void* thread2(void* arg) {
pthread_mutex_lock(&mutex_b);
pthread_mutex_lock(&mutex_a); // 若thread1已锁mutex_a,则死锁
// 操作
pthread_mutex_unlock(&mutex_a);
pthread_mutex_unlock(&mutex_b);
}
```
解决方案:始终按相同顺序加锁,或使用`pthread_mutex_trylock`。
**RAII风格封装**(C++):
```cpp
#include
std::mutex mtx;
int counter = 0;
void increment() {
std::lock_guard
counter++;
}
```
## 三、原子操作:更轻量的选择
### 3.1 什么是原子操作
原子操作是指在执行过程中不会被其他线程打断的操作。现代CPU提供了对基本数据类型(整型、指针等)的原子读写指令。
### 3.2 C11原子操作
C11标准引入了原子类型和操作,位于`
```c
#include
#include
#include
#define LOOP_COUNT 1000000
atomic_int counter = 0; // 原子类型
void* thread_func(void* arg) {
for (int i = 0; i < LOOP_COUNT; i++) {
atomic_fetch_add(&counter, 1); // 原子自增
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, thread_func, NULL);
pthread_create(&t2, NULL, thread_func, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("期望值: %d, 实际值: %d\n", 2 * LOOP_COUNT, counter);
return 0;
}
```
### 3.3 GCC内置原子操作
GCC提供了一组内置原子函数:
```c
int counter = 0;
void* thread_func(void* arg) {
for (int i = 0; i < LOOP_COUNT; i++) {
__sync_fetch_and_add(&counter, 1);
}
return NULL;
}
```
或使用更现代的C11原子操作:
```c
__atomic_fetch_add(&counter, 1, __ATOMIC_SEQ_CST);
```
### 3.4 C++11原子库
C++提供了更丰富的原子支持:
```cpp
#include
#include
#include
std::atomic
const int LOOP_COUNT = 1000000;
void increment() {
for (int i = 0; i < LOOP_COUNT; i++) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "期望值: " << 2 * LOOP_COUNT
<< ", 实际值: " << counter << std::endl;
return 0;
}
```
## 四、互斥锁 vs 原子操作:如何选择
### 4.1 性能对比
在x86_64平台上进行简单测试:
| 操作 | 耗时(相对值) |
|------|---------------|
| 普通自增 | 1x |
| 互斥锁自增 | 20-50x |
| 原子自增 | 3-10x |
原子操作通常比互斥锁快一个数量级,但当竞争激烈时,原子操作可能因忙等待而导致CPU使用率升高。
### 4.2 适用场景对比
| 场景 | 互斥锁 | 原子操作 |
|------|--------|----------|
| 简单计数器 | 不适合 | 非常适合 |
| 复杂数据结构 | 适合 | 不适合 |
| 临界区代码量大 | 适合 | 不适合 |
| 需要等待条件 | 适合 | 无法实现 |
| 低竞争场景 | 可用 | 适合 |
| 高竞争场景 | 适合(线程阻塞) | 不适合(忙等待)|
### 4.3 组合使用
实际项目中常将两者结合使用:
```c
#include
#include
typedef struct {
atomic_int ref_count; // 引用计数(原子操作)
pthread_mutex_t lock; // 保护内部数据的锁
void* data;
} shared_resource_t;
<"b7.s6k3.org.cn"><"j3.s6k3.org.cn"><"n7.s6k3.org.cn">
void* acquire(shared_resource_t* res) {
atomic_fetch_add(&res->ref_count, 1);
return res->data;
}
void release(shared_resource_t* res) {
if (atomic_fetch_sub(&res->ref_count, 1) == 1) {
pthread_mutex_destroy(&res->lock);
free(res->data);
free(res);
}
}
```
## 五、常见陷阱与注意事项
### 5.1 编译器和CPU重排
编译器和CPU可能为了优化而重排指令顺序,这在单线程下没问题,但在多线程环境下可能导致意外结果。
```c
// 可能被重排
x = 1;
flag = true;
// 另一个线程可能看到flag=true但x=0
```
使用内存屏障或原子操作的适当内存序来避免:
```c
atomic_store(&x, 1, memory_order_release);
atomic_store(&flag, true, memory_order_release);
// 另一个线程
if (atomic_load(&flag, memory_order_acquire)) {
int val = atomic_load(&x, memory_order_acquire);
}
```
### 5.2 ABA问题
使用原子CAS(Compare-And-Swap)操作时需注意ABA问题:
```c
// 线程1读取值为A
// 线程2将A改为B,又改回A
// 线程1CAS成功,但数据结构已变
```
解决方案:使用带版本号的原子指针(如C++的`std::atomic
### 5.3 锁的粒度陷阱
```c
// 粒度过粗:不必要的等待
void process_batch() {
lock();
for (int i = 0; i < 1000; i++) {
// 只修改不同元素,本可并行
data[i]++;
}
unlock();
}
// 改进:细粒度或原子操作
void process_batch() {
for (int i = 0; i < 1000; i++) {
atomic_fetch_add(&data[i], 1);
}
}
```
## 六、总结
数据竞争是多线程编程的核心挑战,解决方案主要有两条路径:
1. **互斥锁**:通过阻塞其他线程来保证独占访问,适合保护复杂操作和长临界区,但开销较大
2. **原子操作**:通过硬件指令保证操作的不可分割性,适合简单数据类型的操作,性能更好但适用范围有限
在实际开发中,应根据场景选择合适的同步机制:
- 简单计数器、标志位:优先使用原子操作
- 复杂数据结构、需等待条件:使用互斥锁
- 读写比例悬殊:考虑读写锁
- 无锁数据结构:结合原子CAS和内存序,但需谨慎设计
理解并掌握这些同步机制,是写出正确、高效多线程程序的必经之路。