C++多线程的临界区如何使用?

 当析构函数遇到多线程── C++ 中线程安全的对象回调

豆丁亦可,内容略微滞后: 

这里是从 word 直接粘贴过来,脚注链接都丢失了。

编写线程安全的类不是难事,用同步原语保护内部状态即 可。但是对象的生与死不能由对象自身拥有的互斥器来保护。如何保证即将析构对象 x  的 时候,不会有另一个线程正在调用 的成员函数?或者说,如何保证在执行 的成 员函数期间,对象 不会在另一个线程被析构?如何避免这种竞态条件是 C++ 

本文读者应具有 C++ 多 线程编程经验,熟悉互斥器、竞态条件等概念,了解智能指针,知道 Observer 设计模式。

多线程下的对象生命期管理

与其他面向对象语言不同,C++ 要 求程序员自己管理对象的生命期,这在多线程环境下显得尤为困难。当一个对象能被多个线程同时看到,那么对象的销毁时机就会变得模糊不清,可能出现多种竞态 条件:

在即将析构一个对象时,从何而知是否有外的线程正在执行该对象的成员函数?

如何保证在执行成员函数期间,对象不会在另一个线程被析 构?

在调用某个对象的成员函数之前,如何得知这个对象还活 着?

从多个线程访问时,其表现出正确的行为

无论操作系统如何调度这些线程,无论这些线程的执行顺序 如何交织

调用端代码无需额外的同步或其他协调动作

为了便于后文讨论,先约定两个工具类。我相信每个写C++ 多 线程程序的人都实现过或使用过类似功能的类,代码从略。

编写单个的线程安全的 class 不算太难,只需用同步原语保护其内部状态。例如下面这个简单的计数器类 Counter

// 当然在实际项目中,这个 class 用原子操作更合理,这里用锁仅仅为了举例。

对象的创建很简单

对象构造要做到线程安全,惟一的要求是在构造期间不要泄 露 this 指针,即

不要在构造函数中注册任何回调

即便在构造函数的最后一行也不行

之所以这样规定,是因为在构造函数执行期间对象还没有完 成初始化,如果 this 被泄露 (escape) 给了其他对象(其自身创建的子对象除外),那么别的线程有可能访问这个半成品对象,这会造成难以预 料的后果。

这也说明,二段式构造——即构造函数+initialize()——有时会是好办法,这虽然不符合 C++ 教 条,但是多线程下别无选择。另外,既然允许二段式构造,那么构造函数不必主动抛异常,调用端靠 initialize() 的返回值来判断对象是否构造成功,这能简化错误处理。

即使构造函数的最后一行也不要泄露 this

相对来说,对象的构造做到线程安全还是比较容易的,毕竟 曝光少,回头率为 0。而析构的线程安全就不那么简单,这也是本文关注的焦点。

对象析构,这在单线程里不会成为问题,最多需要注意避免 空悬指针(和野指针)。而在多线程程序中,存在了太多的竞态条件。对一般成员函数而言,做到线程安全的办法是让它们顺次执行,而不要并发执行,也就是让每 个函数的临界区不重叠。这是显而易见的,不过有一个隐含条件或许不是每个人都能立刻想到:函数用来保护临界区的互斥器本身必须是有效的。而析构函数破坏了 这一假设,它会把互斥器销毁掉。悲剧啊!

Mutex 只能保证函数一个接一个地执行,考虑下面的代码,它试图用互斥锁来保护析构函数:

接下来会发生什么,只有天晓得。因为析构函数会把 mutex_ 销毁,那么 (2) 处有可能永远阻塞下去,有可能进入“临界区”然后 core dump,或者发生其他更糟糕的情况。 

这个例子至少说明 delete 对象之后把指针置为 NULL 根本没用,如果一个程序要靠这个来防止二次释放,说明代码逻辑出了问题。

全地析构。因为成员 mutex 的生命期最多与对象一样长,而析构动作可说是发生在对象身故之后(或者身亡之时)。另外,对于基类对 象,那么调用到基类析构函数的时候,派生类对象的那部分已经析构了,那么基类对象拥有的 mutex 不能保护整个析构过程。再说,析构过程本来也不需要保护,因为只有别的线程都访问不到这个对象时,析构才是安全的,否则会有第 

一个动态创建的对象是否还活着,光看指针(引用也一样) 是看不出来的。指针就是指向了一块内存,这块内存上的对象如果已经销毁,那么就根本不能访问 [CCS:99](就像 free 之后的地址不能访问一样),既然不能访问又如何知道对象的状态呢?换句话说,判断一个指针是不是野 指针没有高效的办法。(万一原址又创建了一个新的对象呢?再万一这个新的对象的类型异于老的对象呢?)

比较难办,处理不好就会造成内存泄漏或重复释放。Association(关联/联系)是一种很宽泛的关系,它表示一个对象 用到 了另一个对象 b,调用了后者的成员函数。从代码形式上看,持 逻辑上的整体与部分关系。为了行文方便,下文不加区分地通称为“指涉”关系。如果 是动态创建的并在整个程序结束前有可能被释放, 那么就会出现第 节谈到的竞态条件。

