Hello, world of concurrency in C++!

本系列笔记是关于如何使用C++写出多线程并发的应用以及关于C++对于多线程的特性、库工具做出解析。仍然需要注意的是,该系列的笔记只是个人理解下的观点,如有错误,请通过email发送信息给我(chenmiao.ku@gmail.com)。

What is concurrency?

在最简单、基本的感知上,并发是指两个或多个独立活动同时发生。并发在我们的日常生活中的一部分:我们可以一边走路一边说话或者是用每只手执行多个不同的行动,亦或是我们个人独立的生活着……

Concurrency in computers systems

每当我们谈论计算机术语:并发时。我们指的是一个独立的系统并行的执行多个独立的活动,而不是顺序或一个接着一个地执行

从历史上看,大多数台式计算机都有一个处理器,其中只有一个处理单元或核心(当然,现在大多数计算机应该都是双核或多核)。像这样的计算机(单核)一次只能处理一个任务,但可以通过在任务之间不断切换任务。一个任务做一点,另一个任务再做一点的方式,这就看起来像任务正在发生并行。这被叫做任务切换(task switching)。需要注意的是,由于任务切换的速度过快,你无法判断上一个任务切换到下一个任务时,任务会在哪一个点挂起。同时,任务切换给了用户和应用一个并发的假象,这就使得应用的行为可能与真实在能够进行并发的计算机上运行时的有细微的不同

在以前,包含多个处理器的计算机通常被用于服务器或高性能计算任务;如今,拥有多核的台式电脑逐渐普遍。无论这些计算机拥有多个处理器还是一个处理器中有用多个处理核心,它们都能够真正的并行运行这些任务。我们称之为硬件并发(hardware concurrency)

下图展示了一个理想情况下,一个计算机刚好有两个任务,并且该两个任务刚好被切分为十个大小相等的块。在一个双核机器上,每一个任务都能够执行在自己的核心上;但在单核机器上,就需要进行任务切换,并且每一个任务块是交替的。一个系统为了交替执行任务,就必须从一个任务切换到另一个任务时执行上下文切换(context switching),并花费一定的时间开销。

为了执行一个上下文切换,系统不得不保存当前正在运行的$CPU$状态和指令指针,计算出要切换到哪一个任务,并为正在切换的任务重新加载到$CPU$状态然后,$CPU$可能需要将新任务的指令和数据加载到缓存中,这可能阻止$CPU$执行任何指令,导致进一步的延迟

Two approaches to concurrency: parallel execution on a dual-core machine versus task switching on a single-core machine

当操作系统决定要切换到另一个任务时,CPU需要加载新任务的指令和数据到缓存中。这是因为缓存是CPU用于快速访问内存中的数据的临时存储器。如果新任务的指令和数据不在缓存中,CPU就必须从主内存中读取它们,这需要更多的时间和资源。
这个加载过程可能会导致CPU无法执行任何指令,因为它必须等待新任务的数据在缓存中准备好。这会导致进一步的延迟,因为CPU无法继续执行其他指令,直到新任务的数据准备就绪。
一旦新任务的数据加载到缓存中,CPU就可以开始执行新任务的指令。但在此过程中,由于加载数据和等待的延迟,总体上会出现额外的延迟。
这种延迟是任务切换过程中不可避免的,但可以通过优化缓存和内存访问策略来减少其影响,以提高系统的性能。

虽然硬件中的并发性在多处理器或多核系统中最为明显,但有些处理器可以在单核上执行多个线程。需要考虑的重要因素就是硬件线程(hardware thread)的数量,这是衡量硬件可以真正并发运行多少独立任务的指标。即使系统具有真正的硬件并发性,任务数量也很容易超过硬件可以并行运行的数量,因此在这些情况下仍旧进行任务切换。如下图所示,四个任务的任务切换发生在一个双核计算机上,和先前理想的情况一样,每个任务都被均分为多个小块。

Task switching of four tasks on two cores

Approaches to concurrency

现在举一个例子,假设一对程序员在一起做一个软件项目。如果你的开发人员在不同的办公室,他们可以和平地工作,不会被彼此打扰,并且他们每个人都有自己的一套参考手册。但显而易见的,沟通并非直截了当的;他们需要使用电话或电子邮件,或者站起来走到对方的办公室。此外,你还需要管理两个办公室,需要购买多份参考手册。

现在,假如你将他们放在同一个办公室,他们现在可以随意的交流或讨论应用的设计。你只需要管理一个办公室,购买一份参考手册;但是,他们可能会发现很难集中注意力,并且出现资源问题(一份参考手册)。

上述的两种方式解释了两种基本的并发实现的方式。每一位开发者就对应了一个线程,办公室就表示了一个进程,而参考手册便是资源。

CONCURRENCY WITH MULTIPLE PROCESSORS

