在本章中,我们将探讨并发(concurrency)的含义及其与并行(parallelism)的区别。我们将深入了解进程和线程背后的基础理论。接着,我们将研究C++内存模型的变化,这些变化在语言中支持了原生的并发。我们还将了解什么是竞态条件(race condition),它是如何导致数据竞争(data race),以及如何防止数据竞争。接下来,我们将熟悉C++20中的std::jthread
基元,它支持多线程。我们将学习std::jthread
类的具体细节,以及如何使用std::stop_source
基元停止已经运行的std::jthread
实例。最后,我们将学习如何同步并发代码的执行,以及如何从执行的任务中报告计算结果。我们将学习如何使用C++同步基元,例如屏障(barriers)和门闩(latches),来同步并发任务的执行,并如何使用承诺(promises)和未来(futures)正确报告这些任务的结果。
总结一下,本章我们将讨论以下主题:
- 什么是并发?
- 线程与进程
- C++中的并发
- 揭开竞态条件和数据竞争的神秘面纱
- 实用的多线程
- 并行执行期间的数据共享
那么,让我们开始吧!
技术要求
本章中的所有示例均在以下配置环境中测试过:
- Linux Mint 21 Cinnamon版
- 使用编译器标志的GCC 12.2 -
-``std=c++20 -pthread
- 稳定的互联网连接
- 请确保您的环境至少是这么新的。对于所有示例,您也可以选择使用https://godbolt.org/。
什么是并发?
现代汽车已成为高度复杂的机器,不仅提供交通工具,还提供各种其他功能。这些功能包括信息娱乐系统,它允许用户播放音乐和视频,以及暖气和空调系统,用于调节乘客的温度。想象一下,如果这些功能不能同时工作的场景。在这种情况下,司机必须选择驾驶汽车、听音乐或保持舒适的气候之间进行选择。这不是我们对汽车的期望,对吧?我们期望所有这些功能同时可用,增强我们的驾驶体验,并提供舒适的旅程。为了实现这一点,这些功能必须并行运行。
但它们真的是并行运行的,还是只是并发运行的?二者有什么区别?
在计算机系统中,并发和并行在某些方面相似,但并不相同。想象一下,你有一些工作要做,但这些工作可以分成几个较小的部分。并发是指多个工作块在重叠的时间间隔内开始、执行和完成,没有执行的特定顺序保证。另一方面,并行是一种执行策略,这些工作块在拥有多个计算资源的硬件上同时执行,如多核处理器。
当多个我们称之为任务的工作块在某段时间内以未指定的顺序执行时,就发生了并发。操作系统可能会运行其中一些任务,而迫使其他任务等待。在并发执行中,任务不断地争取执行时间槽,因为操作系统不保证它们会一次性全部执行。此外,当任务正在执行时,它很可能突然被挂起,另一个任务开始执行。这被称为抢占。显然,在并发任务执行中,任务的执行顺序是不保证的。
让我们回到我们的汽车示例。在现代汽车中,信息娱乐系统负责同时执行许多活动。例如,它可以在让你听音乐的同时运行导航部分。这是因为系统并发运行这些任务。它在处理音乐内容的同时运行与路线计算相关的任务。如果硬件系统只有一个核心,那么这些任务应该并发运行:
图6.1 - 并发任务执行
从上图中,你可以看到每个任务都在不可预测的顺序中获得了非确定性的执行时间。此外,没有保证你的任务会在下一个任务开始之前完成。这就是抢占发生的地方。当你的任务正在运行时,它可能突然被挂起,另一个任务被安排执行。请记住,任务切换并不是一个廉价的过程。系统消耗处理器的计算资源来执行这一动作——进行上下文切换。结论应该是以下内容:我们必须设计我们的系统以尊重这些限制。
另一方面,并行是并发的一种形式,涉及在单独的处理单元上同时执行多个操作。例如,一台配有多个CPU的计算机可以并行执行多个任务,这可以带来显著的性能提升。你不必担心上下文切换和抢占。然而,它也有自己的缺点,我们将会彻底讨论这些缺点。
图6.2 - 并行任务执行
回到我们的汽车示例,如果信息娱乐系统的CPU是多核的,那么与导航系统相关的任务可以在一个核心上执行,而音乐处理的任务可以在其他一些核心上执行。因此,你不需要采取任何行动来设计你的代码以支持抢占。当然,这只有在你确信你的代码将在这样的环境中执行时才成立。
并发与并行之间的基本联系在于,并行可以应用于并发计算,而不影响结果的准确性,但仅并发的存在并不保证并行。
总之,并发是计算中的一个重要概念,它允许多个任务同时执行,尽管这并不是保证的。这可能导致性能提升和有效的资源利用,但代价是更复杂的代码,尊重并发带来的陷阱。另一方面,从软件角度看,真正的并行代码执行更容易处理,但必须由底层系统支持。
在下一节中,我们将熟悉Linux中执行线程和进程之间的区别。
线程与进程
在Linux中,进程是执行中程序的一个实例。一个进程可以有一个或多个执行线程。线程是可以独立于同一进程内的其他线程进行的指令序列。
每个进程都有自己的内存空间、系统资源和执行上下文。进程彼此隔离,不共享内存。它们只能通过文件和进程间通信(IPC)机制进行通信,如管道、队列、套接字、共享内存等。
另一方面,线程是进程内的轻量级执行单元。将指令从非易失性内存加载到RAM甚至缓存的开销已经由创建线程的进程——父进程支付。每个线程都有自己的堆栈和寄存器值,但共享父进程的内存空间和系统资源。因为线程在进程内共享内存,它们可以轻松地相互通信并同步自己的执行。一般来说,这使它们比进程更高效地用于并发执行。
图6.3 - 进程间通信(IPC)
进程和线程之间的主要区别如下:
- 资源分配:进程是独立的实体,拥有自己的内存空间、系统资源和调度优先级。另一方面,线程共享它们所属进程的内存空间和系统资源。
- 创建和销毁:进程由操作系统创建和销毁,而线程由它们所属的进程创建和管理。
- 上下文切换:当发生上下文切换时,操作系统会切换整个进程上下文,包括所有线程。相比之下,线程上下文切换只需要切换当前线程的状态,一般来说,这更快速且消耗的资源更少。
- 通信和同步:使用管道、队列、套接字和共享内存等IPC机制实现进程间的通信。另一方面,线程可以通过在同一进程内共享内存直接进行通信。这还使线程之间的有效同步成为可能,因为它们可以使用锁和其他同步原语来协调对共享资源的访问。
重要说明
Linux在内核中调度任务,这些任务可以是线程也可以是单线程进程。每个任务通过一个内核线程表示;因此,调度程序不区分线程和进程。
进程和线程在现实生活中也有类似之处。假设你和一群人一起在一个项目上工作,项目被分成了不同的任务。每个任务代表需要完成的一项工作单位。你可以把项目想象成一个进程,每个任务想象成一个线程。
在这个类比中,进程(项目)是需要完成以实现共同目标的相关任务的集合。每个任务(线程)是可以分配给特定人员完成的独立的工作单位。
当你将一个任务分配给某人时,你就在项目(进程)中创建了一个新线程。被分配任务(线程)的人可以独立工作,不会干扰其他人的工作。他们还可以与其他团队成员(线程)沟通,以协调他们的工作,就像进程中的线程可以相互沟通一样。他们还需要使用公共项目资源来完成任务。
相比之下,如果你将项目划分为不同的项目,你就创建了多个进程。每个进程都有自己的资源、团队成员和目标。确保两个进程共享项目完成所需的资源会更困难。
因此,计算中的进程和线程就像现实生活中的项目和任务。进程代表需要完成以实现共同目标的相关任务的集合,而线程是可以分配给特定人员完成的独立的工作单位。
在Linux中,进程是带有自己的内存和资源的程序的单独实例,而线程是进程内共享相同内存和资源的轻量级执行单元。线程可以更有效地通信,并且更适合需要并行执行的任务,而进程则提供更好的隔离和容错能力。
牢记这些,让我们看看如何在C++中编写并发代码。
C++中的并发
自C++11起,C++语言就内置了管理和执行并发线程的支持。但它没有任何原生支持管理并发进程。C++标准库提供了各种用于线程管理、线程间的同步和通信、保护共享数据、原子操作和并行算法的类。C++内存模型也设计了线程意识。这使得它成为开发并发应用程序的绝佳选择。
C++的多线程能力是指在单个程序内并发运行多个执行线程的能力。这允许程序利用多个CPU核心并行执行任务,从而加快任务完成速度并提高整体性能。
C++标准库引入了std::thread
线程管理类。一旦实例化,就由用户负责处理线程的目标。用户必须选择加入(join)线程或将其从父线程中分离(detach)。如果他们不处理它,程序就会终止。
随着C++20的发布,引入了一个全新的线程管理类std::jthread
。它使创建和管理线程变得相对容易。要创建一个新线程,可以创建std::jthread
类的实例,传递你想要作为单独线程运行的函数或可调用对象。与std::thread
相比,std::jthread
的一个关键优势是你不必明确担心加入它。它将在std::jthread
销毁期间自动完成。在本章后面,我们将更深入地了解std::jthread
及其使用方法。
请记住,多线程也会使程序更加复杂,因为它需要仔细管理共享资源和同步线程。如果没有正确管理,多线程可能会导致诸如死锁和竞态条件等问题,这可能导致程序挂起或产生意外结果。
此外,多线程要求开发人员确保代码是线程安全的,这可能是一项具有挑战性的任务。并非所有任务都适合多线程;如果尝试并行化,某些任务实际上可能会运行得更慢。
总的来说,C++的多线程可以在性能和资源利用方面提供显著的好处,但也需要仔细考虑潜在的挑战和陷阱。
现在,让我们熟悉编写并发代码的最常见陷阱。
揭示竞态条件和数据竞争
在C++中,多线程支持最初是在C++11语言版本中引入的。C++11标准提供的关键元素之一是内存模型,它有助于促进多线程。内存模型处理两个问题:对象在内存中的布局和这些对象的并发访问。在C++中,所有数据都由对象表示,对象是具有类型、大小、对齐、生命周期、值和可选名称等多种属性的内存块。每个对象在内存中存在特定时间段,并根据其是否为简单标量对象或更复杂类型而存储在一个或多个内存位置中。
在C++的多线程编程上下文中,考虑如何处理多个线程对共享对象的并发访问至关重要。如果两个或更多线程尝试访问不同的内存位置,通常没有问题。然而,当线程同时尝试在同一内存位置写入时,可能导致数据竞争,从而导致程序中出现意外行为和错误。
重要说明
当多个线程尝试访问数据并且至少有一个尝试修改它时,如果没有采取措施同步内存访问,则会发生数据竞争。数据竞争可能导致程序中的未定义行为,并是麻烦的根源。
但是你的程序如何陷入数据竞争?这发生在未正确处理竞态条件时。让我们来看看数据竞争和竞态条件之间的区别:
- 竞态条件:代码的正确性取决于特定的时间或严格的操作顺序的情况
- 数据竞争:当两个或更多线程访问一个对象并且至少有一个线程修改它时
根据这些定义,我们可以推断,程序中发生的每一次数据竞争都是由于未正确处理竞态条件的结果。但相反并不总是成立:并非每个竞态条件都导致数据竞争。
理解竞态条件和数据竞争的最佳方式是看一个例子。让我们想象一个原始的银行系统,真的很原始,我们希望它在任何地方都不存在。
Bill和John在一家银行有账户。Bill的账户里有100美元,John的账户里有50美元。Bill欠John总共30美元。为了偿还债务,Bill决定向John的账户转账两次。第一次是10美元,第二次是20美元。事实上,Bill将偿还John。两次转账完成后,Bill的账户将剩余70美元,而John的账户总计将累积到80美元。
让我们定义一个Account
结构,包含账户所有者的姓名以及在某一时刻的账户余额:
struct Account {
Account(std::string_view the_owner, unsigned
the_amount) noexcept :
balance{the_amount}, owner{the_owner} {}
std::string GetBalance() const {
return "Current account balance of " + owner +
" is " + std::to_string(balance) + '\n';
}
private:
unsigned balance;
std::string owner;
};
在Account
结构中,我们还将添加重载的+=
和-=
运算符方法。这些方法分别负责向对应账户存入或取出特定金额的钱。在每次操作前后,都会打印账户的当前余额。以下是这些运算符的定义,它们是Account
结构的一部分:
Account& operator+=(unsigned amount) noexcept {
Print(" balance before depositing: ", balance,
owner);
auto temp{balance}; // {1}
std::this_thread::sleep_for(1ms);
balance = temp + amount; // {2}
Print(" balance after depositing: ", balance,
owner);
return *this;
}
Account& operator-=(unsigned amount) noexcept {
Print(" balance before withdrawing: ", balance,
owner);
auto temp{balance}; // {1}
balance = temp - amount; // {2}
Print(" balance after withdrawing: ", balance,
owner);
return *this;
}
查看运算符函数的实现显示,它们首先读取账户的当前余额,然后将其存储在局部对象中(标记{1}
),最后使用局部对象的值增加或减少指定金额。
就这么简单!
账户新余额的结果值被写回Account
结构的balance
成员中(标记{2}
)。
我们还需要定义一个方法,负责实际的资金转移:
void TransferMoney(unsigned amount, Account& from, Account& to) {
from -= amount; // {1}
to += amount; // {2}
}
它所做的唯一事情是从一个账户中取出所需金额(标记{1}</