搜索

C++ 教程

C++基础

第1章 搭建开发环境与第一个程序 第2章 基本数据类型与变量 第3章 运算符与表达式 第4章 流程控制语句 第5章 数组与字符串 第6章 函数基础 第7章 指针基础 第8章 引用与const限定 第9章 动态内存管理 第10章 结构体与联合体 第11章 函数进阶

面向对象

第12章 面向对象编程入门 第13章 封装与访问控制 第14章 运算符重载 第15章 继承与派生 第16章 多态与虚函数

STL与进阶

第17章 模板编程基础 第18章 标准模板库(STL)容器 第19章 STL算法与迭代器 第20章 异常处理机制 第21章 文件输入输出 第22章 字符串类string 第23章 命名空间与作用域 第24章 类型转换操作符

现代C++

第25章 智能指针 第26章 移动语义与右值引用 第27章 Lambda表达式 第28章 多线程编程基础 第29章 模板元编程入门 第30章 设计模式基础 第31章 C++11/14新特性 第32章 C++17/20核心特性

实战进阶

第33章 性能优化技巧 第34章 调试与测试 第35章 项目结构与构建 第36章 网络编程入门 第37章 数据库连接操作 第38章 图形界面开发基础 第39章 综合项目实战 第40章 进阶学习路线

第28章 多线程编程基础

第28章 多线程编程基础

进阶 👤 已掌握C++核心语法,希望提升程序并发能力的开发者 🔧 开发高性能服务器、图形界面响应、批量数据处理等场景
本章你将学到
  • 使用std::thread创建与管理线程
  • 理解并解决数据竞争与死锁问题
  • 使用互斥量、条件变量进行线程同步
核心概念并发线程安全

想象一下厨房里只有一个厨师,他必须依次完成洗菜、切菜、炒菜,这就是单线程。如果引入两个厨师同时工作,一个负责洗切,一个负责烹饪,效率就大大提升,这就是多线程的核心价值——让程序“同时”做多件事,充分利用现代多核CPU的计算能力。

什么是线程?

生活类比:一个进程好比一家公司,线程就是公司里并行工作的员工。公司(进程)拥有公共资源(办公室、打印机),员工(线程)共享这些资源,但各自独立执行自己的任务(写代码、做设计)。

技术定义:线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,所有线程共享进程的内存空间(如堆、全局变量),但拥有独立的栈空间和寄存器状态。

C++多线程支持的发展

C++在2011年发布的C++11标准之前,多线程编程依赖平台特定的API,如Windows的CreateThread或POSIX的pthread_create。这导致代码可移植性极差。C++11将线程支持纳入了标准库<thread>,为开发者提供了统一、可移植的并发编程工具。

C++98/03
→
平台API
→
Windows/Linux
↓
C++11及以后
→
标准库<thread>
→
所有平台

创建你的第一个线程

使用std::thread创建线程非常简单,只需将可调用对象(函数、函数对象、lambda表达式)传递给它的构造函数。我在一个日志处理项目中首次使用多线程,将耗时的文件写入操作放到后台,主线程的响应速度立刻提升了5倍以上。

// 一个将在新线程中执行的函数
void backgroundTask() {
    std::cout << "新线程ID: " << std::this_thread::get_id() << std::endl;
    for(int i = 0; i < 3; ++i) {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "后台任务执行中... " << i+1 << std::endl;
    }
}

int main() {
    std::cout << "主线程ID: " << std::this_thread::get_id() << std::endl;

    // 创建线程对象,传入函数指针,线程立即开始执行
    std::thread t(backgroundTask);

    // 主线程继续自己的工作
    std::cout << "主线程在忙别的..." << std::endl;

    // 等待后台线程执行完毕
    t.join();

    std::cout << "主线程结束。" << std::endl;
    return 0;
}

关键点:join()与detach()

线程对象在析构前,必须明确其管理方式,否则程序会调用std::terminate()终止。这是新手最容易踩的坑之一。

  • join():阻塞当前线程(通常是主线程),等待被join的线程执行完毕。这确保了线程资源的正确回收。
  • detach():将线程与std::thread对象分离,允许线程独立地在后台运行(“守护线程”)。分离后,你将失去对该线程的直接控制权。

实际开发中,我强烈建议优先使用join(),除非你非常清楚分离线程的生命周期管理。detach()用不好,容易出现线程还在访问已销毁的局部变量,导致难以追踪的崩溃。

