一、并发编程基础:线程创建与管理、互斥锁与条件变量、死锁预防、std::async与std::future

并发编程,说白了就是让程序同时干多件事。我刚开始接触多线程时,觉得这东西挺酷的——一个程序能同时处理多个任务,多高效啊。但后来踩了不少坑才明白,并发编程就像走钢丝,用好了是利器,用不好就是灾难。

这一章,咱们聊聊C++并发编程的核心基础。我会结合自己这些年做系统开发的经验,把线程创建、同步机制、死锁预防这些关键点讲清楚。

1. 线程的创建与管理

C++11开始,标准库提供了std::thread,终于不用再跟平台API打交道了。我个人习惯用std::thread配合std::jthread(C++20)来管理线程生命周期。

#include <thread>
#include <iostream>

void worker(int id) {
    std::cout << "线程 " << id << " 开始工作\n";
    // 模拟一些计算
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::cout << "线程 " << id << " 结束工作\n";
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    
    // 等待线程结束
    t1.join();
    t2.join();
    
    return 0;
}

嗯,这里要注意:join()必须调用,否则程序会崩溃。我曾经在项目里忘记调用join(),结果程序退出时直接core dump,排查了半天才发现是线程对象析构时检测到线程还在运行。

小技巧:C++20的std::jthread会在析构时自动join(),省心不少。如果项目允许用C++20,我建议优先用jthread

2. 互斥锁与数据保护

多个线程访问共享数据时,必须加锁。否则就会出现数据竞争——说白了就是两个线程同时写同一个变量,结果谁也不知道最终值是什么。

#include <mutex>
#include <thread>

std::mutex mtx;
int shared_data = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++shared_data;
    }
}

我推荐用std::lock_guard而不是手动lock()/unlock()。为什么?因为lock_guard是RAII风格的,出了作用域自动解锁。万一中间抛出异常,锁也能正确释放。我在项目中见过太多因为忘记解锁导致的死锁问题了。

避坑指南:我曾经在代码里手动调用mtx.lock(),然后在某个分支提前return了,结果忘了unlock()。从那以后,我再也不用裸锁了,一律用lock_guardunique_lock

3. 条件变量:线程间的信号机制

条件变量用于线程间的等待与通知。比如生产者-消费者模式中,消费者需要等待数据准备好再处理。

#include <condition_variable>
#include <queue>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return !data_queue.empty(); });
    // 处理数据
    int val = data_queue.front();
    data_queue.pop();
}

void producer(int val) {
    {
        std::lock_guard<std::mutex> lock(mtx);
        data_queue.push(val);
    }
    cv.notify_one();
}

注意这里用unique_lock而不是lock_guard。因为wait()需要暂时释放锁,让其他线程能拿到锁去生产数据。等条件满足时,wait()会重新获取锁。这个机制很巧妙,但也很容易用错。

你想想看,如果不用条件变量,消费者就得不停地轮询检查队列是否为空,白白浪费CPU。条件变量让线程在等待时休眠,有数据时再唤醒,效率高得多。

4. 死锁预防

死锁是并发编程中最头疼的问题之一。说白了就是两个线程互相等待对方释放锁,结果谁也动不了。

我总结了几条预防死锁的经验:

  • 固定锁顺序:所有线程按相同顺序获取锁。比如先锁A再锁B,不要有的线程先锁A再锁B,有的先锁B再锁A。
  • 使用std::lock一次性锁住多个互斥量,避免死锁。
  • 避免嵌套锁:尽量少在持有一个锁的情况下去获取另一个锁。
  • 使用超时锁:try_lock_for()可以避免无限等待。
// 使用std::lock避免死锁
std::mutex mtx1, mtx2;

void safe_operation() {
    std::lock(mtx1, mtx2);  // 同时锁住两个
    std::lock_guard<std::mutex> lock1(mtx1, std::adopt_lock);
    std::lock_guard<std::mutex> lock2(mtx2, std::adopt_lock);
    // 安全操作...
}
核心原则:死锁预防的关键在于「顺序」和「原子性」。要么所有线程按相同顺序加锁,要么一次性原子地获取所有锁。

5. std::async与std::future

说实话,很多时候我们并不需要手动管理线程。用std::async配合std::future,既简单又安全。

#include <future>
#include <iostream>

int compute(int x) {
    return x * x;
}

int main() {
    std::future<int> result = std::async(std::launch::async, compute, 42);
    
    // 干点别的事...
    std::cout << "结果: " << result.get() << std::endl;
    return 0;
}

std::async会自动在后台线程执行任务,std::future则用来获取结果。调用get()时会阻塞等待,直到结果可用。

我个人习惯用std::launch::async策略,强制在新线程中执行。默认策略可能是延迟执行(std::launch::deferred),直到get()时才在调用线程执行,这有时会带来意想不到的性能问题。

经验之谈:我在做图像处理项目时,用std::async并行处理多个图像块,代码简洁且性能不错。相比手动管理线程池,std::async更适合任务粒度较粗、数量不多的场景。

知识体系总览

下面这张图总结了本章的核心知识点和它们之间的关系:

C++并发编程 线程创建与管理 std::thread / std::jthread join() / detach() RAII管理生命周期 互斥锁与同步 std::mutex / lock_guard unique_lock / shared_lock 条件变量 cv.wait/notify 死锁预防 固定锁顺序 std::lock 原子加锁 避免嵌套锁 std::async 与 std::future 异步任务提交:std::async(launch::async, func) 结果获取:future.get() 阻塞等待 比手动线程更安全、更简洁

总结

并发编程的核心就这几样东西:线程怎么创建、数据怎么保护、线程间怎么通信、怎么避免死锁。掌握了这些基础,大部分并发场景都能应对。

我个人建议初学者先从std::async入手,它封装了线程创建和结果获取的细节,不容易出错。等熟悉了再深入手动管理线程和锁。毕竟,能简单解决的问题,没必要搞复杂。

嗯,这一章就到这里。记住:并发编程的难点不在于API怎么用,而在于如何设计出正确、高效的同步策略。多写、多调试、多思考,慢慢就有感觉了。


公众号:蓝海资料掘金营,微信deep3321