【C++基础】多线程并发场景下的同步方法

如果在多线程程序中对全局变量的访问没有进行适当的同步控制(例如使用互斥锁、原子变量等),会导致多个线程同时访问和修改全局变量时发生竞态条件 (race condition)。这种竞态条件可能会导致一系列不确定和严重的后果。

在C++中,可以通过使用互斥锁(mutex)、原子操作、读写锁来实现对全局变量的互斥访问。

一、缺乏同步控制造成的后果

1. 数据竞争(Data Race)

数据竞争发生在多个线程同时访问同一个变量,并且至少有一个线程在写该变量时没有进行同步。由于缺少同步机制,多个线程对全局变量的操作可能会相互干扰,导致变量的值不可预测。

示例:

cpp 复制代码
#include <iostream>
#include <thread>

int globalVar = 0;

void increment() {
    globalVar++;
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();

    std::cout << "Global variable: " << globalVar << std::endl;
    return 0;
}
后果:
  • 上面的代码中,globalVar++ 并不是一个原子操作。它由多个步骤组成:读取值、增加值、写回。在这段代码中,t1t2 可能会同时读取globalVar的值,导致两个线程同时修改它的值,最终的结果会小于预期的2。这就是典型的数据竞争。

2. 不一致的状态(Inconsistent State)

在没有同步控制的情况下,多个线程可能会对全局变量进行同时读写操作,导致变量处于不一致的状态。例如,多个线程可能会同时读取和修改相同的变量,导致最终状态不符合预期。

示例: 假设你有一个程序要求维护一个全局的计数器。如果没有加锁来确保线程安全,两个线程同时执行时,计数器可能会被写成一个无意义的值。

cpp 复制代码
#include <iostream>
#include <thread>

int counter = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter++;  // 非线程安全操作
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << std::endl;
    return 0;
}
后果:
  • 在没有同步的情况下,counter++ 可能会导致多个线程在同一时刻读取到相同的计数器值,并同时将相同的更新值写回变量,这会使得counter的最终值远小于预期的200000
  • 这可能会导致程序的业务逻辑错误,特别是如果全局变量用作关键状态的标识。

3. 崩溃或程序未定义行为

由于数据竞争或者不一致的状态,程序可能会进入一个不可预测的状态,导致崩溃。全局变量的值在多线程的竞争中可能会发生损坏,从而导致未定义的行为(undefined behavior)。

例如:

  • 访问已释放内存:一个线程修改了全局变量并释放了相关内存,但其他线程仍然试图访问该内存。
  • 内存覆盖:多个线程同时修改全局变量,导致不同线程的操作互相覆盖,从而引发崩溃。

二、互斥锁std::mutex实现同步

std::mutex 是C++标准库中的一种机制,用于避免多个线程同时访问同一个资源(如全局变量)时发生竞争条件。

下面是一个示例,展示了如何使用std::mutex来保护全局变量:

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;  // 定义全局互斥锁
int globalVar = 0;  // 定义全局变量

void threadFunction() {
    std::lock_guard<std::mutex> lock(mtx);  // 上锁,确保互斥
    // 访问和修改全局变量
    ++globalVar;
    std::cout << "Global variable: " << globalVar << std::endl;
    // 锁会在lock_guard离开作用域时自动释放
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);

    t1.join();
    t2.join();

    return 0;
}

说明:

  1. std::mutex: 用于保护共享资源(如全局变量)。
  2. std::lock_guard<std::mutex>: 是一个RAII风格的封装器,它在构造时自动上锁,在析构时自动解锁,确保了线程安全。
  3. threadFunction中,每个线程在访问globalVar之前都会先获得互斥锁,这样就能确保线程之间不会同时访问和修改全局变量。

使用std::mutex可以防止不同线程之间因竞争访问全局变量而引发的错误或不一致问题。

有时如果你需要更细粒度的控制,还可以考虑使用std::unique_lock,它比std::lock_guard更灵活,允许手动控制锁的获取和释放。

三、独占锁std::unique_lock实现同步

std::unique_lock 是 C++11 标准库中的一种互斥锁包装器,它提供了比 std::lock_guard 更灵活的锁管理方式。std::unique_lock 允许手动控制锁的获取和释放,而不仅仅是在对象生命周期结束时自动释放锁(如 std::lock_guard 所做的那样)。这使得它比 std::lock_guard 更加灵活,适用于更复杂的场景,比如需要在同一作用域内多次锁定或解锁,或者需要在锁定期间进行一些其他操作。

