如果在多线程程序中对全局变量的访问没有进行适当的同步控制(例如使用互斥锁、原子变量等),会导致多个线程同时访问和修改全局变量时发生竞态条件 (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++
并不是一个原子操作。它由多个步骤组成:读取值、增加值、写回。在这段代码中,t1
和t2
可能会同时读取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;
}
说明:
std::mutex
: 用于保护共享资源(如全局变量)。std::lock_guard<std::mutex>
: 是一个RAII风格的封装器,它在构造时自动上锁,在析构时自动解锁,确保了线程安全。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
的关键特性:
- 手动控制锁的获取和释放 :
std::unique_lock
支持手动解锁和重新锁定,它比std::lock_guard
更加灵活。 - 延迟锁定和提前解锁:你可以选择在对象创建时延迟锁定,或者在锁定后手动释放锁。
- 支持条件变量 :
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;
}
解释:
-
std::condition_variable
和std::unique_lock
:- 在
threadFunction
中,cv.wait(lock)
会释放锁并等待条件变量的通知。 std::unique_lock
能够在调用wait
时自动释放锁,并且在wait
返回时会重新加锁,这使得std::unique_lock
成为使用条件变量的最佳选择。
- 在
-
cv.notify_all()
:通知所有等待该条件的线程,thread1
和thread2
都会在条件满足时继续执行。
四、共享锁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;
}
解释:
- 共享锁 (
std::shared_lock
) :线程readData
使用std::shared_lock
获取共享锁,这允许多个线程同时读取sharedData
,因为读取操作是线程安全的。 - 独占锁 (
std::unique_lock
) :线程writeData
使用std::unique_lock
获取独占锁,这确保了只有一个线程可以写sharedData
,并且写操作会阻塞所有其他线程(包括读操作和写操作)。
2. 多个读线程与单个写线程的并发控制:
在这个示例中,多个读线程可以并行执行,因为它们都获取了共享锁。只有当写线程(获取独占锁)执行时,其他线程(无论是读线程还是写线程)会被阻塞。
- 写操作:获取独占锁,所有读操作和写操作都会被阻塞,直到写操作完成。
- 读操作:多个线程可以同时获取共享锁,只有在没有写操作时才会执行。
3. 共享锁与独占锁的冲突:
- 共享锁:多个线程可以同时获取共享锁,只要没有线程持有独占锁。共享锁不会阻塞其他共享锁请求。
- 独占锁:当一个线程持有独占锁时,其他任何线程的共享锁或独占锁请求都会被阻塞,直到独占锁释放。
4. 使用场景:
std::shared_mutex
主要适用于读多写少的场景。假设有一个资源(如缓存、数据结构),它在大部分时间内被多个线程读取,但偶尔需要被更新。在这种情况下,std::shared_mutex
可以让多个读操作并行执行,同时避免写操作导致的不必要的阻塞。
例如:
- 缓存数据读取:多个线程可以并发读取缓存中的数据,而当缓存需要更新时,独占锁会确保数据一致性。
- 数据库的并发查询和修改:多个线程可以并发查询数据库,但只有一个线程可以执行写操作。
5. std::shared_mutex
与 std::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
提供了一些基本的原子操作方法,这些操作是不可分割的,保证了在多线程环境下线程安全。它主要用于数据的同步与协作,避免了传统同步原语(如锁、条件变量)所带来的性能瓶颈。
原子操作的基本概念:
- 原子性:在执行时,操作不能被打断,保证线程之间对共享变量的操作不会产生竞态条件。
- 内存顺序(Memory Ordering) :控制操作的执行顺序和对共享数据的可见性,
std::atomic
允许通过内存顺序来显式指定不同线程间的同步行为。
std::atomic
提供的原子操作:
- 加载(Load):从原子变量中读取数据。
- 存储(Store):将数据存储到原子变量中。
std::atomic
支持的内存顺序(Memory Ordering):
std::memory_order_acquire
:确保前面的操作在加载之后执行,即它会阻止后续的操作在此之前执行。std::memory_order_release
:确保后面的操作在存储之前执行,即它会阻止前面的操作在此之后执行。
通常情况下,在使用 std::atomic
进行同步时,使用 memory_order_release
在 store
操作时,使用 memory_order_acquire
在 load
操作时,是一种常见的模式,特别是在生产者-消费者模式或者其他类似的同步模式下。
memory_order_release
和 memory_order_acquire
一般搭配使用。
这种组合是为了确保 内存顺序的一致性,并且保证数据正确的可见性。具体来说:
-
memory_order_release
:在执行store
操作时,它会确保在store
之前的所有操作(如数据写入)不会被重排序到store
之后,保证当前线程的写操作对其他线程是可见的。因此,store
操作保证所有前置的写操作都会在这个store
完成后被其他线程看到。 -
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;
}
解释:
-
ready.store(true, std::memory_order_release)
:生产者线程在写入ready
时使用memory_order_release
,这意味着在ready
设置为true
之后,所有在此之前的操作(如对data
的写入)对消费者线程是可见的。 -
ready.load(std::memory_order_acquire)
:消费者线程在读取ready
时使用memory_order_acquire
,这意味着消费者线程在读取ready
后,确保它能够看到生产者线程在store
ready
之前所做的所有修改(如data
的值)。
这种组合保证了生产者线程的写操作(例如 data.store(42)
)对于消费者线程是可见的,且在读取 ready
后,消费者线程可以安全地读取到更新后的 data
。