平时我们编写的普通 C++ 程序,都是从 main() 函数开始,一条语句接着一条语句从上往下顺序执行的。如果前一个操作耗时较长,比如正在下载一个大文件,后面的代码就只能停在原地等待。
在实际开发中,我们经常希望程序能够同时处理多件事情,比如一边在后台下载文件,一边继续响应界面上的点击操作。在 C++ 中,我们可以通过 <thread> 头文件提供的多线程工具,让多条执行路线同时往前推进。
什么是进程与线程?
初次接触并发编程时,很多小伙伴容易把 “进程” 和 “线程” 混为一谈。其实这两个概念很好区分:
- 进程(Process):一个正在运行的程序就是一个进程,比如电脑上打开的浏览器或音乐播放器。每个进程都拥有独立的一块内存空间,不同进程之间默认互不干扰。
- 线程(Thread):线程是进程内部的一条执行路线,一个进程里可以同时包含多条线程。其中,程序启动时默认执行
main()函数的那条路线叫做 “主线程”,由我们手动创建出来的其他路线叫做 “子线程”。
你可以把 “进程” 想象成一家餐厅的后厨,而 “线程” 就是后厨里的厨师。如果后厨里只有一名厨师,切菜、炒菜、装盘就只能一件接一件排队做。如果请了多名厨师,大家就可以在同一个后厨里分工合作,同时推进多道菜的制作。

