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

//分别实现线程函数,并返回值


2.使用临界区对象、事件对象和互斥对象三种方法实现多线程同步

临界区被初始化后,当程序进入临界区后便拥有临界区的全部权,其他线程无权进入只能等对方释放临界区以后,方可进入临界区拥有其全部权再对临界区进行操做 注意:上述是windows API中相关函数,CCriticalSection类是MFC中定义的临界区类,须要在MFC程序中使用(此程序为控制台程序没法使用MFC类),能够操做临界区,lock锁定临界区、unlock释放临界区 临界区为依次访问,不能实现其中一个线程一释放临界区就会被另外一个线程访问临界区!不能实现实时监听 事件对象是指用户在程序中使用内核对象的有无信号状态实现线程的同步临界区被初始化后,当程序进入临界区后便拥有临界区的全部权,其他线程无权进入只能等对方释放临界区以后,方可进入临界区拥有其全部权再对临界区进行操做 SetEvent()将指定的事件设置为有信号状态(有信号状态下其他线程能够访问); ResetEvent()将指定的事件设置为无信号状态; 事件对象为当即访问,一旦事件对象被设置为有信号 马上会被其他线程访问!能实现实时监听 互斥对象还能够在进程间使用,在实现线程同步时包含一个线程ID和一个计数器,线程ID表示拥有互斥对象的线程,计数器表示该互斥对象被同一线程所使用次数 互斥对象为当即访问,一旦互斥对象被释放 马上会被其它正在等待的线程访问!能实现实时监听 //分别实现线程函数,并返回值
源码下载之后,你可以通过任何文本编辑器浏览源码。如果希望编译和运行程序,你还需要按照下面的内容来准备环境。
本文中的源码使用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倍。
双一流大学研究生团队创建,专注于目标检测与深度学习,希望可以将分享变成一种习惯!

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

  • vs2010C++多线程临界区小案例 简单明了,适合初学者,,,

  • 在同一个进程的多线程同步锁,宜用临界区锁,它比较节约线程上下文切换带来的系统开销。但因临界区工作在用户模式下,所以不能对不同进程中的多线程进行同步。

  • 关于读写者问题的源码,其中,采用的方式是临界区和条件变量相结合。

  • 该demo展示了多线程使用临界区访问共享资源,并与不使用临界区对共享资源 访问做对比;并展示了多种开启线程(AfxBeginThread、_beginthreadex)的 方式

  • 10.3 用临界区对象同步线程实例 10.4 用事件对象同步线程实例 10.5 用信号量对象同步线程实例 10.6 调整线程优先级实例 第11章 图形图像开发 11.1 用DC对象缩放位图实例 11.2 用画笔对象画线实例 11.3 用画刷对象...

  • 使用该类可实现C++ 多线程日志的记录,创建了日志记录线程,使用日志等级、队列、信号量、临界区等方法实现记录,可在较高的实时性系统上完成日志记录。

  • 关于在类的构造函数和析构使用临界区函数导致的多线程死锁的一个经验之谈

  • 第6章详细讨论临界区、死锁、数据竞争以及无限延迟方面的主题。第7-10章讲解用于线程同步、线程间通信、进程间通信以及多线程处理的C++组件。第11章讨论C++对象在多线程环境中的行为和交互方式。第12章简单介绍多...

  • 摘要:VC/C++源码,系统相关,线程同步 临界区同步线程,VC++实例源码,多线程同步一直是讨论不休的问题,临界区线程同步更是有点值得一说,这个源码主要是演示了如何使多线程在临界区实现同步,希望对此有疑问的朋友...

  • 一、自旋锁 自旋锁是一种基础的同步原语,用于保障对共享数据的互斥访问。与互斥锁的相比,在获取锁失败的时候不会使得线程阻塞而是一直自旋尝试获取锁。当线程等待自旋锁的时候,CPU不能做其他事情,而是一直处于...

  • 代码中的临界区变量(cs)就可以看作是变量s的锁,当函数EnterCriticalSection返回时,当前线程就获得了这把锁,之后就是对变量的访问了。访问完成后,调用LeaveCriticalSection表示释放这把锁,允许其他线程继续...

  • VC++6.0中实现线程同步,分别采用互斥对象、临界区对象、事件对象以及信号量对象来实现对线程的同步操作,代码经过项目的检验。

  • 多线程临界段同步演示1,用不同步和同步来对比多线程编程,直观深刻。采用API方式而非MFC类方式。

  • 在做C++的项目中发现,由于用惯了c#的lock(){}和java的Synchronize(){},windows自带的临界区,互斥量,内核事件,信号量用起来不太习惯,于是打算自己在vc++中封装一个类似的东东。

我要回帖

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

 

随机推荐