那么似乎一个简单的解决办法是:只创建不销毁。程序使用 一个对象池来暂存用过的对象,下次申请新对象时,如果对象池里有存货,就重复利用现有的对象,否则就新建一个。对象用完了,不是直接释放掉,而是放回池子 里。这个办法当然有其自身的很多缺点,但至少能避免访问失效对象的情况发生。

这种山寨办法的问题有:

对象池的线程安全,如何安全地完整地把对象放回池子里,不会出现“部分放回”

如果共享对象的类型不止一种,那么是重复实现对象池还是使用类模板?

会不会造成内存泄露与分片?因为对象池占用的内存只增不减,而且不能借给别的 对象池使用。

回到正题上来,看看正常方式该咋办。如果对象 

那么悲剧又发生了,既然 所指 的 Observer 对象正在析构,调用它的任何非静态成员函数都是不安全的,何况是虚函数(C++ 标 准对在构造函数和析构函数中调用虚函数的行为有明确的规定,但是没有考虑并发调用的情况)。更糟糕的是,Observer 是个基类,执行到 (4) 处时,派生 类对象已经析构掉了,这时候整个对象处于将死未死的状态,core dump 恐怕是最幸运的结果。

这些 race condition 似乎可以通过加锁来解决,但在哪儿加锁,谁持有这些互斥锁,又似乎不是那么显而易见的。要是有什么活 着的对象能帮帮我们就好了,它提供一个 isAlive() 之类的程序函数,告诉我们那个对象还在不在。可惜指针和引用都不是对象,它们是内建类型。

}
源码下载之后,你可以通过任何文本编辑器浏览源码。如果希望编译和运行程序,你还需要按照下面的内容来准备环境。
本文中的源码使用cmake编译,只有cmake 3.8以上的版本才支持C++ 17,所以你需要安装这个或者更新版本的cmake。
另外,截止目前(2019年10月)为止,clang编译器还不支持并行算法。
具体的安装方法见下文。
安装好之后运行根目录下的下面这个命令即可:
 

它会完成所有的编译工作。
本文的源码在下面两个环境中经过测试,环境的准备方法如下。
在Mac上,我使用brew工具安装gcc以及tbb库。
考虑到其他人与我的环境可能会有所差异,所以需要手动告知tbb库的安装路径。
读者需要执行下面这些命令来准备环境:

注意,请通过运行g++-9命令以确认gcc的版本是否正确,如果版本较低,则需要通过brew命令将其升级到新版本:

Ubuntu上,通过下面的命令安装gcc-9。

但安装tbb库就有些麻烦了。这是因为Ubuntu 16.04默认关联的版本是较低的,直接安装是无法使用的。我们需要安装更新的版本。
联网安装的方式步骤繁琐,所以可以通过下载包的方式进行安装,我已经将这需要的两个文件放到的这里:
如果需要,你可以下载后通过apt命令安装即可:

创建线程非常的简单的,下面就是一个使用了多线程的Hello World示例:

对于这段代码说明如下:
  1. 新建线程的入口是一个普通的函数,它并没有什么特别的地方。
  2. 创建线程的方式就是构造一个thread对象,并指定入口函数。与普通对象不一样的是,此时编译器便会为我们创建一个新的操作系统线程,并在新的线程中执行我们的入口函数。
  3. 关于join函数在下文中讲解。
thread可以和callable类型一起工作,因此如果你熟悉lambda表达式,你可以直接用它来写线程的逻辑,像这样:

为了减少不必要的重复,若无必要,下文中的代码将不贴出include指令以及using声明。
当然,你可以传递参数给入口函数,像下面这样:

不过需要注意的是,参数是以拷贝的形式进行传递的。因此对于拷贝耗时的对象你可能需要传递指针或者引用类型作为参数。但是,如果是传递指针或者引用,你还需要考虑参数对象的生命周期。因为线程的运行长度很可能会超过参数的生命周期(见下文detach),这个时候如果线程还在访问一个已经被销毁的对象就会出现问题。
一旦启动线程之后,我们必须决定是要等待直接它结束(通过join),还是让它独立运行(通过detach),我们必须二者选其一。如果在thread对象销毁的时候我们还没有做决定,则thread对象在析构函数出将调用std::terminate()从而导致我们的进程异常退出。
请思考在上面的代码示例中,thread对象在何时会销毁。
需要注意的是:在我们做决定的时候,很可能线程已经执行完了(例如上面的示例中线程的逻辑仅仅是一句打印,执行时间会很短)。新的线程创建之后,究竟是新的线程先执行,还是当前线程的下一条语句先执行这是不确定的,因为这是由操作系统的调度策略决定的。不过这不要紧,我们只要在thread对象销毁前做决定即可。
  • join:调用此接口时,当前线程会一直阻塞,直到目标线程执行完成(当然,很可能目标线程在此处调用之前就已经执行完成了,不过这不要紧)。因此,如果目标线程的任务非常耗时,你就要考虑好是否需要在主线程上等待它了,因此这很可能会导致主线程卡住。
  • detach:detach是让目标线程成为守护线程(daemon threads)。一旦detach之后,目标线程将独立执行,即便其对应的thread对象销毁也不影响线程的执行。并且,你无法再与之通信。