std::unique_lock 的关键特性:

  1. 手动控制锁的获取和释放std::unique_lock 支持手动解锁和重新锁定,它比 std::lock_guard 更加灵活。
  2. 延迟锁定和提前解锁:你可以选择在对象创建时延迟锁定,或者在锁定后手动释放锁。
  3. 支持条件变量std::unique_lock 支持与条件变量一起使用,这是 std::lock_guard 无法做到的。

基本用法:

1. 构造时自动加锁

std::unique_lock 默认会在构造时自动加锁。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;

void threadFunction() {
    std::unique_lock<std::mutex> lock(mtx);  // 构造时自动上锁
    std::cout << "Thread is running\n";
    // 临界区的操作
    // 锁会在 lock 对象超出作用域时自动释放
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);

    t1.join();
    t2.join();

    return 0;
}
2. 手动解锁与重新加锁

std::unique_lock 允许你在锁定期间手动解锁和重新加锁,这对于一些需要临时释放锁的场景非常有用。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;

void threadFunction() {
    std::unique_lock<std::mutex> lock(mtx);  // 构造时自动上锁
    std::cout << "Thread is running\n";
    
    // 临界区的操作
    lock.unlock();  // 手动解锁
    
    std::cout << "Lock released temporarily\n";
    
    // 临界区之外的操作
    
    lock.lock();  // 重新加锁
    
    std::cout << "Lock acquired again\n";
    // 临界区操作继续进行
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);

    t1.join();
    t2.join();

    return 0;
}
3. 延迟锁定

std::unique_lock 也允许你延迟锁定,通过传递一个 std::defer_lock 参数给构造函数来实现。这会创建一个未锁定的 std::unique_lock,你可以在稍后手动调用 lock() 来加锁。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>

std::mutex mtx;

void threadFunction() {
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);  // 延迟加锁
    std::cout << "Thread is preparing to run\n";
    
    // 做一些不需要加锁的操作
    
    lock.lock();  // 手动加锁
    std::cout << "Thread is running under lock\n";
    
    // 临界区的操作
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);

    t1.join();
    t2.join();

    return 0;
}
4. 条件变量

std::unique_lock 是与条件变量一起使用的理想选择,它支持对互斥锁的手动解锁和重新加锁。这在条件变量的使用场景中非常有用,因为在等待条件时需要解锁互斥锁,而在条件满足时重新加锁。

cpp 复制代码
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void threadFunction() {
    std::unique_lock<std::mutex> lock(mtx);  // 上锁
    while (!ready) {  // 等待 ready 为 true
        cv.wait(lock);  // 等待,自动解锁并挂起线程
    }
    std::cout << "Thread is running\n";
}

void notify() {
    std::this_thread::sleep_for(std::chrono::seconds(1));  // 模拟一些操作
    std::cout << "Notifying the threads\n";
    std::unique_lock<std::mutex> lock(mtx);  // 上锁
    ready = true;
    cv.notify_all();  // 通知所有线程
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);

    std::thread notifier(notify);
    
    t1.join();
    t2.join();
    notifier.join();

    return 0;
}

解释:

  1. std::condition_variablestd::unique_lock

    • threadFunction 中,cv.wait(lock) 会释放锁并等待条件变量的通知。
    • std::unique_lock 能够在调用 wait 时自动释放锁,并且在 wait 返回时会重新加锁,这使得 std::unique_lock 成为使用条件变量的最佳选择。
  2. cv.notify_all() :通知所有等待该条件的线程,thread1thread2 都会在条件满足时继续执行。

四、共享锁std::shared_mutex实现同步

std::shared_mutex 是 C++17 引入的一个同步原语,它提供了一种读写锁机制,允许多个线程共享读取 同一资源,而只有一个线程能够独占写入 该资源。相比于传统的 std::mutex(只支持独占锁),std::shared_mutex 可以提高并发性,特别是在读操作远多于写操作的情况下。

std::shared_mutex 的工作原理:

  • 共享锁(shared lock):多个线程可以同时获取共享锁,这意味着多个线程可以同时读取共享资源。多个线程获取共享锁时不会发生冲突。
  • 独占锁(unique lock):只有一个线程可以获取独占锁,这意味着写操作会阻塞其他所有操作(无论是读操作还是写操作),以保证数据的一致性。

使用 std::shared_mutex

std::shared_mutex 提供了两种类型的锁:

  • std::unique_lock<std::shared_mutex>:用于获取独占锁。
  • std::shared_lock<std::shared_mutex>:用于获取共享锁。

1. 基本使用示例

cpp 复制代码
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <vector>

std::shared_mutex mtx;  // 定义一个 shared_mutex
int sharedData = 0;

