18 并发

18.1 引言

并发是指同时执行多个任务,其意义有二:

几乎所有现代编程语言都对并发提供了不同程度的支持。C++标准库并发特性的前身已在C++社区使用了20多年,经过对可移植性和类型安全性的持续改进,几乎适用于所有现代硬件平台和操作系统。标准库并发特性的重点是提供对系统级并发机制的支持,而非另行构建复杂的高层并发模型。那些高层并发模型,可以基于标准库工具实现,并以第三方库的形式提供给开发用户。

标准库的并发特性直接支持在单一地址空间内同时执行多个线程的编程模型。为此,C++提供了合适的内存模型和一套原子化操作。原子操作允许无锁编程,而内存模型则保证了,只要程序员能有效地避免数据竞争(对可变数据的不受控制的并发访问),一切都会如人们所期望的那样工作。然而,大多数用户所理解的并发,更多还是源于对标准库及基于标准库的其它第三方库的使用。因此,这里有必要强调标准库的主要并发特性,如thread、mutex、lock、packaged_task和future等组件,并给出一些示例。这些特性直接根植于操作系统所提供的内核服务(系统调用),但与直接调用系统调用不同,标准库的并发特性不会产生额外的性能开销,当然也不可能保证显著的性能改进。

永远不要迷信并发是解决一切性能问题的灵丹妙药。如果一项任务可以按顺序完成,那么这样做通常更简单,也更高效。因为,将信息从一个线程传递到另一个线程本身,就是代价高昂的操作。

作为采用显式并发特性的替代方案,通常还可以借助并行算法,充分发挥多个执行引擎共同完成一个计算任务的能力,以获得更好的性能表现。

此外,C++还支持协程,以使函数可以在调用之间保持各自的状态。

C++11:内存模型

18.2 任务和thread

所谓任务(task),就是可与其它计算并发执行的计算过程,而所谓线程(thread),就是任务在程序中的系统级表示。若要启动一个可与其它任务并发执行的任务,就需要创建一个thread类型的对象,该类型的定义位于<thread>头文件中,同时将表示任务的函数或函数对象,作为传递给该对象构造函数的参数。例如:

join函数保证其后的代码在所等待的线程结束之后才会被执行。这个过程称为线程会合,即等待线程结束。

主线程
第二个子线程
第一个子线程
创建
会合
创建
会合
thread t1{ task }
thread t2{ Task{} }
t1.join()
t2.join()
void Task::operator()() { ... 任务的执行过程 ... }
void task() { ... 任务的执行过程 ... }

join函数的问题在于很容易被人们忘记,而一旦忘记,后果通常会很严重。为此,标准库提供了jthread类,该类与thread类的不同之处在于,通过在其析构函数中调用join函数,实现了所谓自动会合的功能。例如:

线程会合由线程对象的析构函数自动完成,而析构函数的调用顺序与构造函数相反,因此这里是先会合t2所表示的线程,再会合t1所表示的线程。

一个程序中的所有线程共享同一个地址空间。这也是线程与进程的主要区别之一,进程间通常不共享地址空间。由于线程共享同一个地址空间,因此线程间可以通过共享数据相互通信,同时借助锁或其它同步机制,规避因数据竞争(对可变数据的不受控制的并发访问)而引发的线程冲突。

地址空间
线程1
共享数据
线程2
线程3

地址空间1
进程1
私有数据
地址空间2
进程2
私有数据
地址空间3
进程3
私有数据

编写线程安全的代码并不容易。例如:

这是一个典型的严重错误。task和Task都向cout表示的输出流对象中插入数据,而没有采取任何形式的同步。最终的输出结果将不可预测,而且每次运行程序都可能得到不同的输出结果,因为两个线程的执行顺序的不确定的。程序可能产生下面这样的奇怪输出:

只有来自标准的特定保证,才能避免那些可能导致崩溃的数据竞争。对于输出流而言,要么只让一个线程使用输出流,要么使用osyncstream。