对于这两个接口,都必须是可执行的线程才有意义。你可以通过joinable()接口查询是否可以对它们进行join或者detach。
上面是一些在线程内部使用的API,它们用来对当前线程做一些控制。
  • yield 通常用在自己的主要任务已经完成的时候,此时希望让出处理器给其他任务使用。
  • get_id 返回当前线程的id,可以以此来标识不同的线程。
  • sleep_for 是让当前线程停止一段时间。
  • sleep_until 和sleep_for类似,但是是以具体的时间点为参数。这两个API都以chrono API(由于篇幅所限,这里不展开这方面内容)为基础。

这段代码应该还是比较容易理解的,这里创建了两个线程。它们都会有一些输出,其中一个会先停止3秒钟,然后再输出。主线程调用join会一直卡住等待它运行结束。

在一些情况下,我们有些任务需要执行一次,并且我们只希望它执行一次,例如资源的初始化任务。这个时候就可以用到上面的接口。这个接口会保证,即便在多线程的环境下,相应的函数也只会调用一次。
下面就是一个示例:有三个线程都会使用init函数,但是只会有一个线程真正执行它。

我们无法确定具体是哪一个线程会执行init。而事实上,我们也不关心,因为只要有某个线程完成这个初始化工作就可以了。
请思考一下,为什么要在main函数中创建once_flag flag。如果是在worker函数中直接声明一个once_flag并使用行不行?为什么?
下面以一个并发任务为示例讲解如何引入多线程。
任务示例:现在假设我们需要计算某个范围内所有自然数的平方根之和,例如[1, 10e8]。
在单线程模型下,我们的代码可能是这样的:

  1. 通过一个常量指定数据范围,这个是为了方便调整。
  2. 通过一个全局变量来存储结果。
  3. 通过一个任务函数来计算值。

很显然,上面单线程的做法性能太差了。我们的任务完全是可以并发执行的。并且任务很容易划分。
下面我们就尝试以多线程的方式来改造原先的程序。

  1. 根据处理器的情况决定线程的数量。
  2. 对于每一个线程都通过worker函数来完成任务,并划分一部分数据给它处理。
  3. 等待每一个线程执行结束。
很好,似乎很简单就完成了并发的改造。然后我们运行一下这个程序:

很抱歉,我们会发现这里的性能并没有明显的提升。更严重的是,这里的结果是错误的。
要搞清楚为什么结果不正确我们需要更多的背景知识。
我们知道,对于现代的处理器来说,为了加速处理的速度,每个处理器都会有自己的高速缓存(Cache),这个高速缓存是与每个处理器相对应的,如下图所示:
事实上,目前大部分CPU的缓存已经不只一层。
处理器在进行计算的时候,高速缓存会参与其中,例如数据的读和写。而高速缓存和系统主存(Memory)是有可能存在不一致的。即:某个结果计算后保存在处理器的高速缓存中了,但是没有同步到主存中,此时这个值对于其他处理器就是不可见的。
事情还远不止这么简单。我们对于全局变量值的修改:sum += sqrt(i);这条语句,它并非是原子的。它其实是很多条指令的组合才能完成。假设在某个设备上,这条语句通过下面这几个步骤来完成。它们的时序可能如下所示:
在时间点a的时候,所有线程对于sum变量的值是一致的。
但是在时间点b之后,thread3上已经对sum进行了赋值。而这个时候其他几个线程也同时在其他处理器上使用了这个值,那么这个时候它们所使用的值就是旧的(错误的)。最后得到的结果也自然是错的。
当多个进程或者线程同时访问共享数据时,只要有一个任务会修改数据,那么就可能会发生问题。此时结果依赖于这些任务执行的相对时间,这种场景称为竞争条件(race condition)。
访问共享数据的代码片段称之为临界区(critical section)。具体到上面这个示例,临界区就是读写sum变量的地方。
要避免竞争条件,就需要对临界区进行数据保护。
很自然的,现在我们能够理解发生竞争条件是因为这些线程在同时访问共享数据,其中有些线程的改动没有让其他线程知道,导致其他线程在错误的基础上进行处理,结果自然也就是错误的。
那么,如果一次只让一个线程访问共享数据,访问完了再让其他线程接着访问,这样就可以避免问题的发生了。
接下来介绍的API提供的就是这样的功能。
开发并发系统的目的主要是为了提升性能:将任务分散到多个线程,然后在不同的处理器上同时执行。这些分散开来的线程通常会包含两类任务:
  1. 独立的对于划分给自己的数据的处理
