多线程数据竞争?一文搞定互斥锁与原子操作

# 多线程数据竞争?一文搞定互斥锁与原子操作


在多线程编程中,数据竞争(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 lock(mtx);  // 自动加锁,析构时自动解锁

    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 counter(0);

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和内存序,但需谨慎设计


理解并掌握这些同步机制,是写出正确、高效多线程程序的必经之路。


请使用浏览器的分享功能分享到微信等