多线程编程:线程创建与管理、互斥锁、条件变量、原子操作、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包装一下。不然线程内部拿到的是拷贝,改不了原变量。
互斥锁:保护共享数据
多个线程同时读写同一个变量,结果往往是灾难性的。我见过一个日志系统,两个线程同时写文件,最后日志内容混在一起,根本没法看。
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::future和std::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_task或std::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
知识体系总览
下面这张图把本章的核心知识点串起来了。你可以看到,从最基础的线程创建,到高级的线程池,每一步都是在前一步的基础上构建的。
多线程编程的核心就这些了。你想想看,从最基础的线程创建,到互斥锁保护数据,再到条件变量做同步,最后用线程池统一管理——整个体系其实很清晰。
我个人习惯是:能用原子操作就不用锁,能用锁就不用自己写线程池。简单的东西出错的概率小。等真正遇到性能瓶颈了,再考虑更复杂的方案。
好了,这一章就到这里。代码都在上面了,建议你亲手敲一遍,跑一跑。多线程的坑,踩过一次就记住了。