共享数据的噩梦:数据竞争

当多个线程不加控制地读写同一块内存时,就会发生数据竞争(Data Race)。结果变得不可预测,这是多线程Bug的主要来源。

int shared_counter = 0; // 共享数据

void increment() {
    for(int i = 0; i < 100000; ++i) {
        ++shared_counter; // 危险操作!
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();
    // 我们期望shared_counter是200000,但实际运行结果总是小于它
    std::cout << "Final counter: " << shared_counter << std::endl;
    return 0;
}

为什么++shared_counter不是原子的?它至少包含三个步骤:从内存加载值到寄存器、增加寄存器值、存回内存。两个线程的这三个步骤可能交织,导致部分增加操作被覆盖。

⚠️ 常见误区

  • “我的操作很简单,不会出问题”:任何非原子的读写操作,哪怕像counter++这样一行代码,在并发环境下都是不安全的。
  • “测试几次结果都对,代码就没问题”:数据竞争是未定义行为,可能在某些运行环境下“恰好”得到正确结果,但换台机器、换个编译器优化等级就可能出错。这种偶发性Bug最难调试。
  • “我用的是局部变量,很安全”:如果这个局部变量是引用或指针,并且指向了共享数据,同样不安全。线程栈是独立的,但堆和全局数据区是共享的。

互斥量:给共享数据加锁

为了解决数据竞争,我们需要互斥量(Mutex, Mutual Exclusion)。它的作用就像公共厕所的门锁,一个线程进去“使用资源”时锁上门,其他线程只能在门口等待,用完后解锁,下一个线程才能进入。

C++11提供了std::mutex。保护数据的关键模式是:用互斥量保护数据,而不是保护代码。你需要确保所有访问共享数据的路径都先获得锁。

#include <mutex>
std::mutex mtx; // 互斥量
int shared_counter = 0;

void safe_increment() {
    for(int i = 0; i < 100000; ++i) {
        mtx.lock();   // 加锁
        ++shared_counter;
        mtx.unlock(); // 解锁
    }
}

但直接使用lock()和unlock()非常危险,如果临界区代码抛出异常,可能导致锁永远无法释放,造成死锁。因此,永远优先使用RAII风格的锁管理器,如std::lock_guard。

void safer_increment() {
    for(int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx); // 构造时加锁,析构时自动解锁
        ++shared_counter;
    } // lock_guard在此析构,自动调用mtx.unlock()
}

编者提示:std::lock_guard完美体现了RAII(资源获取即初始化)思想。如果你对RAII还不熟悉,建议回顾第25章《智能指针》,其中std::unique_ptr管理内存资源的理念与锁管理器管理互斥量如出一辙。

死锁:当线程互相等待

死锁(Deadlock)是多线程编程中另一个经典问题。典型场景是两个线程各自持有一把锁,同时试图获取对方持有的锁,于是两者都无限期地等待下去。

线程A锁住Mutex1
→
线程B锁住Mutex2
→
线程A尝试锁Mutex2(等待)
→
线程B尝试锁Mutex1(等待)
→
死锁!

C++标准库提供了std::lock函数来一次性锁定多个互斥量,且保证不会死锁。它通常与std::lock_guard的延迟锁定特性配合使用。

std::mutex mtx1, mtx2;

void process_with_two_locks() {
    // 使用std::lock一次性锁定两个互斥量,避免死锁
    std::lock(mtx1, mtx2);
    // 使用std::lock_guard管理锁的所有权,adopt_lock表示已锁定
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);

    // 安全地访问受两个互斥量保护的资源...
}

等待与通知:条件变量

互斥量解决了同步问题,但有时线程需要等待某个条件成立(例如,任务队列不为空)。忙等待(不断循环检查)会浪费CPU。条件变量std::condition_variable允许线程在等待时休眠,并在条件可能满足时被唤醒。

一个典型的生产者-消费者模型:

#include <queue>
#include <condition_variable>

std::queue<int> data_queue;
std::mutex queue_mtx;
std::condition_variable data_cond;

