10. 多线程基础:std::thread 的使用、线程创建与销毁的开销、线程池设计
10.1 从单线程到多线程:为什么我们需要它?
说实话,我刚入行那会儿,对多线程是有点发怵的。总觉得单线程跑得好好的,干嘛要搞这么复杂?直到有一次,我负责一个图像处理模块,单线程处理一张 4K 图片要 3 秒多。用户反馈说「太慢了」,产品经理天天站我身后催。嗯,那时候我才真正意识到——单线程的算力天花板,是真实存在的。
多线程的核心价值,说白了就两条:利用多核 CPU 并行计算,以及在 I/O 等待时不让 CPU 闲着。你想想看,现代 CPU 动不动就 8 核 16 核,你只用一个核,那剩下的 15 个核都在看热闹,多浪费啊。
10.2 std::thread 的基本用法
C++11 标准库给我们带来了 std::thread,这东西用起来其实挺直观的。我习惯把它理解成「一个可以独立运行的函数」。
#include <thread>
#include <iostream>
void worker(int id) {
std::cout << "线程 " << id << " 开始工作\n";
// 模拟一些计算
for (int i = 0; i < 1000000; ++i);
std::cout << "线程 " << id << " 结束\n";
}
int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join(); // 等待 t1 完成
t2.join(); // 等待 t2 完成
return 0;
}
这里有个坑,我必须提醒你:线程对象析构前,必须调用 join() 或 detach()。否则程序会直接崩溃。我曾经在项目里见过一个同事,忘了 join,结果 debug 了一下午才找到原因。
detach() 要格外小心。分离后的线程就像脱缰的野马,你无法再控制它。如果主线程退出了,子线程可能还在访问已经销毁的资源——这会导致未定义行为。
10.3 线程创建与销毁的开销
很多人以为线程就是「轻量级」的,创建销毁没什么成本。其实不然。我做过一个测试,在 Linux 上用 std::thread 创建并立即 join 一个空线程,大概需要 几微秒到十几微秒。听起来不多对吧?但如果你要处理成千上万个任务,这个开销就不可忽视了。
| 操作 | 大致耗时 | 说明 |
|---|---|---|
| 创建线程 | ~5-15 μs | 包括分配栈空间、设置 TLS 等 |
| 销毁线程 | ~2-5 μs | 回收资源、通知内核 |
| 线程上下文切换 | ~0.5-2 μs | 取决于 CPU 架构和负载 |
| 一次函数调用 | ~几纳秒 | 对比之下,线程开销巨大 |
为什么会这样?因为线程创建涉及系统调用、内核分配栈空间、初始化线程局部存储(TLS)等一系列操作。你想想看,每次创建线程都要走一遍内核,这能不慢吗?
10.4 线程池设计:复用才是王道
线程池的核心思想很简单:提前创建好一批线程,让它们等着干活。有任务来了就派一个空闲线程去处理,处理完线程不销毁,继续等下一个任务。
我设计线程池时,一般会考虑这几个要素:
- 任务队列:存放待执行的任务,通常是
std::function<void()>类型 - 工作线程:一组线程,不断从队列取任务执行
- 同步机制:用互斥锁保护队列,用条件变量通知线程有新任务
- 停止标志:通知线程池关闭时,所有线程优雅退出
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <atomic>
class ThreadPool {
public:
ThreadPool(size_t numThreads) : stop(false) {
for (size_t i = 0; i < numThreads; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(this->queueMutex);
this->condition.wait(lock, [this] {
return this->stop || !this->tasks.empty();
});
if (this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task(); // 执行任务
}
});
}
}
template<class F>
void enqueue(F&& f) {
{
std::lock_guard<std::mutex> lock(queueMutex);
tasks.emplace(std::forward<F>(f));
}
condition.notify_one();
}
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(queueMutex);
stop = true;
}
condition.notify_all();
for (std::thread &worker : workers) {
worker.join();
}
}
private:
std::vector<std::thread> workers;
std::queue<std::function<void()>> tasks;
std::mutex queueMutex;
std::condition_variable condition;
std::atomic<bool> stop;
};
10.5 线程池的核心流程
为了让你更直观地理解线程池的工作方式,我画了一张流程图:
10.6 线程数量怎么定?
这个问题我经常被问到。答案其实不复杂:
- CPU 密集型任务:线程数 = CPU 核心数(或核心数 + 1)
- I/O 密集型任务:线程数可以多一些,比如核心数 × 2 或更多
- 混合型任务:需要实际压测,没有银弹
我曾经在一个网络服务项目里,把线程数设成了 64(机器是 16 核)。结果性能反而下降了。为什么?因为线程太多,上下文切换的开销把 CPU 时间都吃掉了。后来我调成 32,效果反而更好。
std::thread::hardware_concurrency() 获取硬件支持的线程数。但要注意,这个值不一定准确——有些 CPU 会报告超线程数量,实际物理核心可能只有一半。
10.7 避坑指南
最后,分享几个我踩过的坑:
- 数据竞争:多个线程同时读写同一变量,不加锁。用
std::mutex或原子操作解决。 - 死锁:两个线程互相等待对方释放锁。我习惯用
std::lock一次性锁住多个互斥量,避免死锁。 - 虚假唤醒:条件变量可能在没有 notify 的情况下被唤醒。所以一定要在循环中检查条件。
- 线程局部变量:每个线程有自己的栈,但全局变量和堆内存是共享的。注意区分。
嗯,多线程编程就是这样——看起来简单,但细节里藏着魔鬼。不过别怕,多用多练,慢慢就熟练了。
公众号:蓝海资料掘金营,微信deep3321