在应用程序中使用并发的第一种方法是将应用程序划分为同时运行的多个独立的单线程进程。然后,这些独立的进程可以通过所有正常的进程间通信通道(signalssocketsfiles…)相互传递消息。

通过这种方式实现的缺点是:进程间通信要么建立起来很复杂,要么很慢,或者两者皆有;同时,运行多个进程存在固有的开销,启动一个进程需要开销,操作系统必须投入内部资源来管理这些进程

当然,也并非全是坏处:操作系统通常在进程和高级通信机制之间提供了额外的保护,这意味着使用进程而不是线程更容易编写安全的并发代码;并且,你可以在通过网络连接的不同机器上运行单独的进程。虽然这增加了通信成本,但在精心设计的系统上,它可以是增加可用并行性和提高性能的一种经济有效的方法

将多个进程通过网络分布在多个主机上,这叫做分布式计算。

communicate

CONCURRENCY WITH MULTIPLE THREADS

并发的另一种方式是在单个进程中运行多个线程线程很像轻量级进程:每个线程独立于其他线程运行,每个线程可以运行不同的指令序列。但是,一个进程中的所有线程共享相同的地址空间,并且大多数数据可以从所有线程直接访问————全局变量在全局保持,指针、对象或数据的引用可以在线程之间传递。尽管线程之间可以共享进程中的地址空间,但是它们的建立和管理依旧是很复杂的,因为相同数据的内存地址在不同的进程中不一定相同

共享的地址空间和线程之间缺乏数据保护使得使用多个线程的开销比使用多个进程的开销要小得多。但是共享内存的灵活性也有代价:如果数据是由多个线程访问的,那么应用程序员必须确认每个线程所看到的数据视图在访问数据时是一致的

与在多个进程之间启动和通信相比,在一个进程内的多个线程之间的启动和通信的开销较低,这意味这是主流语言中最受欢迎的并发方式,尽管共享内存会产生潜在的问题。再者,C++标准并没有为进程之间的通信提供任何内在的支持,因此使用多个进程的应用程序将不得不依赖特定平台的API来实现这一点。

Concurrency vs. Parallelism

在多线程代码上,并发(Concurrency)并行(Parallelism)在意义上有很大部分的重叠。实际上,大多数时候它们指的就是同一件事;其区别主要在于细微差别、关注点和意图。

这两个术语都与利用可用的硬件同时运行多个任务有关,但并行性更加注重性能当人们主要关注利用可用的硬件来提高大规模数据处理的性能时,他们谈论的是并行性而当人们主要关注关注点分离或响应性时,他们谈论的是并发性

  • 并行性强调的是同时执行多个任务以提高整体性能。它关注的是如何充分利用多核处理器或分布式系统等硬件资源来同时处理多个任务,以实现更快的数据处理。并行性的目标是通过同时处理多个任务来加速计算过程。
  • 而并发性更注重任务的分离和响应性。它关注的是如何同时处理多个任务,使它们能够并发地执行,而不是简单地顺序执行。并发性的目标是实现任务的交替执行,使系统能够更好地响应用户的请求,提高系统的吞吐量和响应时间。
  • 总的来说,并行性更注重性能优化和数据处理速度,而并发性更注重任务的分离和系统的响应性能。

Why use concurrency?

在应用程序中使用并发性有两个主要原因:关注点分离(separation of concern)性能(performance)

  • 关注点分离
    • 并发可以帮助将复杂的应用程序分解为更小、更独立的部分,每个部分专注于处理特定的任务或功能。通过将应用程序分成多个并发执行的部分,可以更好地组织和管理代码,提高代码的可读性和可维护性。不同的任务可以在不同的线程或进程中执行,彼此之间相互独立,从而实现了关注点分离。
    • 在这种情况下,线程的数量与可用的CPU核心数量是独立的,因为线程的划分是基于概念设计,而不是为了增加吞吐量
  • 性能优化
    • 并发还可以用于提高应用程序的性能。通过并发地执行多个任务,可以充分利用多核处理器或分布式系统的资源。这样可以加快任务的处理速度,提高系统的响应能力和吞吐量。例如,在并发地处理大量的数据或同时处理多个用户请求时,通过并行地执行任务可以显著提高应用程序的性能。
    • 将单个任务分成多个部分并行运行,从而减少总运行时间,这种被称为任务并行(task parallelism)每个线程对数据的不同部分执行相同的操作,这被称为数据并行(data parallelism)

那些容易受到这种并行性影响的算法通常被称为embarrassingly parallel(尴尬并行)。尽管这个说法可能暗示你可能会为代码如此容易并行化而感到尴尬,但这是一件好事。我还遇到过其他形容这类算法的术语,如naturally parallel(自然并行)conveniently concurrent(方便并发)Embarrassingly parallel算法具有良好的可扩展性特性——随着可用的硬件线程数量增加,算法中的并行性可以增加以匹配。这样的算法完美地体现了一句格言:”人多好办事”。对于那些不是尴尬并行的算法部分,你可能可以将算法划分为固定(因此不可扩展)数量的并行任务。

