第28章 多线程编程基础
第28章 多线程编程基础
- 使用std::thread创建与管理线程
- 理解并解决数据竞争与死锁问题
- 使用互斥量、条件变量进行线程同步
想象一下厨房里只有一个厨师,他必须依次完成洗菜、切菜、炒菜,这就是单线程。如果引入两个厨师同时工作,一个负责洗切,一个负责烹饪,效率就大大提升,这就是多线程的核心价值——让程序“同时”做多件事,充分利用现代多核CPU的计算能力。
什么是线程?
生活类比:一个进程好比一家公司,线程就是公司里并行工作的员工。公司(进程)拥有公共资源(办公室、打印机),员工(线程)共享这些资源,但各自独立执行自己的任务(写代码、做设计)。
技术定义:线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,所有线程共享进程的内存空间(如堆、全局变量),但拥有独立的栈空间和寄存器状态。
C++多线程支持的发展
C++在2011年发布的C++11标准之前,多线程编程依赖平台特定的API,如Windows的CreateThread或POSIX的pthread_create。这导致代码可移植性极差。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)是多线程编程中另一个经典问题。典型场景是两个线程各自持有一把锁,同时试图获取对方持有的锁,于是两者都无限期地等待下去。
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占用率有何变化?
检验你的理解
- 如果忘记调用
join()或detach(),std::thread对象析构时会发生什么? - 为什么说
if(!queue.empty()) { queue.pop(); }这段代码在多线程环境下是不安全的,即使empty()和pop()内部都加了锁? std::lock_guard和std::unique_lock最主要的区别是什么?在什么场景下必须使用后者?- 条件变量
std::condition_variable::wait函数为什么要接受一个std::unique_lock和一个谓词(lambda表达式)?
本章小结
- 线程是并发执行的基本单位,C++11通过
<thread>头文件提供了可移植的线程库。 - 数据竞争是未定义行为的根源,必须使用互斥量(如
std::mutex)保护所有共享数据的访问。 - 优先使用RAII锁管理器(
std::lock_guard,std::unique_lock)来避免忘记解锁导致的死锁。 - 条件变量(
std::condition_variable)用于线程间的等待/通知机制,是实现高效生产者-消费者模型的关键。 - 设计线程安全类时,应提供组合操作的原子接口,而不是让用户自己组合加锁的函数调用。
微信扫码关注
抖音扫码关注