其中第1项任务因为每个线程是独立的,不存在竞争条件的问题。而第2项任务,由于所有线程都可能往总结果(例如上面的sum变量)汇总,这就需要做保护了。在某一个具体的时刻,只应当有一个线程更新总结果,即:保证每个线程对于共享数据的访问是“互斥”的。mutex 就提供了这样的功能。
很明显,在这些类中,mutex是最基础的API。其他类都是在它的基础上的改进。所以这些类都提供了下面三个方法,并且它们的功能是一样的:
| lock|锁定互斥体,如果不可用,则阻塞 |
| try_lock |尝试锁定互斥体,如果不可用,直接返回 |
这三个方法提供了基础的锁定和解除锁定的功能。使用lock意味着你有很强的意愿一定要获取到互斥体,而使用try_lock则是进行一次尝试。这意味着如果失败了,你通常还有其他的路径可以走。
在这些基础功能之上,其他的类分别在下面三个方面进行了扩展:
  • 名称都带有timed,这意味着它们都支持超时功能。它们都提供了try_lock_for和try_lock_until方法,这两个方法分别可以指定超时的时间长度和时间点。如果在超时的时间范围内没有能获取到锁,则直接返回,不再继续等待。
  • recursive_mutex和recursive_timed_mutex的名称都带有recursive。可重入或者叫做可递归,是指在同一个线程中,同一把锁可以锁定多次。这就避免了一些不必要的死锁。
  • shared_timed_mutex和shared_mutex提供了共享功能。对于这类互斥体,实际上是提供了两把锁:一把是共享锁,一把是互斥锁。一旦某个线程获取了互斥锁,任何其他线程都无法再获取互斥锁和共享锁;但是如果有某个线程获取到了共享锁,其他线程无法再获取到互斥锁,但是还有获取到共享锁。这里互斥锁的使用和其他的互斥体接口和功能一样。而共享锁可以同时被多个线程同时获取到(使用共享锁的接口见下面的表格)。共享锁通常用在读者写者模型上。
使用共享锁的接口如下:
接下来,我们就借助刚学到的mutex来改造我们的并发系统,改造后的程序如下:

这里只有三个地方需要关注:
  1. 在访问共享数据之前加锁
  2. 在多线程中使用带锁的版本
执行之后结果输出如下:

这下结果是对了,但是我们却发现这个版本比原先单线程的版本性能还要差很多。这是为什么?
这是因为加锁和解锁是有代价的,这里计算最耗时的地方在锁里面,每次只能有一个线程串行执行,相比于单线程模型,它不但是串行的,还增加了锁的负担,因此就更慢了。
这就是为什么前面说多线程系统会增加系统的复杂度,而且并非多线程系统一定就有更好的性能。
不过,对于这里的问题是可以改进的。我们仔细思考一下:我们划分给每个线程的数据其实是独立的,对于数据的处理是耗时的,但其实这部分逻辑每个线程可以单独处理,没必要加锁。只有在最后汇总数据的时候进行一次锁保护就可以了。

这段代码的改变在于两处:
  1. 通过一个局部变量保存当前线程的处理结果
  2. 在汇总总结过的时候进行锁保护
运行一下改进后的程序,其结果输出如下:

可以看到,性能一下就提升了好多倍。我们终于体验到多线程带来的好处了。
我们用锁的粒度(granularity)来描述锁的范围。细粒度(fine-grained)是指锁保护较小的范围,粗粒度(coarse-grained)是指锁保护较大的范围。出于性能的考虑,我们应该保证锁的粒度尽可能的细。并且,不应该在获取锁的范围内执行耗时的操作,例如执行IO。如果是耗时的运算,也应该尽可能的移到锁的外面。
死锁是并发系统很常见的一类问题。
死锁是指:两个或以上的运算单元,每一方都在等待其他方释放资源,但是所有方都不愿意释放资源。结果是没有任何一方能继续推进下去,于是整个系统无法再继续运转。
死锁在现实中也很常见,例如:两个孩子分别拿着玩具的一半然后哭着要从对方手里得到另外一半玩具,但是谁都不肯让步。
在成年人的世界里也会发生类似的情况,例如下面这个交通状况:
下面我们来看一个编程示例。
现在假设我们在开发一个银行的系统,这个系统包含了转账的功能。
首先我们创建一个Account类来描述银行账号。由于这仅仅是一个演示使用的代码,所以我们希望代码足够的简单。Account类仅仅包含名称和金额两个字段。
另外,为了支持并发,这个类包含了一个mutex对象,用来保护账号金额,在读写账号金额时需要先加锁保护。

Account类很简单,我想就不用多做说明了。
接下来,我们再创建一个描述银行的Bank类。

银行类中记录了所有的账号,并且提供了一个方法用来查询整个银行的总金额。
这其中,我们最主要要关注转账的实现:transferMoney。该方法的几个关键点如下:
  1. 为了保证线程安全,在修改每个账号之前,需要获取相应的锁。
  2. 判断转出账户金额是否足够,如果不够此次转账失败。