void data_preparation_thread() {
    for(int i = 0; i < 10; ++i) {
        int data = i;
        {
            std::lock_guard<std::mutex> lock(queue_mtx);
            data_queue.push(data);
        } // 锁在此作用域结束释放
        data_cond.notify_one(); // 通知一个等待的消费者
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
}

void data_processing_thread() {
    while(true) {
        std::unique_lock<std::mutex> lock(queue_mtx); // 必须用unique_lock
        // 等待条件成立。如果条件不满足,会原子地解锁互斥量并阻塞线程
        data_cond.wait(lock, []{ return !data_queue.empty(); });
        // 被唤醒后,锁已重新获取,且队列非空
        int data = data_queue.front();
        data_queue.pop();
        lock.unlock(); // 可以提前解锁,处理数据时不需锁
        std::cout << "处理数据: " << data << std::endl;
        if(data == 9) break;
    }
}

为什么用std::unique_lock而不是std::lock_guard?

std::condition_variable::wait需要能够解锁和重新锁定互斥量,std::lock_guard不提供这种灵活性。std::unique_lock更重量级,但功能也更强大,支持延迟锁定、手动锁定/解锁、转移所有权。在需要与条件变量配合或灵活管理锁时,就用std::unique_lock。

线程安全的数据结构设计

仅仅在外部加锁使用STL容器(如std::vector, std::queue)是不够的,因为STL容器本身不是线程安全的。你需要封装一个线程安全的包装器。这里有个隐藏的坑:即使每个成员函数都加锁,多个操作的组合(如检查空再弹出)也不是原子的。

粗粒度锁

  • 整个数据结构用一把大锁保护
  • 实现简单,不易出错
  • 并发性能较低
  • 推荐新手使用

细粒度锁

  • 数据结构内部用多把锁保护不同部分
  • 实现复杂,容易死锁
  • 并发性能高
  • 适合高级场景

对于大多数应用,一个简单的线程安全队列封装就足够了:

template<typename T>
class ThreadSafeQueue {
private:
    mutable std::mutex mtx_;
    std::queue<T> data_queue_;
    std::condition_variable cond_;
public:
    ThreadSafeQueue() = default;

    void push(T new_value) {
        std::lock_guard<std::mutex> lock(mtx_);
        data_queue_.push(std::move(new_value));
        cond_.notify_one();
    }

    // 等待并弹出,提供原子性
    void wait_and_pop(T& value) {
        std::unique_lock<std::mutex> lock(mtx_);
        cond_.wait(lock, [this]{ return !data_queue_.empty(); });
        value = std::move(data_queue_.front());
        data_queue_.pop();
    }

    bool empty() const {
        std::lock_guard<std::mutex> lock(mtx_);
        return data_queue_.empty();
    }
};

动手试一试

1. 修复数据竞争:编写一个程序,创建4个线程,每个线程对同一个全局变量执行10万次加1操作。使用互斥量保护,确保最终结果精确为40万。
2. 模拟生产者-消费者:使用上面提供的ThreadSafeQueue类框架,创建一个生产者线程(每秒生产一个随机数)和两个消费者线程(消费并打印数字)。观察输出是否有序?为什么?
3. 体验死锁:故意写一个会产生死锁的程序(两个线程,两把锁,循环等待),运行它。在任务管理器或系统监视器中观察程序的CPU占用率有何变化?

检验你的理解

  1. 如果忘记调用join()或detach(),std::thread对象析构时会发生什么?
  2. 为什么说if(!queue.empty()) { queue.pop(); }这段代码在多线程环境下是不安全的,即使empty()和pop()内部都加了锁?
  3. std::lock_guard和std::unique_lock最主要的区别是什么?在什么场景下必须使用后者?
  4. 条件变量std::condition_variable::wait函数为什么要接受一个std::unique_lock和一个谓词(lambda表达式)?

本章小结

  • 线程是并发执行的基本单位,C++11通过<thread>头文件提供了可移植的线程库。
  • 数据竞争是未定义行为的根源,必须使用互斥量(如std::mutex)保护所有共享数据的访问。
  • 优先使用RAII锁管理器(std::lock_guard, std::unique_lock)来避免忘记解锁导致的死锁。
  • 条件变量(std::condition_variable)用于线程间的等待/通知机制,是实现高效生产者-消费者模型的关键。
  • 设计线程安全类时,应提供组合操作的原子接口,而不是让用户自己组合加锁的函数调用。

意见反馈

微信二维码 微信扫码关注 抖音二维码 抖音扫码关注