定义并发任务所追求的目标,是保持任务间的完全隔离,唯一的例外是与其它任务通信的部分,而这种通信应该以简单而明显的方式进行。思考并发任务的最简单方式,是将其视作一个可以与调用者过程并发执行的函数,只需为其传递参数,获取结果,并保证二者不同时使用共享数据,即不发生数据竞争即可。

创建者线程
被创建线程
参数
结果
不同时
不同时
调用者过程
并发执行的函数
共享数据

C++11:thread

18.2.1 传递参数

典型地,线程需要处理数据。可以将数据、指向数据的指针或引用数据的引用,作为参数传递给线程。例如:

向线程传递参数有两种方式:

无论以哪种方式向线程传参,如果线程所持有的仅仅是某个对象的指针或引用,一定要保证该指针或引用的目标对象的生命周期,不短于其在线程中被访问的周期。从这个意义上讲,向线程传递值,比传递指针或引用更安全。

18.2.2 返回结果

表示任务的函数和函数对象都不能带有返回值,因此线程的处理结果(如果有的话),只能通过该函数或函数对象的输出参数或输出成员,提供给线程的创建者。例如:

这里使用了来自<functional>头文件的类型函数ref,以显式告诉编译器将elapsed1视为引用,否则编译器将按对象方式推导thread类模板的相应类型参数,而这与task函数第三个参数的类型并不一致,报告编译错误。

这种获得线程结果的方法的确可行,而且应用得也很普遍,只是通过指针或引用输出数据,显得不太优雅。

18.3 共享数据

有时需要在多个线程间共享数据,这时就必须对访问进行同步。原则上在任何时候,都只允许最多一个线程访问共享数据。当然,多个线程同时读取不可变数据,其实是没有问题的。

18.3.1 mutex和锁

mutex是一种互斥对象,是线程间并发访问共享数据的关键元素。线程通过调用mutex对象的lock成员函数,获得该mutex,并通过调用其unlock成员函数释放之。任何时候,一个mutex最多只能被一个线程所持有,任何试图获取已为其它线程所持有的mutex的操作,都会阻塞,直到其持有者主动释放,阻塞的一方才会被唤醒,并在成功获取该mutex后,成为其新的持有者,直至主动释放。标准库在<mutex>头文件中给出了关于mutex的定义。

同步
线程2
与共享数据无关的操作
获取mutex
访问共享数据
释放mutex
与共享数据无关的操作
线程1
与共享数据无关的操作
获取mutex
访问共享数据
释放mutex
与共享数据无关的操作

同步
线程2
与共享数据无关的操作
获取mutex
访问共享数据
释放mutex
与共享数据无关的操作
线程1
与共享数据无关的操作
获取mutex
访问共享数据
释放mutex
与共享数据无关的操作

例如:

scoped_lock<mutex>类在构造函数中获取mutex,并在析构函数中释放mutex。因此,可以将访问共享数据的代码放在一个独立的作用域(语句块)中,并将scoped_lock<mutex>类型的对象声明为该作用域的局部变量,以此实现对mutex的自动获取和释放。更重要的是,这样做可以避免因提前返回或抛出异常而错过释放mutex。例如:

共享数据和mutex之间的对应关系依赖于约定。程序员必须清楚哪个mutex用于保护哪个共享数据。显然,这很容易出错。因此,最好还是利用一些语言手段,让这个对应关系变得更清晰也更好维护。例如:

在访问rec对象的任何部分之前,先获取rec.rm。

需要同时访问多个共享数据的情况并不鲜见,这可能导致死锁。例如,线程1在成功获取mutex1后试图获取mutex2,同时,线程2在成功获取mutex2后试图获取mutex1,这样一来,两个线程都不会继续执行。

线程1
线程2
等待
等待
...
获取mutex1
获取mutex2
...
释放mutex1
释放mutex2
...
...
获取mutex2
获取mutex1
...
释放mutex2
释放mutex1
...

scoped_lock可以同时获取多个锁。例如:

scoped_lock类的构造函数只有在获取了全部参数mutex后才会继续,并在持有mutex时不会阻塞。scoped_lock类的析构函数负责释放其所持有的全部mutex。