有了银行和账户结构之后就可以开发转账系统了,同样的,由于是为了演示所用,我们的转账系统也会尽可能的简单:

这里每次生成一个随机数,然后通过银行进行转账。
最后我们在main函数中创建两个线程,互相在两个账号之间来回转账:

至此,我们的银行转账系统就开发完成了。然后编译并运行,其结果可能像下面这样:
如果你运行了这个程序,你会发现很快它就卡住不动了。为什么?
我们仔细思考一下这两个线程的逻辑:这两个线程可能会同时获取其中一个账号的锁,然后又想获取另外一个账号的锁,此时就发生了死锁。如下图所示:
当然,发生死锁的原因远不止上面这一种情况。如果两个线程互相join就可能发生死锁。还有在一个线程中对一个不可重入的互斥体(例如mutex而非recursive_mutex)多次加锁也会死锁。
你可能会觉得,我可不会这么傻,写出这样的代码。但实际上,很多时候是由于代码的深层次嵌套导致了死锁的发生,由于调用关系的复杂导致发现这类问题并不容易。
如果仔细看一下上面的输出,我们会发现还有另外一个问题:这里的输出是乱的。两个线程的输出混杂在一起了。究其原因也很容易理解:两个线程可能会同时输出,没有做好隔离。
下面我们就来逐步解决上面的问题。
对于输出混乱的问题很好解决,专门用一把锁来保护输出逻辑即可:

请思考一下两处lock和unlock调用,并考虑为什么不在while(true)下面写一次整体的加锁和解锁。
要避免死锁,需要仔细的思考和设计业务逻辑。
有一个比较简单的原则可以避免死锁,即:对所有的锁进行排序,每次一定要按照顺序来获取锁,不允许乱序。例如:要获取某个玩具,一定要先拿到锁A,再拿到锁B,才能玩玩具。这样就不会死锁了。
这个原则虽然简单,但却不容易遵守。因为数据常常是分散在很多地方的。
不过好消息是,C++ 11标准中为我们提供了一些工具来避免因为多把锁而导致的死锁。我们只要直接调用这些接口就可以了。这个就是上面提到的两个函数。它们都支持传入多个Lockable对象。
接下来我们用它来改造之前死锁的转账系统:

这里只改动了3行代码。
  1. 这里通过lock函数来获取两把锁,标准库的实现会保证不会发生死锁。
  2. lock_guard在下面我们还会详细介绍。这里只要知道它会在自身对象生命周期的范围内锁定互斥体即可。创建lock_guard的目的是为了在transferMoney结束的时候释放锁,lockB也是一样。但需要注意的是,这里传递了 adopt_lock表示:现在是已经获取到互斥体了的状态了,不用再次加锁(如果不加adopt_lock就是二次锁定了)。
运行一下这个改造后的程序,其输出如下所示:

现在这个转账程序会一直运行下去,不会再死锁了。输出也是正常的了。

互斥体(mutex相关类)提供了对于资源的保护功能,但是手动的锁定(调用lock或者try_lock)和解锁(调用unlock)互斥体是要耗费比较大的精力的,我们需要精心考虑和设计代码才行。因为我们需要保证,在任何情况下,解锁要和加锁配对,因为假设出现一条路径导致获取锁之后没有正常释放,就会影响整个系统。如果考虑方法还可以会抛出异常,这样的代码写起来会很费劲。
鉴于这个原因,标准库就提供了上面的这些API。它们都使用了叫做RAII的编程技巧,来简化我们手动加锁和解锁的“体力活”。

  1. 全局的互斥体g_i_mutex用来保护全局变量g_i
  2. 这是一个设计为可以被多线程环境使用的方法。因此需要通过互斥体来进行保护。这里没有调用lock方法,而是直接使用lock_guard来锁定互斥体。
  3. 在多个线程中使用这个方法。
RAII是一种C++编程技术,它将必须在使用前请求的资源(例如:分配的堆内存、执行线程、打开的套接字、打开的文件、锁定的互斥体、磁盘空间、数据库连接等——任何存在受限供给中的事物)的生命周期与一个对象的生存周期相绑定。

RAII保证资源可用于任何会访问该对象的函数。它亦保证所有资源在其控制对象的生存期结束时,以获取顺序的逆序释放。类似地,若资源获取失败(构造函数以异常退出),则为已构造完成的对象和基类子对象所获取的所有资源,会以初始化顺序的逆序释放。这有效地利用了语言特性以消除内存泄漏并保证异常安全。
  • 将每个资源封装入一个类,其中:
    • 构造函数请求资源,并建立所有类不变式,或在它无法完成时抛出异常,
    • 析构函数释放资源并决不抛出异常;
  • 始终经由 RAII 类的实例使用满足要求的资源,该资源
    • 自身拥有自动存储期或临时生存期,或
    • 具有与自动或临时对象的生存期绑定的生存期
回想一下上文中的transferMoney方法中的三行代码:

如果使用unique_lock这三行代码还有一种等价的写法:

请注意这里lock方法的调用位置。这里先定义unique_lock指定了defer_lock,因此实际没有锁定互斥体,而是到第三行才进行锁定。
最后,借助scoped_lock,我们可以将三行代码合成一行,这种写法也是等价的。
scoped_lock会在其生命周期范围内锁定互斥体,销毁的时候解锁。同时,它可以锁定多个互斥体,并且避免死锁。
目前,只还有shared_lock我们没有提到。它与其他几个类的区别在于:它是以共享的方式锁定互斥体。
至此,我们还有一个地方可以改进。那就是:转账金额不足的时候,程序直接返回了false。这很难说是一个好的策略。因为,即便虽然当前账号金额不足以转账,但只要别的账号又转账进来之后,当前这个转账操作也许就可以继续执行了。
这在很多业务中是很常见的一个需求:每一次操作都要正确执行,如果条件不满足就停下来等待,直到条件满足之后再继续。而不是直接返回。
条件变量提供了一个可以让多个线程间同步协作的功能。这对于生产者-消费者模型很有意义。在这个模型下:
  • 生产者和消费者共享一个工作区。这个区间的大小是有限的。
  • 生产者总是产生数据放入工作区中,当工作区满了。它就停下来等消费者消费一部分数据,然后继续工作。
  • 消费者总是从工作区中拿出数据使用。当工作区中的数据全部被消费空了之后,它也会停下来等待生产者往工作区中放入新的数据。
从上面可以看到,无论是生产者还是消费者,当它们工作的条件不满足时,它们并不是直接报错返回,而是停下来等待,直到条件满足。
下面我们就借助于条件变量,再次改造之前的银行转账系统。
这个改造主要在于账号类。我们重点是要调整changeMoney方法。
  1. 这里声明了一个条件变量,用来在多个线程之间协作。
  2. 这里使用的是unique_lock,这是为了与条件变量相配合。因为条件变量会解锁和重新锁定互斥体。
  3. 这里是比较重要的一个地方:通过条件变量进行等待。此时:会通过后面的lambda表达式判断条件是否满足。如果满足则继续;如果不满足,则此处会解锁互斥体,并让当前线程等待。解锁这一点非常重要,因为只有这样,才能让其他线程获取互斥体。
  4. 这里是条件变量等待的条件。如果你不熟悉lambda表达式,请自行网上学习,或者阅读我之前写的文章。
  5. 此处也很重要。当金额发生变动之后,我们需要通知所有在条件变量上等待的其他线程。此时所有调用wait线程都会再次唤醒,然后尝试获取锁(当然,只有一个能获取到)并再次判断条件是否满足。除了notify_all还有notify_one,它只通知一个等待的线程。wait和notify就构成了线程间互相协作的工具。
请注意:wait和notify_all虽然是写在一个函数中的,但是在运行时它们是在多线程环境中执行的,因此对于这段代码,需要能够从不同线程的角度去思考代码的逻辑。这也是开发并发系统比较难的地方。
有了上面的改动之后,银行的转账方法实现起来就很简单了,不用再考虑数据保护的问题了:
当然,转账逻辑也会变得简单,不用再管转账失败的情况发生。
修改完之后的程序运行输出如下:
但是细心的读者会发现,Bank totalMoney的输出有时候是200,有时候不是。但不管怎样,即便这一次不是,下一次又是了。关于这一点,请读者自行思考一下为什么,以及如何改进。
这一小节中,我们来熟悉更多的可以在并发环境中使用的工具,它们都位于<future>头文件中。
很多语言都提供了异步的机制。异步使得耗时的操作不影响当前主线程的执行流。
在C++11中,async便是完成这样的功能的。下面是一个代码示例:

这仍然是我们之前熟悉的例子。这里有两个地方需要说明:
  1. 这里以异步的方式启动了任务。它会返回一个future对象。future用来存储异步任务的执行结果,关于future我们在后面packaged_task的例子中再详细说明。在这个例子中我们仅仅用它来等待任务执行完成。
  2. 此处是等待异步任务执行完成。
需要注意的是,默认情况下,async是启动一个新的线程,还是以同步的方式(不启动新的线程)运行任务,这一点标准是没有指定的,由具体的编译器决定。如果希望一定要以新的线程来异步执行任务,可以通过launch::async来明确说明。launch中有两个常量:
  • async:运行新线程,以异步执行任务。
  • deferred:调用方线程上第一次请求其结果时才执行任务,即惰性求值。
除了通过函数来指定异步任务,还可以lambda表达式的方式来指定。如下所示:

在上面这段代码中,我们使用一个lambda表达式来编写异步任务的逻辑,并通过launch::async明确指定要通过独立的线程来执行任务,同时我们打印出了线程的id。

对于面向对象编程来说,很多时候肯定希望以对象的方法来指定异步任务。下面是一个示例:

