33、多线程编程(上):线程创建、join与detach、互斥锁与锁管理
多线程编程,说白了就是让程序同时干好几件事。我刚开始接触这个概念时,觉得挺神奇的——一个程序怎么能同时跑多个任务呢?后来才明白,这其实是操作系统在快速切换,让我们感觉像是「同时」在执行。
嗯,咱们今天要聊的,就是C++11标准引入的那套多线程工具。说实话,在C++11之前,想写跨平台的多线程代码,那叫一个痛苦——Windows用CreateThread,Linux用pthread_create,API完全不同。现在好了,std::thread一套搞定。
线程创建:std::thread
创建线程其实很简单。你只需要告诉它:嘿,去执行这个函数。
#include <iostream>
#include <thread>
void hello() {
std::cout << "Hello from thread!" << std::endl;
}
int main() {
std::thread t(hello); // 创建线程,执行hello函数
t.join(); // 等待线程结束
return 0;
}
我个人习惯把线程对象看作一个「执行者」。你创建它的时候,它就开始跑了。不需要手动调用start之类的函数。
你也可以传参数给线程函数:
void print_sum(int a, int b) {
std::cout << "Sum: " << (a + b) << std::endl;
}
int main() {
std::thread t(print_sum, 3, 4); // 传参
t.join();
return 0;
}
这里有个坑——参数是按值传递的。如果你想传引用,得用std::ref包装一下。我在项目中遇到过因为忘记用std::ref,导致数据没同步的bug,排查了半天。
join与detach:线程的两种归宿
创建线程后,你必须决定它的「结局」。有两种选择:
- join():主线程等着,直到子线程结束。就像你叫了个外卖,一直等到外卖送到才继续干活。
- detach():主线程不管了,子线程在后台自己跑。就像你叫了个外卖,然后该干嘛干嘛,外卖到了放门口就行。
来看个例子:
#include <iostream>
#include <thread>
#include <chrono>
void worker() {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "Worker done" << std::endl;
}
int main() {
std::thread t(worker);
// 选择一:join
// t.join(); // 主线程会等2秒
// 选择二:detach
t.detach(); // 主线程不等待,直接继续
std::cout << "Main continues..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(3));
return 0;
}
你想想看,如果既没join也没detach,会发生什么?程序会直接崩溃!std::thread的析构函数会调用std::terminate。我曾经在写一个网络库时,忘了处理某个线程的join,结果程序退出时总是异常终止,查了好久才发现。
互斥锁:std::mutex
多线程最大的问题是什么?数据竞争。多个线程同时读写同一个变量,结果就乱套了。
举个例子:
#include <iostream>
#include <thread>
#include <mutex>
int counter = 0;
std::mutex mtx;
void increment() {
for (int i = 0; i < 100000; ++i) {
mtx.lock(); // 加锁
++counter; // 临界区
mtx.unlock(); // 解锁
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << "Counter: " << counter << std::endl;
return 0;
}
没有锁的话,counter最终结果肯定不是200000。为什么?因为++counter不是原子操作,它包含读、改、写三步。两个线程同时读到了同一个值,各自加1再写回去,结果只加了1次。
核心原则:只要多个线程访问同一个可变数据,就必须用锁保护起来。这是多线程编程的第一条铁律。
锁管理:lock_guard与unique_lock
手动lock/unlock有个大问题——容易忘记unlock。如果函数中间抛出异常,unlock可能根本执行不到。这时候就需要RAII(资源获取即初始化)风格的锁管理器。
std::lock_guard:简单粗暴
void safe_increment() {
std::lock_guard<std::mutex> lock(mtx);
// 这里随便写,不用担心解锁
++counter;
// lock_guard析构时自动解锁
}
lock_guard在构造时加锁,析构时解锁。就这么简单。我个人的习惯是,只要不需要手动控制锁的粒度,就用lock_guard。
std::unique_lock:更灵活
unique_lock比lock_guard多了几个功能:可以延迟加锁、提前解锁、转移所有权。来看个例子:
std::mutex mtx;
void flexible_lock() {
std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 先不加锁
// 做一些不需要锁的操作...
lock.lock(); // 需要时再加锁
// 临界区操作...
lock.unlock(); // 提前解锁
// 再做一些不需要锁的操作...
// unique_lock析构时,如果还持有锁,会自动释放
}
我在项目中遇到过需要「条件变量」的场景,那时候unique_lock就是必须的了。因为条件变量的wait函数需要unique_lock,它能在等待期间暂时释放锁,唤醒后再重新获取。
| 特性 | lock_guard | unique_lock |
|---|---|---|
| 构造时加锁 | 是 | 可配置 |
| 手动解锁 | 否 | 是 |
| 转移所有权 | 否 | 是 |
| 与条件变量配合 | 否 | 是 |
| 性能开销 | 极小 | 略大 |
知识体系总览
下面这张图,把今天讲的内容串起来了:
嗯,今天的内容就到这里。多线程编程是个大话题,今天我们只开了个头。线程创建、join/detach、互斥锁、锁管理器——这些是基础中的基础。你想想看,掌握了这些,你已经能写出基本正确的多线程程序了。
不过要提醒一句:多线程的坑远不止这些。死锁、活锁、伪共享、ABA问题……每个都够你喝一壶的。但别怕,先把今天的基础打牢,后面遇到问题才能从容应对。
一句话总结:创建线程用std::thread,管理生命周期用join/detach,保护共享数据用mutex+lock_guard/unique_lock。记住这个组合拳,多线程编程就入门了。