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;
};
🔑 关键点: 上面的代码中,条件变量的等待条件用了 lambda 表达式。这样设计的好处是——即使发生「虚假唤醒」,线程也会重新检查条件,不会误执行空任务。

10.5 线程池的核心流程

为了让你更直观地理解线程池的工作方式,我画了一张流程图:

线程池工作流程 主线程 任务队列 (互斥锁保护) 条件变量 notify 工作线程池 线程 1 线程 2 线程 3 线程 4 执行任务(task()) 执行完后回到线程池等待下一个任务 循环等待

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