C++多线程教程-1.1.4 并发编程的风险(竞态条件、死锁、数据竞争、资源争用)

本部分内容属于:
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++ 并非原子操作,实际拆解为三步:

  1. 从内存读取 counter 的值到CPU寄存器;

  2. 寄存器中的值加1;

  3. 将寄存器的值写回内存中的 counter

若线程A执行完步骤1后被CPU调度切换,线程B执行完整的三步,线程A恢复执行后会基于旧值写回,导致一次累加"丢失"。线程数量越多,丢失的次数越多,结果偏差越大。

5. 基础规避思路
  • 使用原子操作 (后续4.1节 std::atomic)确保操作不可中断;

  • 使用互斥量 (后续3.1节 std::mutex)保护共享资源的访问。


二、数据竞争(Data Race)
1. 核心定义

数据竞争是C++标准明确定义的未定义行为(Undefined Behavior),指满足以下两个条件的并发操作:

  1. 至少有一个线程对共享内存进行写操作

  2. 至少有一个线程对同一共享内存进行读/写操作

  3. 操作之间没有同步机制(如互斥量、原子操作)。

⚠️ 注意:竞态条件是"逻辑错误",数据竞争是"标准未定义行为",数据竞争可能导致竞态条件,但竞态条件不一定伴随数据竞争(例如同步后的时序问题)。

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. 核心定义

死锁指两个或多个线程互相持有对方所需的资源,且都不释放已持有的资源,导致所有线程永久阻塞,程序无法继续执行。死锁需满足四大必要条件(缺一不可):

  1. 互斥条件:资源只能被一个线程持有;

  2. 持有并等待:线程持有部分资源,同时等待其他线程持有的资源;

  3. 不可剥夺:资源不能被强制从持有线程中剥夺;

  4. 循环等待:线程间形成环形的资源等待链。

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_mutextry_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节)。


总结

  1. 竞态条件:因线程执行时序不可控导致结果错误,核心是共享资源的非原子操作被多线程打断;

  2. 数据竞争:C++标准明确定义的未定义行为,无同步机制的共享资源读写操作,可能导致程序崩溃或结果异常;

  3. 死锁:多个线程互相持有对方所需资源且不释放,导致所有线程永久阻塞,需满足互斥、持有并等待、不可剥夺、循环等待四大必要条件;

  4. 资源争用:多线程高频竞争同一共享资源,导致线程阻塞、上下文切换频繁,进而降低程序并发效率,需通过细粒度锁、局部计算等方式优化。

这些风险的完整解决方案将在后续章节(互斥量、原子操作、线程池)中详细讲解,核心原则是:最小化共享资源、严格同步共享操作、优化资源竞争粒度

相关推荐
艳阳天_.2 小时前
web 分录科目实现辅助账
开发语言·前端·javascript
梵刹古音2 小时前
【C语言】 循环结构
c语言·开发语言·算法
消失的旧时光-19432 小时前
C++ 函数参数传递方式总结:什么时候用值传递、引用、const 引用?
开发语言·c++
2601_949868362 小时前
Flutter for OpenHarmony 剧本杀组队App实战04:发起组队表单实现
开发语言·javascript·flutter
一匹电信狗2 小时前
【C++】CPU的局部性原理
开发语言·c++·系统架构·学习笔记·c++11·智能指针·新特性
m0_561359672 小时前
C++代码冗余消除
开发语言·c++·算法
毕设源码-郭学长2 小时前
【开题答辩全过程】以 基于Python爬取学院师资队伍信息的设计与分析为例,包含答辩的问题和答案
开发语言·python
会开花的二叉树2 小时前
吃透Reactor多线程:EventLoop_Channel_ThreadPool协作原理
开发语言·c++·tcp/ip·servlet
Jm_洋洋2 小时前
【C++进阶】虚函数、虚表与虚指针:多态底层机制剖析
java·开发语言·c++