多线程系统如何避免并发访问带来的数据竞争

目录

描述

汇总比较

Demo示例

信号量

互斥锁

读写锁

原子操作类模板(std::atomic)

条件变量

内存屏障(std::atomic/std::atomic_thread_fence)

volatile 关键字(C++中无控制并发访问的效果)

屏障(std::barrier)


多线程系统数据访问与操作,经常存在并发访问的问题,那如何避免数据竞争呢?有那些手段呢?

这些手段之间孰优孰劣呢?本篇进行汇总。

描述

避免数据竞争是多线程编程中确保数据一致性和程序正确性的关键。以下是一些常用的手段和技术:

  1. 互斥锁(Mutexes): 使用互斥锁来保护共享数据,确保在同一时间只有一个线程可以访问共享资源。

  2. 读写锁(Read-Write Locks): 允许多个读操作同时进行,但写操作是独占的,这可以提高并发性能。

  3. 原子操作类模板(Atomic Operations): 使用原子变量和原子操作来保证某些操作(如计数器的增加或减少)是不可分割的。

  4. 条件变量(Condition Variables): 用于线程间的同步,允许线程在特定条件不满足时挂起,并在条件满足时被唤醒。

  5. 信号量(Semaphores): 控制对共享资源的访问数量,防止超过最大限制的线程同时访问资源。

  6. 屏障(Barriers): 确保所有线程在继续执行前都达到了某个点,通常用于分阶段的并行计算。

  7. 内存屏障(Memory Barriers): 确保内存操作的顺序,防止编译器和处理器对读写操作进行重排。

  8. volatile 关键字: 在某些语言中(C++中不可以!),volatile 关键字可以防止编译器优化,确保变量的更改对所有线程立即可见。

  9. 线程局部存储(Thread-Local Storage): 使用线程局部变量来避免共享数据,每个线程都有自己的变量副本。

  10. 无锁编程(Lock-Free Programming): 设计算法和数据结构,使得线程不需要使用锁就可以安全地访问共享数据。

  11. 顺序一致性(Sequential Consistency): 确保操作按照特定的顺序执行,避免因内存模型的不确定性导致的数据竞争。

  12. 避免共享(Minimizing Sharing): 尽可能减少或消除共享状态,每个线程操作自己的数据副本。

  13. 正确同步(Proper Synchronization): 确保所有同步机制都正确实现,包括锁的获取和释放顺序,以及避免死锁。

  14. 代码审查和静态分析: 通过代码审查和静态分析工具来检查潜在的并发问题。

汇总比较

类别手段适用场景
主流应用互斥锁保护临界区,同一时间只有一个线程可以访问一个共享资源
读写锁允许多个读操作同时进行,但写操作是独占的“读多写少”的场景
信号量不仅可以用于互斥,还可以用于控制对一定数量的共享资源的访问
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. 初始化:在程序开始时,初始化信号量。对于二进制信号量,通常初始化为1,表示资源初始时是可用的。对于计数信号量,初始化时设置为资源的可用数量。

  2. 等待(Wait)操作:当线程想要访问共享资源时,它首先执行等待操作(通常称为 P 操作或 sem_wait)。这个操作会检查信号量的计数器:

    • 如果计数器大于0,它将计数器减1,表示资源已被占用,然后线程可以继续执行。
    • 如果计数器为0,线程将被阻塞,直到其他线程释放资源(执行信号量的释放操作)。
  3. 执行:线程在获取资源后执行其任务。

  4. 释放(Signal)操作:线程完成对共享资源的访问后,执行释放操作(通常称为 V 操作或 sem_post)。这个操作将信号量的计数器加1,表示资源已被释放,其他等待的线程可以继续执行。

  5. 销毁:在程序结束时,销毁信号量,释放相关资源。

#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;
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值