现代 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)的对象,也可以是所有非零宽度的相邻位