C++ 多线程与 thread

平时我们编写的普通 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 被覆盖。遇到多个线程争抢同一个变量的场景,我们需要使用互斥锁把修改操作保护起来,下一节就会详细介绍。

上一篇:

下一篇:

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号