基于共享数据的通信是非常底层的操作。尤其是,程序员必须设法了解各项任务已经完成和尚未完成的工作。在这方面,使用共享数据就不如调用函数并接收返回值。另一方面,有些人笃信共享数据一定比拷贝函数的参数和返回值更有效率。不可否认,当涉及大量数据时的确如此,但频繁获取和释放mutex同样也是有性能开销的,而且开销还不小。再者,现代计算机对于数据复制其实非常擅长,尤其是象vector这样的紧凑型数据。最好不要因效率之故而不假思索地选择基于共享数据的通信,先测量再做选择。

基本的mutex在任何时候都只能被一个线程所持有。但对共享数据的常见访问却往往是,多线程读取和单线程写入,即读共享写独占的同步逻辑。shared_mutex类支持这种读写锁的用法。多个读线程可以同时拥有同一个shared_mutex,这时任何写线程都不能再获取它,而一旦有一个写线程持有这个shared_mutex,任何其它线程,无论读写,都无法再获取它,这就叫读共享写独占。

shared_lock
shared_lock
shared_lock
unique_lock
shared_mutex

unique_lock
shared_lock
shared_lock
unique_lock
shared_mutex

例如:

C++11:互斥量和锁

C++14:shared_mutex、shared_lock和unique_lock

C++17:scoped_lock

18.3.2 原子量

mutex涉及操作系统底层,属于代价较重的线程同步机制。它允许在没有数据竞争的前提下,完成对任意数量共享数据的并发访问。然而,如果只针对少量数据,使用更简单、更轻量级的低成本机制,也不失为一种明智的选择。例如atomic变量,亦称原子量。例如下面的代码,这是一个经典的“双重检查锁定”的简单示例:

为了保证“初始化操作”只执行一次,这里使用了“双重检查锁定”策略。其中的init,就是一个bool类型的原子量。借助原子量,可以在不使用mutex的前提下,避免并发访问init时发生数据竞争。另外,这里还是使用lock_guard,它与scoped_lock功能相同,但因其仅获取一个mutex,故比后者简单得多。

C++11:低层并发支持(atomic)

C++20:对atomic的诸多扩展

18.4 等待事件

一个线程有时需要等待某个外部事件,如另一个线程完成了某个任务,或是过去了一段时间。最简单的事件就是时间流逝。使用<chrono>头文件中与时间相关的特性,可以很好地完成这类任务。例如:

this_thread命名空间中的sleep_for函数,可令当前线程(包括主线程)睡眠参数指定的时间。duration_cast模板函数,用于将参数时间段调整为所期望的单位,如纳秒。

通过外部事件实现线程间通信的基本方法是使用<condition_variable>头文件中的condition_variable类。condition_variable提供了一种机制,它允许一个线程等待另一个线程。特别地,它允许线程等待某个条件(事件)的满足(发生),而这个条件(事件)是否满足(发生),往往取决于其它线程是否完成了某项特定的工作。

condition_variable支持很多优雅而高效的数据共享方式,但也有可能变得相当复杂。考虑两个线程通过一个queue传递消息的经典例子:

这里的queue、string、mutex和condition_variable皆由标准库提供。

消费者线程负责从消息队列读取消息:

这里通过对mutex上的unique_lock进行显式保护,实现了对queue和condition_variable的操作。condition_variable类的wait成员函数,会在其第二个参数返回false时,令调用线程在condition_variable中等待,同时释放所持有的mutex,直至其被唤醒,并在重新获得该mutex后,再次检查其第二个参数的返回值,以决定是继续等待还是立即返回。这个过程已经考虑到其它消费者线程提前获得mutex并抢先消费的情形。

还有一个细节需要注意,就是传递给condition_variable类wait成员函数的第一个参数,可以是unique_lock类型的对象,但不能是scoped_lock类型的对象。

生产者线程负责向消息队列写入消息:

