本部分内容属于:
C++并发编程系列-基于标准库
第一部分:并发编程基础与C++线程模型
1.1 并发编程核心概念
1.1.4 并发编程的风险(竞态条件、死锁、数据竞争、资源争用)
其它章节内容请查看对应章节。
1.1.4 并发编程的风险(竞态条件、死锁、数据竞争、资源争用)
1.1.4 并发编程的风险(竞态条件、死锁、数据竞争、资源争用)
并发编程通过多线程并行执行提升程序效率,但多个线程共享资源时,若缺乏合理的同步机制,会引入一系列难以调试的风险。本节系统讲解并发编程的四大核心风险,结合可运行的代码示例分析成因,并给出基础规避思路(完整解决方案将在后续章节展开)。
一、竞态条件(Race Condition)
1. 核心定义
竞态条件指多个线程以不可预测的顺序访问共享资源,导致程序输出结果依赖于线程执行的时序,最终产生错误或不一致的结果。竞态条件是并发编程中最基础也最常见的风险,本质是"对共享资源的非原子性操作"被多线程打断。
2. 代码示例:竞态条件复现
cpp
#include <iostream>
#include <thread>
#include <vector>
// 共享资源:计数器
int counter = 0;
// 线程执行的函数:对计数器累加100000次
void increment_counter() {
for (int i = 0; i < 100000; ++i) {
// 非原子操作:读取counter -> 加1 -> 写回counter
// 多线程执行时,该操作可能被打断,导致计数错误
counter++;
}
}
int main() {
// 创建10个线程并发执行累加操作
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter);
}
// 等待所有线程执行完成
for (auto& t : threads) {
t.join();
}
// 预期结果:10 * 100000 = 1000000
// 实际结果:远小于1000000(每次运行结果可能不同)
std::cout << "Total counter is " << counter << std::endl;
return 0;
}
3. 输出结果(示例)
Plain
615201
4. 成因解析
counter++ 并非原子操作,实际拆解为三步:
-
从内存读取
counter的值到CPU寄存器; -
寄存器中的值加1;
-
将寄存器的值写回内存中的
counter。
若线程A执行完步骤1后被CPU调度切换,线程B执行完整的三步,线程A恢复执行后会基于旧值写回,导致一次累加"丢失"。线程数量越多,丢失的次数越多,结果偏差越大。
5. 基础规避思路
-
使用原子操作 (后续4.1节
std::atomic)确保操作不可中断; -
使用互斥量 (后续3.1节
std::mutex)保护共享资源的访问。
二、数据竞争(Data Race)
1. 核心定义
数据竞争是C++标准明确定义的未定义行为(Undefined Behavior),指满足以下两个条件的并发操作:
-
至少有一个线程对共享内存进行写操作;
-
至少有一个线程对同一共享内存进行读/写操作;
-
操作之间没有同步机制(如互斥量、原子操作)。
⚠️ 注意:竞态条件是"逻辑错误",数据竞争是"标准未定义行为",数据竞争可能导致竞态条件,但竞态条件不一定伴随数据竞争(例如同步后的时序问题)。
2. 代码示例:数据竞争复现
cpp
#include <iostream>
#include <thread>
#include <vector>
// 共享资源:计数器
int counter = 0;
// 线程执行的函数:对计数器累加100000次
void increment_counter() {
for (int i = 0; i < 100000; ++i) {
// 非原子操作:读取counter -> 加1 -> 写回counter
// 多线程执行时,该操作可能被打断,导致计数错误
counter++;
}
}
int main() {
// 创建10个线程并发执行累加操作
std::vector<std::thread> threads;
threads.reserve(10);
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter);
}
// 等待所有线程执行完成
for (auto& t : threads) {
t.join();
}
// 预期结果:10 * 100000 = 1000000
// 实际结果:远小于1000000(每次运行结果可能不同)
std::cout << "Total counter is " << counter << std::endl;
return 0;
}
3. 风险后果
数据竞争的后果不可预测:
-
读取到"半更新"的数据(如64位整数只写了低32位);
-
编译器优化导致内存可见性问题(线程2读取到缓存中的旧值);
-
程序崩溃、核心转储,或在不同编译器/平台下表现不一致。
4. 基础规避思路
-
所有对共享数据的读写操作必须通过同步机制保护;
-
使用
std::atomic修饰共享变量(确保内存可见性和原子性); -
避免裸共享变量,通过RAII封装(后续章节)隔离共享资源。
三、死锁(Deadlock)
1. 核心定义
死锁指两个或多个线程互相持有对方所需的资源,且都不释放已持有的资源,导致所有线程永久阻塞,程序无法继续执行。死锁需满足四大必要条件(缺一不可):
-
互斥条件:资源只能被一个线程持有;
-
持有并等待:线程持有部分资源,同时等待其他线程持有的资源;
-
不可剥夺:资源不能被强制从持有线程中剥夺;
-
循环等待:线程间形成环形的资源等待链。
2. 代码示例:死锁复现
cpp
#include <iostream>
#include <thread>
#include <mutex>
// 两个互斥量(共享资源的锁)
std::mutex mutex1;
std::mutex mutex2;
// 线程1:先锁mutex1,再尝试锁mutex2
void thread1_func() {
std::cout << "Thread 1: Trying to acquire mutex1" << std::endl;
mutex1.lock();
std::cout << "Thread 1: Acquired mutex1, trying to acquire mutex2" << std::endl;
// 模拟耗时操作,确保线程2先获取mutex2
std::this_thread::sleep_for(std::chrono::milliseconds(100));
mutex2.lock(); // 等待mutex2,而mutex2已被线程2持有
std::cout << "Thread 1: Acquired mutex2" << std::endl;
// 释放资源(死锁时无法执行到此处)
mutex2.unlock();
mutex1.unlock();
}
// 线程2:先锁mutex2,再尝试锁mutex1
void thread2_func() {
std::cout << "Thread 2: Trying to acquire mutex2" << std::endl;
mutex2.lock();
std::cout << "Thread 2: Acquired mutex2, trying to acquire mutex1" << std::endl;
mutex1.lock(); // 等待mutex1,而mutex1已被线程1持有
std::cout << "Thread 2: Acquired mutex1" << std::endl;
// 释放资源(死锁时无法执行到此处)
mutex2.unlock();
mutex1.unlock();
}
int main() {
std::thread t1(thread1_func);
std::thread t2(thread2_func);
t1.join();
t2.join();
return 0;
}
3. 输出结果(示例)
Plain
// 程序可能输出结果1
Thread 1: Trying to acquire mutex1Thread 2: Trying to acquire mutex2
Thread 1: Acquired mutex1, trying to acquire mutex2
Thread 2: Acquired mutex2, trying to acquire mutex1
// 永久阻塞,程序不结束
// 程序可能输出结果2
Thread 2: Trying to acquire mutex2Thread 1: Trying to acquire mutex1
Thread 1: Acquired mutex1, trying to acquire mutex2
Thread 2: Acquired mutex2, trying to acquire mutex1
// 永久阻塞,程序不结束
//程序可能输出结果3
Thread 1: Trying to acquire mutex1Thread 2: Trying to acquire mutex2
Thread 2: Acquired mutex2, trying to acquire mutex1
Thread 2: Acquired mutex1
Thread 1: Acquired mutex1, trying to acquire mutex2
Thread 1: Acquired mutex2
Process finished with exit code 0
4. 基础规避思路
-
顺序加锁:所有线程按固定顺序获取资源(如先锁mutex1,再锁mutex2);
-
超时机制 :使用
std::timed_mutex的try_lock_for避免永久等待(后续3.1.3节); -
避免嵌套锁:减少锁的嵌套层级,降低循环等待概率;
-
资源分层:将资源按层级划分,线程只能从低层级向高层级申请资源。
四、资源争用(Resource Contention)
1. 核心定义
资源争用指多个线程频繁竞争同一共享资源(如锁、CPU、内存、I/O),导致线程频繁阻塞、上下文切换,最终降低程序整体性能(甚至比单线程更慢)。资源争用不直接导致程序错误,但会严重影响并发效率。
2. 代码示例:资源争用复现
cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::mutex resource_mutex;
long long total = 0;
// 高频率竞争锁的函数
void accumulate(long long start, long long end) {
for (long long i = start; i < end; ++i) {
// 每次循环都加锁/解锁,导致高频资源争用
std::lock_guard<std::mutex> lock(resource_mutex);
total += i;
}
}
int main() {
auto start_time = std::chrono::high_resolution_clock::now();
// 创建4个线程,竞争同一把锁累加数据
std::thread t1(accumulate, 0, 25000000);
std::thread t2(accumulate, 25000000, 50000000);
std::thread t3(accumulate, 50000000, 75000000);
std::thread t4(accumulate, 75000000, 100000000);
t1.join();
t2.join();
t3.join();
t4.join();
auto end_time = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time);
// 输出结果每次可能不相同
std::cout << total << std::endl;
std::cout << duration.count() << std::endl;
return 0;
}
3. 输出结果(示例)
Plain
4999999950000000
2249
// 输出结果每次可能不相同
4. 对比单线程(参考)
若用单线程执行相同累加操作,耗时约150毫秒------并发版本因高频锁竞争,性能反而大幅下降。
5. 核心成因与规避思路
-
成因:锁粒度太粗(整个累加操作都加锁)、锁持有时间过长、线程数超过CPU核心数导致上下文切换;
-
规避思路:
-
细粒度锁:仅保护必要的共享数据操作;
-
局部计算+批量更新:线程先在本地计算结果,最后批量更新共享数据(减少锁竞争次数);
-
合理设置线程数:CPU密集型任务线程数不宜超过CPU核心数;
-
无锁编程:使用原子操作替代互斥量(后续4.1节)。
总结
-
竞态条件:因线程执行时序不可控导致结果错误,核心是共享资源的非原子操作被多线程打断;
-
数据竞争:C++标准明确定义的未定义行为,无同步机制的共享资源读写操作,可能导致程序崩溃或结果异常;
-
死锁:多个线程互相持有对方所需资源且不释放,导致所有线程永久阻塞,需满足互斥、持有并等待、不可剥夺、循环等待四大必要条件;
-
资源争用:多线程高频竞争同一共享资源,导致线程阻塞、上下文切换频繁,进而降低程序并发效率,需通过细粒度锁、局部计算等方式优化。
这些风险的完整解决方案将在后续章节(互斥量、原子操作、线程池)中详细讲解,核心原则是:最小化共享资源、严格同步共享操作、优化资源竞争粒度。