C++ 多线程入门到实战:从手动线程到线程池完整教程(详解版)
一、为什么需要多线程
1.1 单线程的瓶颈
假设你的电脑 CPU 有 4 个核心,但你的程序只用了一个核心。处理 10 张图片,每张 1 秒:
-
单线程:10 秒
-
4 个线程同时工作:理想情况 2.5 秒
多线程可以让程序同时使用多个 CPU 核心,大幅缩短批量任务的执行时间。如果你的程序需要同时处理很多互不依赖的任务(如批量下载、图像处理、数据转换),多线程几乎是必选项。
二、基础:手动创建线程
2.1 最简单的线程创建(正确版)
#include <iostream>
#include <thread>
#include <chrono>
#include <string>
using namespace std;
// 线程要执行的函数
void process_image(const string& img) {
this_thread::sleep_for(chrono::seconds(1)); // 模拟耗时处理
cout << img << " 处理完成" << endl;
}
int main() {
// 创建两个线程,分别执行 process_image
thread t1(process_image, "1.jpg");
thread t2(process_image, "2.jpg");
// 等待两个线程结束
t1.join();
t2.join();
cout << "所有任务完成" << endl;
return 0;
}
2.2 创建线程的语法
thread 线程对象(要执行的函数, 参数1, 参数2, ...);
-
第一参数可以是普通函数、lambda 表达式、函数对象。
-
后面的参数会原样传递给函数。
-
线程对象一旦创建,线程立刻开始执行,不会等
join。
2.3 join 是什么?为什么必须调用?
t.join() 的意思是:“主线程在此等待,直到线程 t 执行完再继续”。
错误写法:忘记 join
int main() {
thread t1(process_image, "1.jpg");
// 忘记写 t1.join()
cout << "主线程结束" << endl;
return 0;
} // t1 析构,但线程还在运行 -> 程序崩溃
后果:线程对象 t1 在 main 结束时析构,但此时它关联的线程仍在运行(或者已经结束但未被回收)。C++ 标准规定,如果线程对象在销毁时仍处于“joinable”状态(即未 join 也未 detach),就会调用 std::terminate(),导致整个程序强制崩溃。
你可能会看到类似 terminate called without an active exception 的错误。
错误写法:忘记 join,还想用 detach 逃避
int main() {
thread t1(process_image, "1.jpg");
t1.detach(); // 分离线程
return 0;
}
detach() 将线程与线程对象脱离关系,线程在后台独立运行。此时 main 退出后,后台线程可能还未执行完,这时它访问的局部变量(如果传了引用)会变成悬空引用,导致未定义行为。初学者一般不推荐使用 detach()。
三、共享资源的线程安全问题
3.1 问题:cout 不是线程安全的
多个线程同时调用 cout 输出内容时,每个线程可能会在输出一半时被另一个线程打断,导致文字交错、乱序,甚至程序崩溃。
错误示例:无保护打印
void process_image(const string& img) {
this_thread::sleep_for(chrono::seconds(1));
cout << img << " 处理成功!" << endl; // 多线程同时写
}
运行结果可能出现:
1.jpg 处理成功2.jpg! 处理成功!
或者根本无法阅读的乱码。
为什么会这样?
cout 的底层输出是一个字节流,<< 操作是分多次写入的。线程 A 刚写了一半的字符串,操作系统切换到线程 B,线程 B 也写 cout,两者数据就混在一起了。这种数据竞争属于未定义行为。
解决方案:加互斥锁(mutex)
#include <mutex>
mutex print_mtx; // 全局互斥锁,仅用于保护输出
void process_image(const string& img) {
this_thread::sleep_for(chrono::seconds(1));
{
lock_guard<mutex> lock(print_mtx); // 获取锁
// 在这个作用域内,其他线程无法同时获取 print_mtx
cout << img << " 处理成功!" << endl;
} // 离开作用域,锁自动释放
}
lock_guard 原理:
-
构造时自动调用
print_mtx.lock()。 -
析构时自动调用
print_mtx.unlock()。 -
即使中间抛出异常,也能正确解锁,不会死锁。
3.2 问题:多线程共享计数器
多个线程同时修改一个普通 int,会发生数据竞争。
错误示例:普通变量做计数器
int success_count = 0; // 普通变量
void process_image(const string& img) {
this_thread::sleep_for(chrono::seconds(1));
success_count++; // 多线程同时执行
}
后果:假设两个线程同时读到 success_count=0,各自加 1 后写回,最终结果可能仍是 1,丢失了一次增加。这就是经典的“丢失更新”问题。
而且,对普通 int 的并发读写属于未定义行为,编译器可能优化出完全错误的指令。
解决方案:使用原子变量 std::atomic
#include <atomic>
atomic<int> success_count{0};
void process_image(const string& img) {
this_thread::sleep_for(chrono::seconds(1));
success_count++; // 原子操作:要么完全执行,要么没执行
}
atomic<int> 保证 ++ 操作不会被其他线程打断,无数据竞争。
3.3 mutex vs atomic 如何选择?
| 场景 | 推荐 | 原因 |
|---|---|---|
| 单个整数/指针/布尔值的读写、自增 | std::atomic |
硬件直接支持,极快,无锁 |
| 修改多个变量(需要保持一致性) | std::mutex |
必须保证“同时修改”这些变量时不被中断 |
操作 STL 容器(如 vector、map) |
std::mutex |
容器本身不是线程安全的,必须加锁 |
| 需要“读-修改-写”复杂逻辑 | std::mutex |
原子操作只能完成非常简单的步骤 |
四、进阶:生产者 - 消费者模型(共享队列抢任务)
4.1 为什么需要任务队列?
手动给每个线程分配任务(比如 t1 处理前 5 张,t2 处理后 5 张)太僵硬:
-
如果有的图片处理快、有的慢,先完成的线程会闲着,后完成的还在忙,负载不均衡。
-
任务数量变化时很难分配。
更好的方式:所有任务丢到一个公共队列,多个线程自动从队列里取任务。谁空闲谁就去取下一个,自动均衡。
4.2 模型组件
-
生产者:负责添加任务(主线程)。
-
消费者:负责取任务并执行(工作线程)。
-
共享队列:存储任务。
-
同步机制:互斥锁保护队列,条件变量用于等待/通知。
4.3 初学者的典型错误:忙等待
错误写法:工作线程里循环检测队列
void worker() {
while (!done) {
if (!task_queue.empty()) { // 无锁检查,错误!
// 取任务...
}
}
}
同时主线程可能这样检查任务是否全部完成:
while (true) {
if (task_queue.empty()) break;
}
问题:
-
不加锁读取队列 -> 数据竞争 -> 未定义行为。
-
即使加了锁,这种循环也会让 CPU 一个核跑到 100%,空耗电力,还影响其他线程的性能。这叫做忙等待(busy waiting)。
✅ 正确方式:使用条件变量 std::condition_variable
条件变量能让线程“睡”在某个条件上,直到另一个线程通知它“条件可能满足了”,再醒来检查。
4.4 完整正确实现(带详细注释)
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <string>
#include <atomic>
#include <chrono>
#include <random>
using namespace std;
// 共享资源
queue<string> task_queue; // 任务队列
mutex queue_mtx; // 保护队列的锁
mutex print_mtx; // 保护输出的锁(分离锁,减少竞争)
condition_variable cv; // 条件变量(用于“有任务”和“任务空”的通知)
atomic<bool> done{false}; // 生产者是否不再添加任务
atomic<int> success_count{0};
atomic<int> fail_count{0};
void worker() {
while (true) {
string img;
// ----- 取任务(加锁保护)-----
{
// 这里必须用 unique_lock,因为条件变量需要手动解锁
unique_lock<mutex> lock(queue_mtx);
// 等待条件:队列不空 OR 生产者结束
// 如果条件不满足,线程会解锁并进入睡眠,不消耗 CPU
cv.wait(lock, []{ return !task_queue.empty() || done; });
// 被唤醒后,检查退出条件
if (task_queue.empty() && done) {
break; // 队列空且不再有新任务,线程退出
}
img = task_queue.front();
task_queue.pop();
} // 锁在这里自动释放
// ----- 处理任务(无锁)-----
this_thread::sleep_for(chrono::seconds(1));
// 线程局部随机数(每个线程只初始化一次,高效)
thread_local mt19937 gen(random_device{}());
uniform_int_distribution<int> dist(1, 10);
int r = dist(gen);
if (r < 9) success_count++;
else fail_count++;
// ----- 打印结果(使用输出锁)-----
{
lock_guard<mutex> lock(print_mtx);
cout << img << (r < 9 ? " 处理成功!" : " 处理失败!") << endl;
}
// ----- 通知主线程:队列可能已空(可选的优化)-----
{
lock_guard<mutex> lock(queue_mtx);
if (task_queue.empty()) {
cv.notify_one(); // 通知等待“队列空”的主线程
}
}
}
}
int main() {
// 1. 生产任务
for (int i = 1; i <= 10; ++i) {
task_queue.push(to_string(i) + ".jpg");
}
// 2. 启动消费者
thread t1(worker);
thread t2(worker);
// 3. 主线程等待队列变空(优雅等待,非忙等)
{
unique_lock<mutex> lock(queue_mtx);
cv.wait(lock, []{ return task_queue.empty(); });
}
// 4. 通知所有消费者可以退出了
done = true;
cv.notify_all(); // 唤醒可能在睡眠的线程,让他们检查 done
// 5. 等待线程回收
t1.join();
t2.join();
// 6. 此时所有线程已结束,可以安全读取统计结果
cout << "\n===== 全部处理完成 =====" << endl;
cout << "成功:" << success_count << " 张" << endl;
cout << "失败:" << fail_count << " 张" << endl;
return 0;
}
4.5 condition_variable 深入理解
cv.wait(lock, 条件函数) 做了什么?
-
检查条件函数,如果返回
true,立刻返回(不睡眠)。 -
如果返回
false,解锁lock,然后阻塞当前线程(进入睡眠,不耗 CPU)。 -
当其他线程调用
cv.notify_one()或notify_all()时,睡眠线程被唤醒。 -
醒来后重新获取锁,再次检查条件函数,如果是
true则返回;否则继续睡。
为什么传 unique_lock 而不是 lock_guard?condition_variable 需要在 wait 时解锁、醒来后重新加锁,而 lock_guard 不允许手动解锁,所以必须用更灵活的 unique_lock。
4.6 初学者最容易忽略的一个后果
忘记 done 标志或判断错误:
如果主线程不再添加任务,但没有设置 done=true,且队列空了,工作线程会永远睡在 cv.wait 上,导致 join() 永远无法返回,程序死锁。
五、终极:线程池的实现与使用
5.1 为什么需要线程池?
手动创建线程有以下问题:
-
创建/销毁开销大:线程的创建涉及系统调用,频繁创建会拖慢程序。
-
数量不可控:如果无限制地开新线程,线程过多会导致操作系统调度开销剧增,甚至内存耗尽。
-
代码重复:每次都要写
thread、join、任务队列、条件变量,容易出错。
线程池就是提前创建好一定数量的线程,让它们复用:线程始终运行,不停地从任务队列取任务执行。你只需要往池子里“扔”任务即可。
5.2 常见错误:误把“固定分配”当成线程池
网上有些教程(甚至旧版本文)可能会写出这样的“线程池”:
// 错误!这不是线程池,只是为每个任务分配一个线程
class FakeThreadPool {
public:
FakeThreadPool(size_t num_threads, const vector<Task>& tasks) {
for (size_t i = 0; i < num_threads; ++i) {
workers.emplace_back(tasks[i]); // 只执行 tasks[i]
}
}
...
};
致命缺陷:
-
要求任务数 ≥ 线程数,否则数组越界崩溃。
-
只能执行与线程数相等的任务,多余的任务根本不被执行。
-
线程没有复用,每个线程只执行一个任务就结束了,退化为手动创建线程。
5.3 正确线程池的核心设计
一个真正的线程池必须具备:
-
任务队列(队列可动态添加任务)。
-
一组预先创建的工作线程,它们在循环中不断从队列取任务。
-
提交任务的接口(
enqueue或submit)。 -
同步机制:互斥锁+条件变量,用于安全地添加/取出任务。
-
优雅退出:析构时通知所有线程结束,并等待它们完成剩余任务。
5.4 正确且极简的线程池实现(可直接保存为 thread_pool.h)
#ifndef THREAD_POOL_H
#define THREAD_POOL_H
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
class ThreadPool {
public:
using Task = std::function<void()>; // 可存放任何可调用对象
// 构造函数:启动 num_threads 个工作线程
explicit ThreadPool(size_t num_threads) : stop(false) {
for (size_t i = 0; i < num_threads; ++i) {
workers.emplace_back([this] {
// 线程主循环
while (true) {
Task 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();
}
});
}
}
// 提交任务(任何可调用对象:函数、lambda 等)
template<typename F>
void enqueue(F&& f) {
{
std::lock_guard<std::mutex> lock(queue_mutex);
// 如果已经停止,通常不应该再添加任务,这里简单起见不抛出异常
tasks.emplace(std::forward<F>(f));
}
condition.notify_one(); // 唤醒一个等待的线程
}
// 析构函数:停止所有线程,等待完成
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all(); // 唤醒所有线程
for (std::thread& worker : workers) {
if (worker.joinable()) {
worker.join(); // 等待线程退出
}
}
}
// 禁止拷贝
ThreadPool(const ThreadPool&) = delete;
ThreadPool& operator=(const ThreadPool&) = delete;
private:
std::vector<std::thread> workers; // 工作线程数组
std::queue<Task> tasks; // 任务队列
std::mutex queue_mutex; // 保护队列的锁
std::condition_variable condition; // 条件变量
bool stop; // 停止标志
};
#endif
5.5 线程池使用示例
#include <iostream>
#include <vector>
#include <string>
#include <mutex>
#include <atomic>
#include <chrono>
#include <random>
#include "thread_pool.h"
using namespace std;
// 保护输出的锁
mutex print_mtx;
atomic<int> success_count{0};
atomic<int> fail_count{0};
void process_image(const string& img) {
this_thread::sleep_for(chrono::seconds(1)); // 模拟工作
// 线程局部随机数生成器(避免每次构造)
thread_local mt19937 gen(random_device{}());
uniform_int_distribution<int> dist(1, 10);
int r = dist(gen);
if (r < 9) success_count++;
else fail_count++;
lock_guard<mutex> lock(print_mtx);
cout << img << (r < 9 ? " 处理成功!" : " 处理失败!") << endl;
}
int main() {
// 准备任务数据
vector<string> images;
for (int i = 1; i <= 10; ++i) {
images.push_back(to_string(i) + ".jpg");
}
// 创建线程池(2 个常驻线程)
ThreadPool pool(2);
// 提交所有任务
for (const auto& img : images) {
pool.enqueue([img]() {
process_image(img);
});
}
// 此处 pool 离开作用域时:
// 1. 析构函数设置 stop=true,并唤醒所有线程
// 2. 工作线程执行完剩余任务后退出循环
// 3. 主线程 join 所有工作线程后,析构结束
// 所以下面输出时,所有任务必然已全部完成
cout << "\n===== 全部处理完成 =====" << endl;
cout << "成功:" << success_count << " 张" << endl;
cout << "失败:" << fail_count << " 张" << endl;
return 0;
}
5.6 为什么析构函数能保证任务全部完成?
析构函数执行顺序:
-
获取
queue_mutex,设置stop = true,释放锁。 -
condition.notify_all()唤醒所有可能在等待任务的工作线程。 -
for循环依次对每个工作线程调用join()。
工作线程被唤醒后,检查 stop && tasks.empty(),如果此时队列里还有任务,条件为 false,线程会继续取任务执行,直到队列真正为空,下一次循环再检查才会退出。主线程在 join 处等待,所以所有任务一定会在析构函数返回前执行完毕。
常见误区:如果你在创建线程池后,立刻又在主线程里打印统计结果,而没有等池子析构,就可能读到不完整的数值。所以如果不想依赖析构等待,可以手动调用一个 wait() 方法,但此处利用析构是最简单可靠的方式。
六、常见坑点总结及后果分析
| 错误行为 | 具体后果 | 正确做法 |
|---|---|---|
忘记 join() 或 detach() |
程序调用 std::terminate() 崩溃 |
务必 join() 或 detach(),初学者优先 join() |
| 在多线程中直接使用普通变量共享 | 数据竞争(丢失更新、读出脏数据),未定义行为,难以调试 | std::atomic 或加锁 |
多个线程同时 cout,不加锁 |
输出乱序、交叠、崩溃 | 用独立的 std::mutex 保护输出 |
| 操作共享容器(队列、vector等)不加锁 | 容器内部结构破坏,程序可能 segment fault | 所有读写容器操作必须在锁内完成 |
| 使用忙等待循环检测条件 | CPU 100% 空转,耗电,影响其他线程性能 | 使用 std::condition_variable 等待/通知 |
条件变量忘记设置 done 或退出条件 |
消费者永久睡眠,join() 永远等待,程序死锁 |
确保有一个“不再生产”的标志,并正确判断 |
| 在任务未完成时访问结果 | 读到不完整/错误的数据 | 确保所有线程 join 后,或析构线程池后再读取 |
| 随机数生成器每次重新构造 | 性能极差,且可能产生相同随机序列 | 使用 thread_local 静态生成器 |
| 锁粒度过大(例如输出和队列用同一把锁) | 不必要的等待,降低并发效率 | 无关资源用不同的锁 |
七、最终通用模板(可直接复制使用)
thread_pool.h
#ifndef THREAD_POOL_H
#define THREAD_POOL_H
#include <vector>
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
class ThreadPool {
public:
using Task = std::function<void()>;
explicit ThreadPool(size_t num_threads) : stop(false) {
for (size_t i = 0; i < num_threads; ++i)
workers.emplace_back([this] {
while (true) {
Task 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<typename F>
void enqueue(F&& f) {
{
std::lock_guard<std::mutex> lock(queue_mutex);
tasks.emplace(std::forward<F>(f));
}
condition.notify_one();
}
~ThreadPool() {
{
std::lock_guard<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for (std::thread& w : workers)
if (w.joinable()) w.join();
}
private:
std::vector<std::thread> workers;
std::queue<Task> tasks;
std::mutex queue_mutex;
std::condition_variable condition;
bool stop;
};
#endif
使用模板(三步走)
#include "thread_pool.h"
#include <vector>
#include <iostream>
// 1. 写你的业务函数
void your_function(const YourParam& param) {
// 你的处理逻辑
}
int main() {
std::vector<YourParam> data;
// 填充数据 ...
// 2. 创建线程池(比如 4 个线程)
ThreadPool pool(4);
// 3. 提交任务
for (const auto& d : data) {
pool.enqueue([d]() {
your_function(d);
});
}
// 4. 离开作用域,析构自动等待所有任务完成
return 0;
}
八、总结
我们一步步解决了:
-
线程的创建与等待,以及忘记
join的崩溃隐患。 -
cout乱序 和 计数器丢失 的原因与修复(锁与原子)。 -
生产者-消费者模型的高效实现,消除了忙等待,学会了条件变量的使用。
-
线程池的真正含义,并通过一个短小但完整正确的实现,展示了如何复用线程。
这个线程池虽“极简”,但包含了任务队列、线程复用、同步通知、优雅退出等工业级基础。充分理解之后,可以根据需求添加功能(如返回值的 std::future、任务优先级等)。
更多推荐

所有评论(0)