void readData(int threadId) {
    std::shared_lock<std::shared_mutex> lock(mtx);  // 获取共享锁
    std::cout << "Thread " << threadId << " is reading data: " << sharedData << std::endl;
}

void writeData(int threadId, int value) {
    std::unique_lock<std::shared_mutex> lock(mtx);  // 获取独占锁
    sharedData = value;
    std::cout << "Thread " << threadId << " is writing data: " << sharedData << std::endl;
}

int main() {
    std::vector<std::thread> threads;

    // 启动多个线程进行读取操作
    for (int i = 0; i < 5; ++i) {
        threads.push_back(std::thread(readData, i));
    }

    // 启动一个线程进行写入操作
    threads.push_back(std::thread(writeData, 100, 42));

    // 等待所有线程结束
    for (auto& t : threads) {
        t.join();
    }

    return 0;
}

解释:

  1. 共享锁 (std::shared_lock) :线程 readData 使用 std::shared_lock 获取共享锁,这允许多个线程同时读取 sharedData,因为读取操作是线程安全的。
  2. 独占锁 (std::unique_lock) :线程 writeData 使用 std::unique_lock 获取独占锁,这确保了只有一个线程可以写 sharedData,并且写操作会阻塞所有其他线程(包括读操作和写操作)。

2. 多个读线程与单个写线程的并发控制

在这个示例中,多个读线程可以并行执行,因为它们都获取了共享锁。只有当写线程(获取独占锁)执行时,其他线程(无论是读线程还是写线程)会被阻塞。

  • 写操作:获取独占锁,所有读操作和写操作都会被阻塞,直到写操作完成。
  • 读操作:多个线程可以同时获取共享锁,只有在没有写操作时才会执行。

3. 共享锁与独占锁的冲突

  • 共享锁:多个线程可以同时获取共享锁,只要没有线程持有独占锁。共享锁不会阻塞其他共享锁请求。
  • 独占锁:当一个线程持有独占锁时,其他任何线程的共享锁或独占锁请求都会被阻塞,直到独占锁释放。

4. 使用场景

std::shared_mutex 主要适用于读多写少的场景。假设有一个资源(如缓存、数据结构),它在大部分时间内被多个线程读取,但偶尔需要被更新。在这种情况下,std::shared_mutex 可以让多个读操作并行执行,同时避免写操作导致的不必要的阻塞。

例如:

  • 缓存数据读取:多个线程可以并发读取缓存中的数据,而当缓存需要更新时,独占锁会确保数据一致性。
  • 数据库的并发查询和修改:多个线程可以并发查询数据库,但只有一个线程可以执行写操作。

5. std::shared_mutexstd::mutex 比较

  • std::mutex:提供独占锁,适用于写操作频繁且不需要并发读的场景。每次加锁时,其他线程都无法进入临界区。
  • std::shared_mutex:适用于读多写少的场景,允许多个线程同时读取共享资源,但写操作会阻塞所有其他操作。

6. 性能考虑

  • 读操作频繁时 :使用 std::shared_mutex 可以提高并发性,因为多个线程可以同时读取数据。
  • 写操作频繁时 :性能可能会低于 std::mutex,因为写操作需要独占资源并阻塞所有其他操作。

7. 条件变量

std::mutex 一样,std::shared_mutex 也可以与条件变量(std::condition_variable)一起使用,不过在使用时要注意,不同的线程需要加锁和解锁对应的锁。

cpp 复制代码
#include <iostream>
#include <thread>
#include <shared_mutex>
#include <condition_variable>

std::shared_mutex mtx;
std::condition_variable_any cv;
int sharedData = 0;

void readData() {
    std::shared_lock<std::shared_mutex> lock(mtx);  // 获取共享锁
    while (sharedData == 0) {  // 等待数据可用
        cv.wait(lock);  // 等待数据被写入
    }
    std::cout << "Reading data: " << sharedData << std::endl;
}

void writeData(int value) {
    std::unique_lock<std::shared_mutex> lock(mtx);  // 获取独占锁
    sharedData = value;
    std::cout << "Writing data: " << sharedData << std::endl;
    cv.notify_all();  // 通知所有等待的线程
}

int main() {
    std::thread reader(readData);
    std::thread writer(writeData, 42);

    reader.join();
    writer.join();

    return 0;
}

解释:

  • std::shared_lock:用于共享读锁,允许多个线程同时读取。
  • cv.wait(lock):使用共享锁来等待某些条件的变化。
  • cv.notify_all():通知所有等待线程,唤醒它们继续执行。

五、std::atomic实现同步

std::atomic 是 C++11 标准引入的一种类型,用于实现原子操作。原子操作指的是操作在执行过程中不可被中断,因此能够保证数据的一致性和正确性。