这段代码有三处需要说明:
  1. 这里通过一个类来描述任务。这个类是对前面提到的任务的封装。它包含了任务的输入参数,和输出结果。
  2. work函数是任务的主体逻辑。
  3. 通过async执行任务:这里指定了具体的任务函数以及相应的对象。请注意这里是&w,因此传递的是对象的指针。如果不写&将传入w对象的临时复制。
在一些业务中,我们可能会有很多的任务需要调度。这时我们常常会设计出任务队列和线程池的结构。此时,就可以使用packaged_task来包装任务。
如果你了解设计模式,你应该会知道命令模式。
packaged_task绑定到一个函数或者可调用对象上。当它被调用时,它就会调用其绑定的函数或者可调用对象。并且,可以通过与之相关联的future来获取任务的结果。调度程序只需要处理packaged_task,而非各个函数。
packaged_task对象是一个可调用对象,它可以被封装成一个std::fucntion,或者作为线程函数传递给std::thread,或者直接调用。

  1. 首先创建一个集合来存储future对象。我们将用它来获取任务的结果。
  2. 同样的,根据CPU的情况来创建线程的数量。
  3. 将任务包装成packaged_task。请注意,由于concurrent_worker被包装成了任务,我们无法直接获取它的return值。而是要通过future对象来获取。
  4. 获取任务关联的future对象,并将其存入集合中。
  5. 通过一个新的线程来执行任务,并传入需要的参数。
  6. 通过future集合,逐个获取每个任务的计算结果,将其累加。这里r.get()获取到的就是每个任务中concurrent_worker的返回值。
为了简单起见,这里的示例只使用了我们熟悉的例子和结构。但在实际上的工程中,调用关系通常更复杂,你可以借助于packaged_task将任务组装成队列,然后通过线程池的方式进行调度:
在上面的例子中,concurrent_task的结果是通过return返回的。但在一些时候,我们可能不能这么做:在得到任务结果之后,可能还有一些事情需要继续处理,例如清理工作。
这个时候,就可以将promise与future配对使用。这样就可以将返回结果和任务结束两个事情分开。
下面是对上面代码示例的改写:

这段代码和上面的示例在很大程度上是一样的。只有小部分内容做了改动:
  1. concurrent_task不再直接返回计算结果,而是增加了一个promise对象来存放结果。
  2. 在任务计算完成之后,将总结过设置到promise对象上。一旦这里调用了set_value,其相关联的future对象就会就绪。
需要注意的是,future对象只有被一个线程获取值。并且在调用get()之后,就没有可以获取的值了。如果从多个线程调用get()会出现数据竞争,其结果是未定义的。
如果真的需要在多个线程中获取future的结果,可以使用shared_future。
借助这个参数,开发者可以直接使用这些算法的并行版本,不用再自己创建并发系统和划分数据来调度这些算法。
sequenced_policy可能的取值有三种,它们的说明如下:
注意:本文的前面已经提到,目前clang编译器还不支持这个功能。因此想要编译这部分代码,你需要使用gcc 9.0或更高版本,同时还需要安装Intel Threading Building Blocks。
下面还是通过一个示例来进行说明:
  1. 通过一个函数生成个随机数。

  2. 将数据拷贝3份,以备使用。

  3. 接下来将通过三个不同的parallel_policy参数来调用同样的sort算法。每次调用记录开始和结束的时间。

  4. 输出本次测试所使用的时间。

可以看到,性能最好的和最差的相差了超过26倍。
双一流大学研究生团队创建,专注于目标检测与深度学习,希望可以将分享变成一种习惯!

整理不易,点赞鼓励一下吧↓

}

软件架构表示整个软件的结构,与数据组织与执行流程相关,它反映设计思想、开发方法学以及域模型。

多线程软件架构是一种将工作模式分解为两个或更多并发执行线程的软件架构。分解软件是分割为单独逻辑任务的过程,供软件的工作模式来执行,其中部分任务分配给不同的执行线程,当这些线程允许同步或并发地在单进程内执行时,软件就具有多线程架构。

对话(session)为最大的执行单元,它包含一个或多个可以并发执行的进程。当多个进程并发执行时,对话就是多任务或多进程。一个进程包含一个或多个线程。当一个进程内有两个或更多线程并发执行时,进程就是多线程(或称多线程化)。一个线程包含一条或多条指令,当一个线程内包含一条或多条并发执行的指令时,这些指令共同形成了一个算法,我们称这套指令为并行算法(parallel algorithm)。

2. 使用多线程的常见架构

在应用程序中使用多线程处理的目标之一是精选出具有内在并发性和并行性的架构。通过这种方式,软件开发者不必强迫应用程序的逻辑使用多线程化模型。至于多线程处理,功能必须服从形式。应当搜寻和使用那些天生就适合于多线程处理的架构。如果对于研究域不存在这些的架构,则必须创建这样的架构。有一些天生就适合于多线程处理的常见架构,如下是其中的三种。

C/S范例将工作模式分为由进程或线程表示的两端。一端(即客户机)请求数据或动作,另一端(即服务器)完成该请求。

