一、并发编程基础:线程创建与管理、互斥锁与条件变量、死锁预防、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,排查了半天才发现是线程对象析构时检测到线程还在运行。
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_guard或unique_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更适合任务粒度较粗、数量不多的场景。
知识体系总览
下面这张图总结了本章的核心知识点和它们之间的关系:
总结
并发编程的核心就这几样东西:线程怎么创建、数据怎么保护、线程间怎么通信、怎么避免死锁。掌握了这些基础,大部分并发场景都能应对。
我个人建议初学者先从std::async入手,它封装了线程创建和结果获取的细节,不容易出错。等熟悉了再深入手动管理线程和锁。毕竟,能简单解决的问题,没必要搞复杂。
嗯,这一章就到这里。记住:并发编程的难点不在于API怎么用,而在于如何设计出正确、高效的同步策略。多写、多调试、多思考,慢慢就有感觉了。