condition_variable类的notify_one成员函数,负责向该condition_variable中处于等待状态的线程发送通知,唤醒该线程,以令其有机会从wait函数中返回,并继续执行后续操作。

唤醒
消费者线程
获取mutex
若消息队列为空则在condition_variable中等待
从消息队列读取消息
释放mutex
生产者线程
获取mutex
向消息队列写入消息
向在condition_variable中等待的线程发出通知
释放mutex

C++11:条件变量

18.5 任务间通信

标准库提供了一些特性,允许程序员在抽象的任务层,指派需要同时执行的某项工作,而不必基于底层的线程和锁机制实现并发与同步。这些特性包括:

C++11:高层并发支持(packaged_thread、future、promise和async)

18.5.1 future和promise

future和promise的妙处在于,可以在不显式使用任何同步机制的前提下,获取在另一个线程中就绪的值。它的基本思路非常简单,当一个线程需要向另一个线程输出数据时,可将其值置于promise中,而另一个线程则从future中获取该值。

输入线程
输出线程
set_value
set_exception
get
catch
future
数据
异常
数据
异常
promise

假设所要传输的数据是一个X类型的对象,fx是一个future<X>类型的对象,则从future中获取数据的代码如下:

如果数据尚未就绪,get函数会阻塞,直至数据被放入相应的promise。get函数可能会抛出异常,该异常可能来自系统,也可能来自相应的promise。

假设px是一个promise<X>类型的对象,则向promise中设置数据的代码如下:

其中current_exception函数用于获取当前被捕获的异常。

C++11:拷贝和重新抛出异常

18.5.2 packaged_task

下面的问题是,用于在两个线程间传输数据的future-promise对如何得到呢?标准库为此提供了一个名为packaged_task的类,以简化在线程中操作future和promise的过程。例如只需向packaged_task发出一个get_future请求,它就会返回一个future,并在内部维护与之对应的promise,分别用于读写数据。例如:

这是一个很普通的计算整数除法的函数,其中包含除零检测。现在希望在独立的子线程中执行该函数,并在主线程中得到该函数的返回值或可能抛出的异常:

运行该程序,产生如下输出:

packaged_task将任务函数或函数对象的类型(int(int,int))作为其模板参数,并将任务函数或函数对象(divide)作为其构造参数。从packaged_task对象中获取用于读取线程输出的future对象,该输出来自任务函数的返回值。创建线程时需要使用move,因为packaged_task不支持拷贝。

主线程
t2:jthread
t1:jthread
p2:packaged_task
p1:packaged_task
return
throw
get
catch
f1:future
f2:future
divide
promise
divide
promise

这段代码并没有明确提及和锁有关的任何东西,程序员得以专注于任务本身,而非在任务间的通信机制。这两个任务在各自独立的线程中执行,因而可能是并行的。

18.5.3 async

抛却一切与系统级底层线程有关的概念,人们需要的可能仅仅是一个凑巧需要和其它函数同时执行的函数。这种编程模型虽然不是C++标准库唯一支持的模型,但是可以很好地满足大多数场景下对于并发的需求。

标准库的async函数用于启动一个异步执行的任务。例如:

async将函数的调用和返回值的获取截然分开,并将这两部分在不同的线程中处理。借助async完全无需操心任何与线程和锁有关的细节,只需专注于需要异步执行的任务本身,如divide。当然,如果多个异步执行的任务需要使用共享资源,且共享资源需要通过锁机制加以保护,则不应该使用async。使用async时,甚至不知道具体创建了多少个线程,这完全由async决定。async根据其被调用时的系统可用资源量确定创建多少个线程。

请注意,使用async并非仅仅为了利用并行计算提高程序的运行性能。它的核心是异步而非并行,虽然并行是实现异步的一种手段。例如程序可能一方面需要从用户处获取输入,另一方面还要执行其它计算。

18.5.4 终止线程

有时需要终止一个线程的运行,因为不再对其结果感兴趣。简单地杀死线程,通常是不妥当的,因为线程可能正持有某些必须释放的资源,如mutex、子线程或数据库连接等。为此,标准库提供了一种礼貌地请求线程执行清理,而后优雅地离开的机制——stop_token。如果一个线程具有stop_token并被请求终止,则它自然会体面地终止。