黑板是一种问题解决结构,与专家系统一样,黑板设计用于解决特定的问题,由此生成的解决方案也只针对该问题才有用。

黑板架构由两个基本组件构成: a. 黑板数据结构; b. 知识源。

通过知识源,在问题解决过程中协作使用黑板。知识源是一个具有特定域过程化知识、陈述性知识或两者兼有的软件组件。它也包含一个或多个推理引擎,可以工作于所包含的过程化或陈述性知识之上。

黑板模型是多线程技术的理想之选,它的临界区可以受互斥量和条件变量的保护。可以将每个知识源分配给一个单独的线程。

知识源所执行的处理过程可能要消耗很多的处理器时间。就像在逻辑服务器中一样,知识源可以执行演绎、归纳或推测。这种推理可能包含上千次推论与假设过程。我们并不希望序列化在这种条件下知识源所做的处理过程。特别是,一个知识源的输入可能是另一个知识源的输出。它们经常重复和递归性地一起工作。有时,它们也可能以一个分享的方式工作。不过,唯一发生的交互就是通过黑板数据结构,它是一个临界区。

4. 途径上的不同(面向对象与过程化)

过程化编程方式的特点是在系统开发中使用自顶向下(top-down)、逐步求精法(stepwise refinement approach)。在自顶向下方式中,系统或应用程序用单个主过程来描述。然后将这个过程分解成逻辑子过程(logical subprocedure)。这些逻辑子过程被进一步分解成逻辑子过程。这个分解过程一直持续到整个应用程序能够在程序语句级进行描述。在过程化编程方式中,将应用程序看作过程和函数的集合。

在过程化编程中,在将应用程序分解为函数部分时,几乎不考虑所使用的数据结构和数据库。数据只被看作应用程序中过程和函数所使用的对象而已。应用程序以过程为中心。在多线程应用程序中的主要缺陷是竞争条件和死锁,它们都以数据和资源为焦点,而正是临界区(数据)和资源(数据和设备)的误用才导致多线程序应用程序出现问题。多线程的过程化方式因为是以函数为中心,而不是以数据为中心,所以才存在缺陷。

在多线程应用程序中,任何非常量数据都有可能成为一个临界区。在中型到大型的多线程应用程序中,如何才能成功管理临界区以及其它共享资源呢?可以使用面向对象编程技术来解决多线程应用程序中大部分管理临界区及其它共享资源的问题。适当使用封装来组合互斥类和事件类与即将在多线程环境中使用的所有类,这是控制竞争条件的最有效可行方法之一。

我们可以使用C++的封装机制提高多线程应用程序的可靠性,其途径至少有以下两种:

a. 在C++类中封装互斥量和条件变量,只提供成员函数的访问权限;

b. 通过继承或复合结合互斥量或条件变量类与宿主类。

当多线程处理面向对象架构时,转移责任给我们带来了一个重要的途径。同步对象访问以及保护对象临界区的责任应用从对象的用户身上转移到对象的提供者身上。对象的用户不用费心去规划对象具有的临界区,在使用私有属性时,对象的提供者必须提供同步和保护措施。

对于可能应用于多线程环境中的类,我们必须包含对象访问策略。提供对多线程处理的支持以及对类的临界区的保护是类提供者的一部分责任。如果打算将类用于多线程应用程序中,则必须为类中的临界区指定访问策略。类的设计者必须提供类的相应并发模型。

为了开发支持并发的面向对象架构,C++程序员可以首先,在一套低层类中封装特定系统的功能性,低层类例如线程类和互斥类,同时,在低层类的上层构建高层类。最后,最高层类的用户中只能看到一般性的并发模型。

一旦设计者为类选定了并发访问策略,必须相应地设计该类的成员函数,使得类用于多线程环境中时,强制实施并发访问策略。这要求与类连接的同步类应当用于正确保护临界区。它还要求设计者将类的数据组件分割成可以并发修改和访问的部分以及不能如此做的部分。锁定和取消锁定通过所需的成员函数来完成。用信号通知和广播由所需的成员函数来完成。通过类成员函数实现类的访问策略后,该类就可以用作一个任务类、线程类或应用框架中的组件了。

在设计方,它从选定适宜于多线程处理的架构开始,一旦选定了架构,然后设计应用框架。应用框架设计完毕,再设计支持应用框架的类库和容器类。类库、容器类和域类都是基于同步类而构建。

在实现方,从最低层的类开始,向最高层类推进。它是一种构建块途径来多线程处理C++组件。它不是试图强迫应用于多个线程,而是选定一个自然而然适宜于多线程处理的架构。然后,我们用一些提供了同步和协调的组件来填充这些架构。

}

我要回帖

更多关于 c++多线程有几种实现方法,都是什么 的文章

更多推荐

版权声明:文章内容来源于网络,版权归原作者所有,如有侵权请点击这里与我们联系,我们将及时删除。

点击添加站长微信