多线程编程:线程创建与管理、互斥锁、条件变量、原子操作、future与promise、线程池

多线程编程,说白了就是让程序同时干好几件事。我刚开始学C++那会儿,觉得单线程跑得挺顺的,干嘛要自找麻烦?直到有一次写一个图片处理程序,一张大图要算好几秒,界面直接卡死——用户差点把电脑砸了。嗯,从那以后,我老老实实学起了多线程。

这一章我们聊聊C++11开始引入的标准线程库。它让跨平台多线程编程变得简单多了。你不需要再跟pthread或Windows API打交道,一套代码到处编译。

线程的创建与管理

创建线程其实特别简单。用std::thread,传一个可调用对象进去就行。

#include <thread>
#include <iostream>

void hello() {
    std::cout << "Hello from thread!\n";
}

int main() {
    std::thread t(hello);
    t.join();  // 等待线程结束
    return 0;
}

这里有个坑——必须显式调用join()detach()。否则线程对象析构时会调用std::terminate(),程序直接崩掉。我曾经在项目里见过一个同事,写了上百行代码忘了join,排查了半天……

传参数也很自然:

void print_sum(int a, int b) {
    std::cout << a + b << "\n";
}

std::thread t(print_sum, 3, 4);
t.join();

如果你传的是引用参数,记得用std::ref包装一下。不然线程内部拿到的是拷贝,改不了原变量。

注意:线程一旦detach,就跟你没关系了。它会在后台默默运行,你无法再join它。确保detach的线程在退出前完成所有工作,否则可能访问已销毁的变量。

互斥锁:保护共享数据

多个线程同时读写同一个变量,结果往往是灾难性的。我见过一个日志系统,两个线程同时写文件,最后日志内容混在一起,根本没法看。

std::mutex就是用来解决这个问题的。它保证同一时间只有一个线程能访问被保护的代码段。

#include <mutex>

std::mutex mtx;
int counter = 0;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtx.lock();
        ++counter;
        mtx.unlock();
    }
}

不过手动lock/unlock容易忘。我建议用std::lock_guard,它会在构造时加锁,析构时自动解锁——异常安全,省心省力。

void increment() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);
        ++counter;
    }
}

如果你需要更灵活的控制,比如尝试加锁或延迟加锁,可以用std::unique_lock。它支持手动解锁、移动语义,配合条件变量特别好用。

条件变量:线程间的信号灯

条件变量用于线程间的等待与通知。一个线程等条件满足,另一个线程发信号唤醒它。

典型的场景是生产者-消费者模型。生产者往队列里放数据,消费者等数据来了再处理。

#include <condition_variable>

std::mutex mtx;
std::condition_variable cv;
std::queue<int> data_queue;
bool ready = false;

void producer() {
    std::this_thread::sleep_for(std::chrono::seconds(1));
    {
        std::lock_guard<std::mutex> lock(mtx);
        data_queue.push(42);
        ready = true;
    }
    cv.notify_one();  // 唤醒一个等待的线程
}

void consumer() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, []{ return ready; });  // 等待条件成立
    int val = data_queue.front();
    data_queue.pop();
    // 处理 val ...
}

注意wait()的第二个参数是个lambda——这叫虚假唤醒防护。为什么会有虚假唤醒?操作系统底层的原因,你只要记住:永远在循环或lambda里检查条件。我曾经踩过这个坑,程序偶尔莫名其妙地提前执行,排查了两天才发现是虚假唤醒。

原子操作:无锁的轻量级方案

如果只是对单个整数做加减,用互斥锁有点杀鸡用牛刀。这时候std::atomic就派上用场了。

#include <atomic>

std::atomic<int> counter{0};

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

原子操作底层用的是CPU的CAS指令(Compare-And-Swap),比锁快得多。但它只适合简单的数据类型,比如int、bool、指针。复杂结构体就别想了。

小技巧:如果你不确定用哪种内存顺序,就用std::memory_order_seq_cst(默认值)。它最安全,只是性能稍微差一点。等程序跑通了再优化也不迟。

future与promise:异步结果的传递

有时候你启动一个线程去算东西,算完了想把结果拿回来。用全局变量加锁当然可以,但太丑了。std::futurestd::promise就是干这个的。

#include <future>

int compute() {
    std::this_thread::sleep_for(std::chrono::seconds(2));
    return 42;
}

int main() {
    std::future<int> result = std::async(compute);
    std::cout << "Waiting...\n";
    int val = result.get();  // 阻塞直到拿到结果
    std::cout << "Result: " << val << "\n";
    return 0;
}

std::async是最简单的用法。它自动决定在新线程还是当前线程执行。如果你需要更精细的控制,可以用std::packaged_taskstd::promise手动绑定。

promise和future是一对:promise负责设置值,future负责取走值。它们通过共享状态通信,只能使用一次。

std::promise<int> prom;
std::future<int> fut = prom.get_future();

std::thread t([&prom]{
    prom.set_value(100);
});

int result = fut.get();  // 拿到100
t.join();

线程池:管理线程的艺术

频繁创建和销毁线程开销很大。线程池就是预先创建一批线程,有任务就丢进去执行,没任务就等着。

C++标准库没有内置线程池,但我们可以自己写一个简单的。核心思路:

  • 一个任务队列(用std::queue<std::function<void()>>
  • 一组工作线程
  • 互斥锁保护队列
  • 条件变量通知线程有新任务
class ThreadPool {
public:
    ThreadPool(size_t threads) : stop(false) {
        for (size_t i = 0; i < threads; ++i) {
            workers.emplace_back([this]{
                while (true) {
                    std::function<void()> task;
                    {
                        std::unique_lock<std::mutex> lock(queue_mutex);
                        condition.wait(lock, [this]{
                            return stop || !tasks.empty();
                        });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
        }
    }

    template<class F>
    void enqueue(F f) {
        {
            std::lock_guard<std::mutex> lock(queue_mutex);
            tasks.emplace(std::function<void()>(f));
        }
        condition.notify_one();
    }

    ~ThreadPool() {
        {
            std::lock_guard<std::mutex> lock(queue_mutex);
            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 queue_mutex;
    std::condition_variable condition;
    bool stop;
};

这个线程池虽然简单,但足够应付大部分场景。我在一个网络服务项目里用过类似的实现,处理几千个并发请求毫无压力。

核心要点:
  • 线程数一般设为std::thread::hardware_concurrency(),即CPU核心数
  • 任务队列用std::function包装,可以接受任何可调用对象
  • 析构时先设置stop标志,再通知所有线程,最后join

知识体系总览

下面这张图把本章的核心知识点串起来了。你可以看到,从最基础的线程创建,到高级的线程池,每一步都是在前一步的基础上构建的。

多线程编程知识体系 线程创建与管理 互斥锁 原子操作 条件变量(线程同步) future / promise(异步结果) 线程池(综合应用) 从基础到综合,层层递进

多线程编程的核心就这些了。你想想看,从最基础的线程创建,到互斥锁保护数据,再到条件变量做同步,最后用线程池统一管理——整个体系其实很清晰。

我个人习惯是:能用原子操作就不用锁,能用锁就不用自己写线程池。简单的东西出错的概率小。等真正遇到性能瓶颈了,再考虑更复杂的方案。

好了,这一章就到这里。代码都在上面了,建议你亲手敲一遍,跑一跑。多线程的坑,踩过一次就记住了。


公众号:蓝海资料掘金营,微信deep3321