提示 同一个进程里的所有线程都共用同一块内存空间,因此它们交换数据非常方便。但也正因如此,一旦多个线程同时修改同一个变量,就很容易引发数据冲突。
使用 std::thread 创建线程
在 C++ 中,我们可以使用标准库提供的 std::thread 类来创建并管理线程,使用前需要先通过 #include <thread> 引入头文件。
语法:
#include <thread>
std::thread 线程对象名(函数名, 参数1, 参数2, ...);
线程对象名.join();说明:
创建 std::thread 对象时,第 1 个参数传入子线程要执行的函数名。如果该函数需要接收参数,我们只需要把实参依次跟在函数名后面即可。在使用 std::thread 时,有以下 2 点需要特别注意:
- 创建完成时立即启动:当
std::thread对象定义完成的那一刻,子线程就已经在后台启动并开始执行传入的函数了,并不是等到调用join()时才开始运行。 - 调用 join() 等待汇合:
join()成员函数的作用是让主线程停在这一行,等待该子线程把任务执行完毕,然后再继续往下走。
示例 1:创建无参子线程
#include <iostream>
#include <thread>
void printMessage() {
std::cout << "子线程:欢迎来到绿叶网!\n";
}
int main() {
std::thread worker(printMessage); // 创建并启动子线程
worker.join(); // 等待子线程结束
std::cout << "主线程:子线程已汇合,程序结束\n";
return 0;
}运行结果如下。
子线程:欢迎来到绿叶网!
主线程:子线程已汇合,程序结束分析:
std::thread worker(printMessage); 表示创建一个名为 worker 的线程对象,并立即启动子线程去执行 printMessage() 函数。随后的 worker.join(); 会让主线程停在原地等待,直到子线程执行完毕,主线程才继续往下输出最后一句话。
需要注意的是,创建线程时传入的是函数名 printMessage 本身,后面千万不要加上小括号写成 printMessage()。一旦加上小括号,就会变成在主线程里直接调用该函数,再尝试把它的 void 返回值传给 std::thread,从而导致编译报错。
另外,如果在 Linux 环境下使用 g++ 命令编译多线程程序,记得加上 -pthread 参数,否则可能会出现链接报错。
除了创建单个无参子线程,我们还可以同时启动多个子线程并发执行任务。如果线程函数带有参数,只需要在创建 std::thread 时把实参依次写在函数名后面即可。下面我们来看一个例子。
示例 2:启动多个带参子线程
#include <iostream>
#include <thread>
void runTask(int id, int steps) {
for (int i = 1; i <= steps; i++) {
std::cout << "子线程 " << id << ":第 " << i << " 步\n";
}
}
int main() {
std::thread t1(runTask, 1, 3); // 启动子线程 1
std::thread t2(runTask, 2, 3); // 启动子线程 2
t1.join(); // 等待子线程 1 结束
t2.join(); // 等待子线程 2 结束
return 0;
}运行结果如下。
子线程 1:第 1 步
子线程 1:第 2 步
子线程 2:第 1 步
子线程 1:第 3 步
子线程 2:第 2 步
子线程 2:第 3 步分析:
从运行结果可以看出,子线程 1 和子线程 2 的打印内容是穿插出现的。这是因为 t1 和 t2 创建后会同时在后台往前推进,各自把传入的实参赋给 runTask() 的形参 id 和 steps。
这里顺便提一点:操作系统每次先调度哪一个线程并不固定,因此小伙伴们在自己电脑上运行时,看到的输出顺序可能和上面不同。有时还会看到两个线程的文字挤在同一行,这是因为单条语句里的多个 << 是分步输出的,中途可能会被另一个线程插队打印。
在上面的例子中,参数都是按值传递的。如果我们希望在子线程里直接修改主线程的变量,把函数形参定义为引用(如 int& result),能不能直接把普通变量传进去呢?
答案是不行的,直接传入普通变量会导致编译报错。这是因为 std::thread 默认会把传入的所有实参先拷贝一份存到线程内部。如果允许引用形参直接绑定这份内部副本,子线程改了半天也影响不到外面的原变量,所以编译器干脆直接报错拦截。
如果确实需要按引用传递原变量,我们可以使用 <functional> 头文件中的 std::ref() 把实参包装一下,明确告诉 std::thread 传递原变量的引用。
示例 3:通过 std::ref() 传引用
#include <functional> // 提供 std::ref()
#include <iostream>
#include <thread>
void calcSquare(int n, int& result) {
result = n * n;
}
int main() {
int res = 0;
std::thread worker(calcSquare, 6, std::ref(res));
worker.join();
std::cout << "计算结果:" << res << "\n";
return 0;
}运行结果如下。
计算结果:36分析:
在这个例子中,我们调用 std::ref(res) 生成了一个引用包装对象,让子线程里的形参 result 直接绑定主线程中的变量 res。子线程算完 6 * 6 后,主线程里的 res 就随之变成了 36。
因此,当线程函数的形参是引用时,小伙伴们一定要区分下面这两种写法。
// 正确方式
std::thread t1(calcSquare, 6, std::ref(res));
// 错误方式
std::thread t2(calcSquare, 6, res);除了单独定义普通函数,在实际开发中,我们也可以直接把 Lambda 表达式交给 std::thread 执行。如果需要在子线程中修改主线程的变量,直接在捕获列表里写成 [&res] 按引用捕获即可,不需要再额外使用 std::ref()。
示例 4:传入 Lambda 表达式
#include <iostream>
#include <thread>
int main() {
int n = 10;
int res = 0;
std::thread worker([n, &res]() {
res = n * n;
});
worker.join();
std::cout << "Lambda 计算结果:" << res << "\n";
return 0;
}运行结果如下。
Lambda 计算结果:100分析:
[n, &res]() 表示按值捕获变量 n,同时按引用捕获变量 res。当子线程要执行的逻辑只有短短几行代码时,直接传入 Lambda 表达式往往比在外面单独定义一个函数更加直观方便。
线程的收尾:join() 与 detach()
在 C++ 中使用 std::thread 时,有一条必须遵守的规则:任何一个关联了活动子线程的 std::thread 对象,在它离开作用域销毁之前,我们都必须明确调用 join() 或 detach() 完成收尾。
1. 使用 join() 等待线程结束
如果我们创建了一个 std::thread 对象,却忘了在它销毁前调用 join() 或 detach(),那么当 main() 函数结束、线程对象析构时,程序就会直接调用 std::terminate() 异常终止。因此,下面这种漏写收尾语句的写法是错误的。
int main() {
std::thread worker(printMessage);
return 0; // 漏写 join() 导致异常终止
}另外,同一个线程对象只能调用一次 join()。如果项目分支逻辑较复杂,不确定某个线程对象之前是否已经汇合过,我们可以先调用 joinable() 成员函数检查它当前是否可以汇合,比如:
if (worker.joinable()) {
worker.join(); // 确认可汇合后再调用
}2. 使用 detach() 分离线程
除了 join(),std::thread 还提供了另一个收尾函数 detach()。从英文意思上就很容易记住,detach 表示 “分离”。它的作用是切断 std::thread 对象与底层子线程之间的关联,让子线程在后台独立运行,主线程不再等待它:
std::thread worker(printMessage);
worker.detach(); // 分离到后台独立运行注意 初学阶段千万不要把 detach() 当成省去 join() 的偷懒写法。一旦主线程先运行结束,后台还没跑完的子线程就会被强行中断。如果它还在访问主线程里已经销毁的局部变量,更会直接引发内存错误。因此,在日常开发中我们应该优先使用 join()。
C++20 std::jthread 自动汇合(推荐)
前面学习 RAII 机制时我们知道,现代 C++ 提倡把资源的释放交给对象的析构函数自动完成,而不是每次都靠我们手动记着去收尾。
既然 std::thread 漏写 join() 会导致程序异常终止,C++20 就在 <thread> 头文件中新增了升级版的 std::jthread,其中 jthread 是 “joining thread(自动汇合线程)” 的缩写。
它的创建写法和 std::thread 完全一样。最大的改进在于:当 std::jthread 对象离开作用域销毁时,如果子线程还没有汇合,它的析构函数会自动调用 join() 等待子线程执行完毕。
示例 5:std::jthread 自动汇合
#include <iostream>
#include <thread>
void work() {
std::cout << "子线程:jthread 任务正在执行\n";
}
int main() {
{
std::jthread worker(work); // 出花括号自动调用 join()
}
std::cout << "主线程:worker 已自动收尾完毕\n";
return 0;
}运行结果如下。
子线程:jthread 任务正在执行
主线程:worker 已自动收尾完毕分析:
在这个例子中,我们将 std::jthread worker(work); 定义在一对局部花括号 {} 内部,全程没有手写 worker.join()。当程序执行到右花括号 } 时,worker 离开作用域析构,会自动等待 work() 执行结束,随后才继续打印主线程的提示。
在支持 C++20 的项目中,我们推荐优先使用 std::jthread。如果在本地命令行使用 g++ 编译时提示找不到 jthread,记得加上 -std=c++20 参数开启 C++20 标准支持。
常见问题
1. 开启的线程越多,程序的运行速度就一定越快吗?
不一定。创建线程以及操作系统在多个线程之间来回切换,本身都需要消耗额外的时间和内存。而且电脑同一时刻能真正并行处理的线程数受限于硬件的 CPU 核心数量,平时我们可以通过 std::thread::hardware_concurrency() 查看当前环境的硬件并发线程数。
如果一个计算任务本身非常简单,创建和切换线程花掉的时间甚至比直接算完还要长。只有当任务计算量较大,或者包含文件读写、网络请求等等待操作时,使用多线程才能明显提升效率。
2. 如果两个线程同时修改同一个普通变量,会发生什么?
会发生 “数据竞争”,导致算出来的结果出错,甚至引发未定义行为。也就是说,运行结果不再可控,程序可能算出错误数值或直接崩溃。
举个例子,假设两个线程同时对 int counter = 0; 各自执行 100000 次 counter++。运行过程中双方很可能会同时读到同一个旧值,各自加 1 后再写回内存,导致其中一次加 1 被覆盖。遇到多个线程争抢同一个变量的场景,我们需要使用互斥锁把修改操作保护起来,下一节就会详细介绍。