When not to use concurrency

知道什么时候不能使用并发和知道什么时候能使用并发同样重要。从根本上说,唯一一个不使用并发的原因是收益不值得付出其代价

除非潜在的性能收益足够大或关注点的分离足够清晰,以便证明为了正确实现并行性所需的额外开发时间和维护多线程代码所需的额外成本是合理的,否则不要使用并发性

当然,使用并发的性能增益可能没有你想想中的那么大;启动线程有一个固有的开销,因为操作系统必须得去分配相关的内核资源和堆栈空间,然后将新线程添加到调度器中,所有这些都需要时间。如果在线程上运行的任务很快就完成了,那么与启动线程的开销相比,任务所花费的时间可能会相形见绌,这可能导致应用程序的整体性能比直接由生成线程执行任务时更差

此外,线程是一种有限的资源。如果同时运行太多的线程,这会消耗操作系统资源,并可能使整个系统运行速度变慢。不仅如此,使用太多的线程还会耗尽进程的可用内存或地址空间,因为每个线程都需要单独的堆栈空间。

最后,运行的线程越多,操作系统必须进行的上下文切换就越多。每次上下文切换都会占用一些本来可以用来做有用工作的时间,因此在某些时候,添加一个额外的线程将降低而不是提高应用程序的整体性能。由于这个原因,如果您试图实现系统的最佳性能,就有必要调整正在运行的线程数量,以考虑到可用的硬件并发性(或缺乏并发性)。

使用并发性提高性能与其他任何优化策略一样:它有可能极大地提高应用程序的性能,但也可能使代码复杂化,使其更难以理解,更容易出现bug

Concurrency and multithreading in C++

通过多线程对并发性的标准化支持对于c++来说是一个相对较新的事物。只有从c++11标准开始,你才能够编写多线程代码,而不需要借助特定于平台的扩展。

Efficiency in the C++ Thread Library

如果你追求最高的性能,了解使用任何高级功能相对于直接使用底层低级功能所带来的实现成本是非常重要的。这个成本就是抽象代价(abstraction penalty)

Abstraction Penalty
抽象代价是指使用高级功能相对于直接使用底层低级功能所带来的性能开销。封装的高级功能提供了更方便的接口和抽象层,但在底层实现上可能引入了额外的开销。这些开销可能包括资源消耗、额外的函数调用、内存分配和释放等。因此,在追求极致性能时,需要权衡使用高级功能所带来的便利性和抽象层与底层实现的开销之间的权衡。

C++标准委员会在设计C++标准库的整体以及特别是标准C++线程库时已经意识到了这一点。其中一个设计目标是,在提供相同功能的情况下,直接使用低级$API$应该没有或者几乎没有任何好处。因此,该库的设计旨在允许在大多数主要平台上进行高效实现(具有较低的抽象代价)。

C++标准委员会的另一个目标是确保C++为那些希望在接近硬件层面上获得最佳性能的开发人员提供足够的低级功能。为了实现这一目标,除了新的内存模型之外,还引入了一个全面的原子操作库,用于直接控制单个位和字节的操作,以及线程间同步和可见性的任何变化。这些原子类型和相应的操作现在可以用于许多开发人员之前可能选择使用特定平台汇编语言的地方。使用新的标准类型和操作的代码更具可移植性和易于维护。

有时,使用这些低级功能可能会带来性能成本,因为需要执行额外的代码。但是,这种性能成本并不一定意味着更高的抽象代价。如果你追求性能,而使用高级功能的成本过高,你可能更适合使用低级功能手工实现所需的功能。在绝大多数情况下,额外的复杂性和错误的机会远远超过了从微小的性能提升中可能获得的潜在好处。

Getting Started

我们从一个最基本的”Hello World”程序作为例子,下面是一个单线程的执行代码,这将作为我们后续修改为多线程的一个基准:

1
2
3
4
5
#include <iostream>

int main() {
std::cout << "Hello World\n";
}

下面则是”Hello World”多线程的版本:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
#include <thread>

void hello() {
std::cout << "Hello Concurrency World\n";
}

int main() {
std::thread t { hello };
t.join();
}

我们首先能够发现的是,多了一个头文件<thread>,这个头文件是管理线程的函数和类声明的文件;其他保护共享数据的函数和类则声明在其他头文件中。

然后,我们的打印语句则是放在了一个单独的函数hello()中,这是因为每一个线程都必须有一个初始函数,线程从这个初始函数开始执行。因此,这里的hello()函数作为了线程t的初始函数。

当我们的新线程启动后,初始线程(main)会继续执行。如果它没有等待新线程完成,它将继续到main()结束并结束程序,这会导致可能在新线程运行前,程序就已经结束。因此我们在新线程的语句下面需要调用join()(该函数将在第二篇笔记中解释)。

至此,一个多线程的”Hello World”就完成了。