目录
内存屏障(std::atomic/std::atomic_thread_fence)
多线程系统数据访问与操作,经常存在并发访问的问题,那如何避免数据竞争呢?有那些手段呢?
这些手段之间孰优孰劣呢?本篇进行汇总。
描述
避免数据竞争是多线程编程中确保数据一致性和程序正确性的关键。以下是一些常用的手段和技术:
-
互斥锁(Mutexes): 使用互斥锁来保护共享数据,确保在同一时间只有一个线程可以访问共享资源。
-
读写锁(Read-Write Locks): 允许多个读操作同时进行,但写操作是独占的,这可以提高并发性能。
-
原子操作类模板(Atomic Operations): 使用原子变量和原子操作来保证某些操作(如计数器的增加或减少)是不可分割的。
-
条件变量(Condition Variables): 用于线程间的同步,允许线程在特定条件不满足时挂起,并在条件满足时被唤醒。
-
信号量(Semaphores): 控制对共享资源的访问数量,防止超过最大限制的线程同时访问资源。
-
屏障(Barriers): 确保所有线程在继续执行前都达到了某个点,通常用于分阶段的并行计算。
-
内存屏障(Memory Barriers): 确保内存操作的顺序,防止编译器和处理器对读写操作进行重排。
-
volatile 关键字: 在某些语言中(C++中不可以!),
volatile
关键字可以防止编译器优化,确保变量的更改对所有线程立即可见。 -
线程局部存储(Thread-Local Storage): 使用线程局部变量来避免共享数据,每个线程都有自己的变量副本。
-
无锁编程(Lock-Free Programming): 设计算法和数据结构,使得线程不需要使用锁就可以安全地访问共享数据。
-
顺序一致性(Sequential Consistency): 确保操作按照特定的顺序执行,避免因内存模型的不确定性导致的数据竞争。
-
避免共享(Minimizing Sharing): 尽可能减少或消除共享状态,每个线程操作自己的数据副本。
-
正确同步(Proper Synchronization): 确保所有同步机制都正确实现,包括锁的获取和释放顺序,以及避免死锁。
-
代码审查和静态分析: 通过代码审查和静态分析工具来检查潜在的并发问题。
汇总比较
类别 | 手段 | 适用场景 |
主流应用 | 互斥锁 | 保护临界区,同一时间只有一个线程可以访问一个共享资源 |
读写锁 | 允许多个读操作同时进行,但写操作是独占的“读多写少”的场景 | |
信号量 | 不仅可以用于互斥,还可以用于控制对一定数量的共享资源的访问 | |
C++标准库 | 原子操作模版 | 一种线程安全的方式来访问和修改共享数据,而无需使用显式的互斥锁 |
内存屏障 | 内存屏障是一种同步机制,用于防止编译器和CPU对内存操作进行重排序 | |
特殊适配 | volatile 关键字 | 在C++中,volatile 关键字并不能防止线程并发访问问题。volatile 的主要用途是告诉编译器,每当访问一个 volatile 变量时,都必须从内存中重新读取它的值,而不是使用缓存在寄存器中的值。这通常用于与硬件设备交互的情况,确保程序能够看到硬件设备的最新状态。 |
屏障 | 屏障是一种线程协调机制,它用于让一组线程在某个点上同步。当所有线程都到达屏障时,它们会被阻塞直到某个条件满足,然后才会继续执行 | |
设计思路 | 线程局部存储 | 编码设计阶段的思路解决,依赖特殊数据结构的封装和编码设计,本文不做过多探究 |
无锁编程 | ||
顺序一致性 | ||
避免共享 | ||
正确同步 | ||
代码审查和静态分析 |
Demo示例
示例代码,模拟从同一个账户中进行存款和扣款的操作,来表述用不同手段来防止数据竞争的举例
int account_balance;
// 从账户余额中扣款
void debit(int amount)
{
account_balance -= amount;
}
// 给账户余额中存款
void credit(int amount)
{
account_balance += amount;
}
信号量
信号量(Semaphore)是一种用于控制对共享资源并发访问的同步机制。它通过维护一个计数器来跟踪资源可用的实例数量,从而确保在任何时候只有有限数量的线程可以访问共享资源。
信号量限制数据并发访问的机制通常包括以下几个步骤:
-
初始化:在程序开始时,初始化信号量。对于二进制信号量,通常初始化为1,表示资源初始时是可用的。对于计数信号量,初始化时设置为资源的可用数量。
-
等待(Wait)操作:当线程想要访问共享资源时,它首先执行等待操作(通常称为
P
操作或sem_wait
)。这个操作会检查信号量的计数器:- 如果计数器大于0,它将计数器减1,表示资源已被占用,然后线程可以继续执行。
- 如果计数器为0,线程将被阻塞,直到其他线程释放资源(执行信号量的释放操作)。
-
执行:线程在获取资源后执行其任务。
-
释放(Signal)操作:线程完成对共享资源的访问后,执行释放操作(通常称为
V
操作或sem_post
)。这个操作将信号量的计数器加1,表示资源已被释放,其他等待的线程可以继续执行。 -
销毁:在程序结束时,销毁信号量,释放相关资源。
#include <semaphore.h>
// 定义一个信号量
sem_t balance_lock;
// 初始化信号量
void init_semaphore() {
//sem_init-参数2:初始为0,表该信号量只在初始化它的线程所在的进程内使用,不被其他进程共享
//sem_init-参数3:初始计数为1,表示该信号量仅允许一个线程访问
sem_init(&balance_lock, 0, 1);
}
// 销毁信号量
void destroy_semaphore() {
sem_destroy(&balance_lock);
}
// 从账户余额中扣款
void debit(int amount) {
// 等待信号量,减少信号量计数
sem_wait(&balance_lock);
// 临界区开始
account_balance -= amount;
// 临界区结束
// 释放信号量,增加信号量计数
sem_post(&balance_lock);
}
// 向账户余额中存款
void credit(int amount) {
// 等待信号量,减少信号量计数
sem_wait(&balance_lock);
// 临界区开始
account_balance += amount;
// 临界区结束
// 释放信号量,增加信号量计数
sem_post(&balance_lock);
}
int main() {
// 初始化账户余额和信号量
int account_balance = 0;
init_semaphore();
// 创建线程进行扣款和存款操作
// ...
// 销毁信号量
destroy_semaphore();
return 0;
}
互斥锁
主要用于保护临界区,确保在同一时间只有一个线程可以访问共享资源,通常是一个二进制信号量,计数器只有两个状态:0(锁定)和1(未锁定)
#include <pthread.h>
// 定义一个互斥锁
pthread_mutex_t balance_lock;
// 初始化互斥锁
void initialize_lock() {
pthread_mutex_init(&balance_lock, NULL);
}
// 销毁互斥锁
void destroy_lock() {
pthread_mutex_destroy(&balance_lock);
}
// 从账户余额中扣款
void debit(int amount) {
// 加锁
pthread_mutex_lock(&balance_lock);
account_balance -= amount;
// 解锁
pthread_mutex_unlock(&balance_lock);
}
// 向账户余额中存款
void credit(int amount) {
// 加锁
pthread_mutex_lock(&balance_lock);
account_balance += amount;
// 解锁
pthread_mutex_unlock(&balance_lock);
}
int main() {
// 初始化账户余额和互斥锁
int account_balance = 0;
initialize_lock();
// 创建线程进行扣款和存款操作
// ...
// 销毁互斥锁
destroy_lock();
return 0;
}
读写锁
允许多个读操作同时进行,但写操作是独占的。这使得在读操作频繁而写操作较少的场景中,可以提高并发性能。适用于读操作远多于写操作的场景,可以提高读操作的并发性。
PS:存在死锁风险,尤其是在写者试图获取锁时,如果其他写者或读者持有锁,可能会导致死锁。
#include <shared_mutex>
#include <iostream>
// 定义一个读写锁
std::shared_mutex account_lock;
// 定义账户余额
int account_balance = 0;
// 扣款函数,需要写锁
void debit(int amount) {
std::unique_lock<std::shared_mutex> lock(account_lock);
account_balance -= amount;
std::cout << "Debit: " << amount << ", Balance: " << account_balance << std::endl;
}
// 存款函数,需要写锁
void credit(int amount) {
std::unique_lock<std::shared_mutex> lock(account_lock);
account_balance += amount;
std::cout << "Credit: " << amount << ", Balance: " << account_balance << std::endl;
}
// 查询余额函数,需要读锁
int get_balance() {
std::shared_lock<std::shared_mutex> lock(account_lock);
return account_balance;
}
int main() {
// 测试代码
debit(50);
credit(100);
std::cout << "Final Balance: " << get_balance() << std::endl;
return 0;
}
原子操作类模板(std::atomic
)
使用原子操作类模板(std::atomic),
在C++中,std::atomic
是用于多线程编程的原子操作类模板,它提供了一种线程安全的方式来访问和修改共享数据,而无需使用显式的互斥锁。std::atomic<int>
是std::atomic
的一个特化,用于操作整数类型的原子变量。
#include <threads.h>
static int account_balance;
static mtx_t account_lock;
int debit(int amount) {
if (mtx_lock(&account_lock) == thrd_error) {
return -1; // Indicate error to caller
}
account_balance -= amount;
if (mtx_unlock(&account_lock) == thrd_error) {
return -1; // Indicate error to caller
}
return 0; // Indicate success
}
int credit(int amount) {
if (mtx_lock(&account_lock) == thrd_error) {
return -1; // Indicate error to caller
}
account_balance += amount;
if (mtx_unlock(&account_lock) == thrd_error) {
return -1; // Indicate error to caller
}
return 0; // Indicate success
}
int main(void) {
if (mtx_init(&account_lock, mtx_plain) == thrd_error) {
// Handle error
}
// ...
}
条件变量
为了避免数据竞争并确保对共享变量 account_balance
的安全访问,可以使用互斥锁(mutex)结合条件变量(condition variable)来实现。条件变量允许线程在某个条件未满足时挂起,并在条件满足时被唤醒。
本处定义一个互斥锁 balance_mutex
和一个条件变量 balance_cond(布尔变量,用于阻塞线程直到某个条件为真
)
。在 debit
函数中,首先获取互斥锁,然后检查账户余额是否充足。如果余额不足,线程将等待条件变量。在 credit
函数中,我们增加账户余额,并在修改余额后发出条件变量的信号,以唤醒可能在等待的线程。
条件变量通常与互斥锁一起使用,以确保在等待和通知过程中数据的一致性和线程安全。在使用条件变量时,必须先获取互斥锁,以防止数据竞争。此外,确保在适当的时候释放互斥锁,以避免死锁。
在实际应用中,条件变量通常用于更复杂的同步场景,其中线程需要等待特定的条件发生,而不仅仅是简单的读/写操作。
#include <pthread.h>
#include <stdio.h>
// 定义一个互斥锁和一个条件变量
pthread_mutex_t balance_mutex;
pthread_cond_t balance_cond;
int account_balance;
void debit(int amount) {
pthread_mutex_lock(&balance_mutex);
// 检查余额是否充足
while (account_balance < amount) {
// 等待条件变量,直到余额充足
pthread_cond_wait(&balance_cond, &balance_mutex);
}
account_balance -= amount;
printf("Debit: %d, New Balance: %d\n", amount, account_balance);
pthread_mutex_unlock(&balance_mutex);
}
void credit(int amount) {
pthread_mutex_lock(&balance_mutex);
account_balance += amount;
printf("Credit: %d, New Balance: %d\n", amount, account_balance);
// 通知可能在等待的线程
pthread_cond_signal(&balance_cond);
pthread_mutex_unlock(&balance_mutex);
}
int main() {
// 初始化互斥锁和条件变量
pthread_mutex_init(&balance_mutex, NULL);
pthread_cond_init(&balance_cond, NULL);
// 初始化账户余额
account_balance = 0;
// 创建线程进行扣款和存款操作
// ...
// 销毁互斥锁和条件变量
pthread_mutex_destroy(&balance_mutex);
pthread_cond_destroy(&balance_cond);
return 0;
}
内存屏障(std::atomic/std::atomic_thread_fence
)
在多线程环境中,为了避免数据竞争,确保对共享变量 account_balance
的访问是原子性和可见性的,可以使用内存屏障来确保操作的顺序和可见性。
- 内存屏障是一种同步机制,用于防止编译器和CPU对内存操作进行重排序。现代CPU为了提高执行效率,可能会对指令进行乱序执行,这可能会导致在多线程环境中出现不可预测的行为。内存屏障确保在屏障之前的内存操作完成后,才会执行屏障之后的内存操作,从而维护内存操作的顺序一致性。
- 内存屏障通常分为编译器屏障和CPU内存屏障。编译器屏障防止编译器重排序,而CPU内存屏障防止CPU重排序。
- 内存屏障的种类包括LoadLoad屏障、StoreStore屏障、LoadStore屏障和StoreLoad屏障,它们分别对不同的内存访问顺序提供保证。
- 在C++中,内存屏障可以通过
std::atomic_thread_fence
函数来显式设置,或者在使用std::atomic
变量时隐式使用,以确保操作的内存顺序。
在C++中,可以通过 std::atomic
类型和 std::atomic_thread_fence
函数来实现内存屏障。
#include <atomic>
#include <thread>
std::atomic<int> account_balance{0};
void debit(int amount) {
// 使用内存屏障确保在减去金额之前,所有之前的写操作都已完成
std::atomic_thread_fence(std::memory_order_release);
account_balance -= amount;
// 这里可以添加一个内存屏障,确保减去金额的操作对其他线程可见
std::atomic_thread_fence(std::memory_order_seq_cst);
}
void credit(int amount) {
// 使用内存屏障确保在加上金额之前,所有之前的写操作都已完成
std::atomic_thread_fence(std::memory_order_release);
account_balance += amount;
// 这里可以添加一个内存屏障,确保加上金额的操作对其他线程可见
std::atomic_thread_fence(std::memory_order_seq_cst);
}
volatile
关键字(C++中无控制并发访问的效果)
在C++中,volatile
关键字并不能防止线程并发访问问题(作者本人主要专攻C++,其他语言不太熟悉,暂不讨论)。
volatile
的主要用途是告诉编译器,每当访问一个 volatile
变量时,都必须从内存中重新读取它的值,而不是使用缓存在寄存器中的值。这通常用于与硬件设备交互的情况,确保程序能够看到硬件设备的最新状态。
volatile
并不能保证操作的原子性,也就是说,它不能保证一个操作序列在多线程环境中是不可分割的。
static volatile int account_balance;
void debit(int amount)
{
account_balance ‐= amount;
}
在本例中,static volatile int account_balance;
这样的声明会告诉编译器 account_balance
是一个易变的变量,每次访问它时都需要从内存中重新读取,而不是使用寄存器中的值。这种声明通常用于确保编译器不会对访问该变量的代码进行优化,这在某些嵌入式编程场景中可能是必要的,但在多线程环境中,它并不足以保证线程安全。
屏障(std::barrier
)
C++标准库中的std::barrier
是在C++20中引入的,使用前确保编译器支持C++20或更高版本。
在多线程编程中,屏障(barriers)通常用于控制一组线程的执行顺序,确保所有线程在继续执行之前都达到了某个点。屏障主要用于同步线程的相对进度,而不是用来保护共享数据的访问。因此,屏障本身并不提供对共享变量访问的互斥性。
在本例中,account_balance
是一个共享资源,多个线程可能会同时对其进行读写操作。为了避免数据竞争,需要确保在任何时刻只有一个线程能够修改 account_balance
。这通常通过互斥锁(mutex)或原子操作(atomic operations)来实现,而不是通过屏障。
在C++中,使用屏障(barrier)通常是为了确保一组线程在继续执行之前都已经到达了某个同步点。这在某些并行算法中非常有用,比如在进行迭代计算时,每个迭代步骤结束之后,所有线程都需要在屏障处等待,直到所有线程都完成了当前步骤的工作,然后才能一起进入下一个迭代步骤。
故举另外一个例子,来进行屏障的使用说明
#include <iostream>
#include <vector>
#include <thread>
#include <barrier>
#include <atomic>
std::atomic<int> shared_counter{0}; // 共享计数器
std::barrier<> sync_barrier(4); // 假设我们有4个线程
void worker(int id) {
for (int i = 0; i < 10; ++i) {
// 模拟一些工作
++shared_counter;
// 到达屏障,等待所有线程
sync_barrier.arrive_and_wait();
// 所有线程都到达屏障后,可以安全地打印信息
std::cout << "Thread " << id << " passed barrier in iteration " << i << std::endl;
}
}
int main() {
std::vector<std::thread> threads;
// 创建4个线程
for (int i = 0; i < 4; ++i) {
threads.emplace_back(worker, i);
}
// 等待所有线程完成
for (auto& thread : threads) {
thread.join();
}
std::cout << "Final shared counter value: " << shared_counter.load() << std::endl;
return 0;
}