现代 CPU 一般由多个内核组成,可以同时执行多重指令,以加快程序的整体执行速度。多线程应用程序得以实现的首要原因,就是同时利用了多个内核,实现线程之间的共享内存同步。如果线程之间不需要共享状态,那么最好采用不同的输入,运行同一程序的多个实例,作为不同的进程。
多线程应用程序并非总能加快程序运行速度。在分析要求之前,就预先进行优化,并实现多线程,这种做法其实是错误的。多线程编程的编写和调试往往更复杂。线程通过访问共享内存来进行协调和“通信”,因此在没有适当同步的情况下,可能会出现数据竞争(官方定义见下文),进而可能导致程序崩溃。
例如,假设在非 CPU 密集型程序中,进程速度变慢并非因 CPU 所致——这个问题并不是我们需要消除的瓶颈。在这种情况下,升级 CPU 或添加其他线程来分担计算工作,并不会提高吞吐量。
不过,也有许多程序可以从多线程运行中获益。例如,当我们遇到 I/O 阻塞,导致应用程序速度变慢时,新增一个处理 I/O 的线程,就可以加快进程。有些应用程序必须使用多线程。比如,现代操作系统往往需要同时访问多个资源。关于应用程序何时需要多线程,何时不需要多线程的精彩讨论,请观看 Ansel Sermersheim 在 CppCon 2017 上的演讲:“多线程是答案,那么什么是问题?”
本文将讨论采用现代 C++ 语言的多线程编程。首先介绍多线程编程的基础,然后再深入分析若干主题,包括无等待和无锁数据结构和算法等。
C++11 之前
C++ 大约于 1985 年问世,但直到 C++11,其标准规范内才出现线程的概念。标准规定了 C++ 编译器和标准库实现者的相关要求, C++ 开发人员可以遵循这些规范编写代码。
如果规范中没有线程相关要求,那我们就不得不另想办法来创建多线程编程。
过去,我们不得不依赖外部库开发 C++ 多线程编程。例如,曾经在 C 和 C++ 中,人们就将 POSIX 线程库作为标准的线程 API。过去,我们利用 pthreads 创建应用程序,因此,在创建多线程应用程序时,我们就不得不同时遵守两个独立的规范,即适用于通用代码的 C++ 规范以及适用于多线程的 POSIX 规范。
然而,即便遵循了这两个规范,在使用标准库容器和函数时,我们还是会受到一定限制。
举个小例子:假设在 C++11 以前的代码中,有两个线程同时调用同一标准库容器的两个 const 成员函数,那么这时,线程安全吗?在 C++11 以前的语言中,这两个 const 成员函数的实现可能已经潜移默化地改变了可变数据成员,进而在数据竞争的作用下,导致未定义行为。我们可以深入研究容器的实现细节,或者相信这样的并发调用是线程安全的,但是我们并不能编写与库无关的线程安全代码。随着 C++11 的到来,这一切都改变了。
自 C++11 以来
C++11 最终使“编写跨平台的、与库无关的线程安全代码”成为可能,只要在编写时遵循 C++ 规范即可。它(1)引入了 std::thread、std::mutex、std::atomic<>、std::future<> 等类型;同时还(2)定义了内存模型并设定了明确的保证条件,确保创建的实现线程安全。
例如,规范允许我们在线程安全代码中使用 std 容器:
“C++ 标准库函数不能直接或间接修改当前线程以外的其他线程可以访问的对象 (6.9.2),但通过函数的非 const
参数,包括此(参数),直接或间接访问对象时除外。”(摘自:https://isocpp.org/files/papers/N4860.pdf)
我们首先简要探讨在 C++ 中定义基本多线程保证所需的关键概念。从此处开始,我们提到的 C++ 标准,指的是自 C++11 就开始生效,到 C++20 仍适用的标准。C++11 之后才出现的变更会明确加以说明。首先,我们来探讨几个关键概念:
线程
在标准中,执行线程指的是程序中的单个控制流,包括特定函数的初始调用,以及该线程后续执行的各函数调用。注意:一个线程可以创建另一个线程。
内存位置
内存位置可以是标量类型(算术类型、指针类型、枚举类型或 std::nullptr_t)的对象,也可以是所有非零宽度的相邻位字段的最大序列(点击此处查看示例)。
根据 C++ 标准定义,两个或以上执行线程可以分别访问单独的内存位置,而不会相互干扰。
请注意,在内存模型中,标准将“字节”定义为内存的最小可寻址单元。每个字节都有唯一的地址,并且必须支持至少 256 个不同的值。实际上,现在的字节通常包括 8 位。
数据竞争
当两个或以上线程同时访问同一内存位置,且其中一个或多个线程执行写入操作时,会出现竞争条件或数据竞争。多个同时读取操作都是绝对精细和线程安全的。当一个线程试图更改可寻址字节的值时,根据规范,另一个线程观察到的值可能是任何值,这就导致程序的行为未定义。即使我们自认为已经理解了这种架构,但我们也不能对该值进行任何假定。这一点我们会在后续进一步讨论,同时还会讲到数据竞赛的具体架构以及可能的结果。
根据以上定义,我们来看看下面的简单代码:
#include <iostream>
#include <thread>
void increment(int* a_ptr) {
++*a_ptr;
}
int main() {
int a = 0;
std::thread th1(increment, &a);
std::thread th2(increment, &a);
th1.join();
th2.join();
std::cout << "a = " << a << '\n';
}
以上程序有几个线程?
三个 —— 主线程以及由 std::thread 对象创建的两个线程:th1 和 th2。
这两个线程都包含一个作为参数(增量)的函数以及用于调用增量的参数(即地址 a,其含义是 &a)。构建完成后,std::thread 使用这些参数的副本调用增量(注意,有关线程参数的复制/传参,我们将在其它博文中深入讨论)。
这两个线程创建完成后,我们在两个线程对象上调用 join():
th1.join();
th2.join();
th1.join() 等待 th1 执行完增量后加入主线程,然后 th2.join() 等待 th2 加入。第一行代码执行之后,保证 th1 已经完成,第二行执行之后,保证两个线程均已完成。注意,在 th1 完成之前,th2 调用的函数可能已经执行完毕。有关 join()、detach() 等内容,我们会在后续深入分析。
输出值是什么?
当我们对“a”增量两次后,就得到:
a = 2
然而,看看这段代码运行一千次后的结果:
http://coliru.stacked-crooked.com/a/b417d145ea99907b
运行 998 次之后:
a = 2
再运行两次之后:
a = 1
为什么结果是 a = 1?
++*a_ptr 是一个“读–改–写”指令,实际上包含三个指令。例如,在 X86 等典型系统架构上,这个“读-改-写”指令的执行步骤如下:
- 将内存位置“a”处的值读取到 CPU 寄存器中
- 将同一 CPU 寄存器中的值修改为 +1
- 将数值 +1 写回同一内存位置“a”处
读写操作与缓存进行交互,并不直接访问主内存。
在其中一个线程完成写入操作 (3) 之前,两个线程均可能执行读取操作 (1)。一般来说,假设线程 th1 执行操作 (1),那么下列指令执行后,*a 中的值最后为 1:
- 线程 th1 执行操作 (1) 并将值 0 读取到寄存器中
- 线程 th1 执行操作 (2) 并将寄存器中的值修改为 1
- 线程 th2 执行操作 (1) 并将值 0 读取到寄存器中
- 线程 th1 执行操作 (3) 并将值 1 写入 *a_ptr
- 线程 th2 执行操作 (2) 并将寄存器中的值修改为 1
- 线程 th2 执行操作 (3) 并将值 1 写入 *a_ptr
如果 3 在 2 之前执行或 3 与 2 同时执行,那么即使 5 和 6 在 2 之前执行或与 2 同时执行,结果相同。即便运行多次执行命令,结果依然相同,a = 1。但是,如果其中一个线程在另一个线程执行读取指令 (1) 之前已经彻底完成了写入操作 (3),那么运行结束时,*a 内写入的值将为 2。
为了确保程序的输出具有确定性,我们需要将这些“读-改-写”操作设置为原子操作,这意味着,当一个线程执行包含三个指令的 ++*a_ptr 时,另一个线程无法读取或写入 *a_ptr。
还有一种方法就是将内存位置”a”标记为 std::atomic 而不是 int。也就是说,当一个线程试图写入该内存位置时,其他要观察 a 值的线程无法看到 a 值。只有在前述线程彻底完成写入操作之后,或者在操作开始之前(而不是操作期间),才能看到 a 值。换言之,就是在整个读-改-写操作之前或之后。
切记,几乎在所有现代系统架构中,该值都是从缓存系统中读取和/或写入缓存系统,而不是直接与主内存交互。这种机制导致上述干扰出现的几率更高。根据标准规范,在任何情况下,只要代码可能存在数据竞争,我们就应该将其视为未定义行为。如果一个线程执行写入操作,那么我们不能对另一线程所观察到的值进行任何假设。如果同步不当,多线程编程可能会崩溃,甚至可能在更糟糕的情况下产生 bug,但这种 bug 并不会导致程序崩溃。
回想一下,我们已经看到,这段代码运行 1000 次的过程中,结果为 a = 2 的时间占 99.8%。那么为什么结果为 a = 1 的几率如此之小?
这是因为,相对于在不同的核心上创建并运行新的执行线程,运行函数增量所需的时间极短。
结论
此例表明,在多线程编程中,即使大部分运行的结果都是正确的,但最后仍有可能出错。结果取决于正在执行的指令的实际顺序,因为这些指令的运行顺序可能会改变。因此,为了确保代码正确,我们需要适当的逻辑证明,并且进行比单线程程序更为密集的测试。
在编写多线程编程时,我们需要确保不写入任何数据竞争。后续,我们会讨论修复这种代码的其他方式,不过目前,我们只分析利用 std::atomic,将 int 设置为原子操作,从而避免数据竞争:
#include <iostream>
#include <thread>
#include <atomic>
void increment(std::atomic<int>* a_ptr) {
++*a_ptr;
}
int main() {
std::atomic<int> a = 0;
std::thread th1(increment, &a);
std::thread th2(increment, &a);
th1.join();
th2.join();
std::cout << "a = " << a << '\n';
}
对于本次博文中我们审查的简单代码示例,我们看到,实际上,指令可能会按几个不同的顺序执行,同时,(在非原子/固定模式下)并非所有运行的结果都相同。指令执行的实际顺序可能与我们编写的源代码不同。这可能是编译器优化所产生的结果,也可能是 CPU 执行了预取、推测等操作,对读写操作进行了重新排序。因为存在写入缓冲区、私有/共享缓存机制等,缓存系统也可能会导致此类重新排序。最终结果都一样:后编写的程序先执行了。
未来我们会继续深入讨论。不过好在,从证明代码来看,上述潜在重新排序的根源对我们来说应该无关紧要,因为任何此类重新排序都可以视为 C++ 源代码表达式的重新排序。只要我们时刻牢记:线程之间的代码可能会重新排序和交错,就可以减少此类问题的发生。因此,如果加载(读)和存储(写)不一定按我们所编写的顺序执行,我们就必须考虑使用原子和互斥等数据同步原语,保证多线程运行。
幸运的是,这些原语提供了有力的保证,即使同时执行多个指令,我们也能判断程序是否正确。在后续的博文中,我们还会探讨如何利用 memory_order,在默认行为保证的基础上实现更高的灵活性,从而在保证程序正确的前提下提高性能。如果您想立即深入了解更多内容,请观看 Herb Sutter 的精彩演讲,他在讲话中就谈到了执行顺序相关的内容:C++ 及 2012 年以后:Herb Sutter – 原子武器 Incredibuild 加速了 C++ 编译过程,缩短了等待构建完成的时间,这样一来,您就可以有更多的时间来精进代码编写。现在就来试试吧!