假设find_any是一个并行算法,它会产生许多线程并在其中执行find函数以寻找结果。当其中任何一个线程找到结果时,其它线程即可终止运行。这里的find函数符合常见任务风格的典型模式,其中有一个主循环,根据对stop_token的测试,决定是继续还是退出:

stop_token对象的stop_requested成员函数,用于判断是否有其它线程请求终止该线程。stop_token是安全传达此类请求的机制(无数据竞争)。并行算法函数find_any的定义类似下面这个样子:

stop_source对象可以产生stop_token对象,并通过它的request_stop成员函数,向线程传递终止请求。

子线程
主线程
get_token
stop_requested
stop_token
false
stop_source

子线程
主线程
request_stop
stop_requested
stop_token
true
stop_source

这里假设查找结果一定可以得到,否则等待查找结果的自旋循环将永远不会退出。

18.6 协程

协程是一种在多次调用的过程中保持其状态的函数。在这方面,它有点象函数对象,但在每次调用时,协程可以隐式地、完整地恢复上一次的状态,并保存这一次的状态。例如:

这是一个典型的求斐波那契数列的函数。第一次调用,返回斐波那契数列的第一项,再次调用,返回第二项,以此类推。例如:

协程在每次被调用时,将当前栈帧保存到generator类型的返回值中,并在下一次被调用时从中恢复。co_yield返回值并等待下一次被调用。co_return返回值并终止协程。

协程可以是同步的,即调用者等待结果,也可以是异步的,即调用者执行其它操作,直到协程返回结果。上述斐波那契数列的例子显然是同步的。一些优化器可以内联方式优化协程调用,甚至将上述代码直接优化为:

协程框架的实现极其灵活,能够服务于极端范围的潜在用途。它是由专家设计并为专家使用的工具,具有标准化委员会的典型设计风格。遗憾的是,直到C++20,仍然缺乏能让简单功能轻松实现的基础设施,甚至连generator模板都还不是标准库的一部分。作为替代方案,可以使用一些第三方库,如CppCoro(A coroutine library for C++)等,详情参见https://github.com/lewissbaker/cppcoro。

C++20:协程

18.6.1 协作式多任务

高德纳(Donald E. Knuth)在《计算机程序设计艺术(The Art of Computer Programming)》第一卷中盛赞了协程的实用性,但也感慨于很难给出简短的例子。协程存在的价值即是对复杂系统的简化。C++语言在早期获得成功的原因之一,是其对事件驱动模型的模拟。其中的核心思想是将一个复杂任务,表示为由一系列子任务(协程)组成的系统。其中的每个子任务只负责很小的一部分工作,如数据生成、网络计算、输入输出,等等。子任务和子任务之间通过消息队列通信。每个子任务在产生结果后,即将自己放在一个任务队列中等待新的工作。调度器会在需要时从该队列中选择下一个需要执行的子任务。这就是一个典型的协作式多任务处理系统。这种源自Simula语言的设计思想最终成为构建第一个C++库的基础。

入队
出队
入队
出队
派发
执行
通信
执行
通信
事件
事件队列
调度器
待命子任务
任务队列
处理器
活动子任务
活动子任务
消息队列

这种设计的关键是:

首先,借助运行时多态,统一调用数十甚或数百个不同类型的协程:

Event存储动作并调用之,动作通常是一个协程。事件除了包含动作,还可以包含其它内容,比如名称。

下面是一段极简单的使用事件的代码:

其中的integers和chars是两个不同类型的协程:

它们都借助于co_yield在两次调用之间将自己挂起。这意味着Task必须是一个协程句柄。

“魔法”就隐藏在Task中。它保存协程在每次被调用时临返回前的栈帧,并确定co_yield的含义:

除非作为库的实现者,强烈建议不要自己编写这样的代码。网上有很多类似代码的范例可资借鉴。

运行程序,得到如下输出:

18.7 建议