std::atomic 提供了一些基本的原子操作方法,这些操作是不可分割的,保证了在多线程环境下线程安全。它主要用于数据的同步与协作,避免了传统同步原语(如锁、条件变量)所带来的性能瓶颈。

原子操作的基本概念:

  1. 原子性:在执行时,操作不能被打断,保证线程之间对共享变量的操作不会产生竞态条件。
  2. 内存顺序(Memory Ordering) :控制操作的执行顺序和对共享数据的可见性,std::atomic 允许通过内存顺序来显式指定不同线程间的同步行为。

std::atomic 提供的原子操作:

  1. 加载(Load):从原子变量中读取数据。
  2. 存储(Store):将数据存储到原子变量中。

std::atomic 支持的内存顺序(Memory Ordering):

  • std::memory_order_acquire:确保前面的操作在加载之后执行,即它会阻止后续的操作在此之前执行。
  • std::memory_order_release:确保后面的操作在存储之前执行,即它会阻止前面的操作在此之后执行。

通常情况下,在使用 std::atomic 进行同步时,使用 memory_order_releasestore 操作时,使用 memory_order_acquireload 操作时,是一种常见的模式,特别是在生产者-消费者模式或者其他类似的同步模式下。

memory_order_releasememory_order_acquire 一般搭配使用。

这种组合是为了确保 内存顺序的一致性,并且保证数据正确的可见性。具体来说:

  1. memory_order_release :在执行 store 操作时,它会确保在 store 之前的所有操作(如数据写入)不会被重排序到 store 之后,保证当前线程的写操作对其他线程是可见的。因此,store 操作保证所有前置的写操作都会在这个 store 完成后被其他线程看到。

  2. memory_order_acquire :在执行 load 操作时,它会确保在 load 之后的所有操作(如数据读取)不会被重排序到 load 之前,保证当前线程在读取共享数据后,后续的操作可以看到正确的数据。在 load 之前的所有操作(包括对共享变量的写入)会在读取这个值之后对当前线程可见。

这两者配合使用,确保了线程间的同步,避免了数据竞态条件。

具体场景

考虑一个生产者-消费者模型,生产者负责写入数据并通知消费者,消费者负责读取数据并处理。

示例:
cpp 复制代码
#include <iostream>
#include <atomic>
#include <thread>

std::atomic<int> data(0);
std::atomic<bool> ready(false);

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {
        // 等待 ready 为 true
    }
    std::cout << "Data: " << data.load(std::memory_order_relaxed) << std::endl;
}

void producer() {
    data.store(42, std::memory_order_relaxed);  // 写数据
    ready.store(true, std::memory_order_release);  // 设置 ready 为 true
}

int main() {
    std::thread t1(consumer);
    std::thread t2(producer);

    t1.join();
    t2.join();

    return 0;
}

解释:

  1. ready.store(true, std::memory_order_release) :生产者线程在写入 ready 时使用 memory_order_release,这意味着在 ready 设置为 true 之后,所有在此之前的操作(如对 data 的写入)对消费者线程是可见的。

  2. ready.load(std::memory_order_acquire) :消费者线程在读取 ready 时使用 memory_order_acquire,这意味着消费者线程在读取 ready 后,确保它能够看到生产者线程在 store ready 之前所做的所有修改(如 data 的值)。

这种组合保证了生产者线程的写操作(例如 data.store(42))对于消费者线程是可见的,且在读取 ready 后,消费者线程可以安全地读取到更新后的 data

相关推荐
心之语歌4 分钟前
LiteFlow Spring boot使用方式
java·开发语言
人才程序员29 分钟前
【C++拓展】vs2022使用SQlite3
c语言·开发语言·数据库·c++·qt·ui·sqlite
OKkankan1 小时前
实现二叉树_堆
c语言·数据结构·c++·算法
梁雨珈1 小时前
PL/SQL语言的图形用户界面
开发语言·后端·golang
励志的小陈1 小时前
C语言-----扫雷游戏
c语言·开发语言·游戏
martian6651 小时前
第19篇:python高级编程进阶:使用Flask进行Web开发
开发语言·python
gis收藏家2 小时前
利用 SAM2 模型探测卫星图像中的农田边界
开发语言·python
Ciderw2 小时前
MySQL为什么使用B+树?B+树和B树的区别
c++·后端·b树·mysql·面试·golang·b+树
yerennuo2 小时前
windows第七章 MFC类CWinApp介绍
c++·windows·mfc
齐雅彤2 小时前
Bash语言的并发编程
开发语言·后端·golang