本章主要内容

  • 等待一个事件
  • 用期望等待一次性事件
  • 带时间限制的等待
  • 使用操作的同步来简化代码

上一章中,我们看到各种在线程间保护共享数据的方法。但有时,你不仅需要保护数据,还需要同步不同线程上的操作。例如,一个线程可能需要等待另一个线程完成一个任务,然后第一个线程才能完成自己的任务。一般来说,通常希望线程等待特定事件发生或一个条件变为真。尽管可以通过定期检查共享数据中存储的“任务完成”标记或类似的东西来实现这一点,但这远不够理想。像这样需要在线程之间同步操作的场景是如此的常见,以至于C++标准库提供了条件变量(condition variables)和期望(futures)形式的设施来处理它。这些设施在并发技术规范(TS,Conncurrency Technical Specification)中得到了扩展,技术规范为期望(futures)提供了更多的操作,一起的还有新的同步设施锁存器(latches)和屏障(barriers)。

本章将讨论如何使用条件变量,期望,锁存器以及屏障来等待事件,以及如何使用它们来简化操作的同步。

4.1 等待一个事件或其他条件

假设你乘坐通宵火车旅行。一种确保你在正确的车站下车的方法是整晚保持清醒,并注意火车停在哪里。这样你就不会误站,但是等你到站的时候估计也累够呛。或者,你可以看一下时刻表,看看火车应该什么时候到达,然后把闹钟定得比到站时间稍微早一点, 接着就可以去睡觉了。这样就可以了;你也不会误站,但如果火车晚点,你就醒得太早了。当然,闹钟的电池也可能会没电了,于是你就睡过了头,以至于误了站。理想的方式是,你可以去睡觉,不管什么时候,只要火车到站,就有人或其他东西能把你唤醒就好了。

这和线程有什么关系呢?嗯,如果一个线程正在等待另一个线程完成一个任务,它有几个选项。首先,它可以不断检查共享数据中的标记(由互斥锁保护),并让第二个线程在完成任务时设置该标记。这在两个方面是浪费的:线程不断检查标记会消耗宝贵的处理时间,并且当互斥锁被等待的线程锁住时,其他线程不能锁住它。这两者对等待线程都不利:如果等待线程在运行,这就限制了可用的执行资源去运行被等待的线程,同时为了检查标记,等待线程锁住了互斥锁来保护它,被等待线程就不能在它完成任务后锁住互斥锁来设置标记。这种情况类似于你整晚和列车驾驶员交谈:驾驶员不得不减慢火车的速度,因为你分散了他的注意力,所以火车需要更长的时间才能到站。类似地,正在等待的线程正在消耗系统中其他线程可以使用的资源,最终等待时间可能比必要的时间更长。

第二个选择是让等待线程在检查的间隙用std::this_thread::sleep_for()函数休眠很短的时间(参见4.3节):

bool flag;
std::mutex m;void wait_for_flag()
{std::unique_lock<std::mutex> lk(m);while(!flag){lk.unlock(); // 1 解锁互斥锁std::this_thread::sleep_for(std::chrono::milliseconds(100)); // 2 休眠100mslk.lock(); // 3 再次锁住互斥锁}
}

循环体中,在休眠前②,函数对互斥锁进行解锁①,并且在休眠结束后再对互斥锁进行上锁③,因此另外的线程就有机会获取锁并设置标记。

这是一个进步,因为当线程休眠时,线程没有浪费执行时间,但是很难确定正确的休眠时间。太短的休眠仍然会浪费处理时间去做检查;太长的休眠时间,会导致当被等待线程完成时,线程还处于休眠状态,从而导致耽搁。这种睡过头的情况很少会对程序的运行产生直接影响,但它可能意味着在快节奏的游戏中掉帧或在实时应用中超出时间片。

第三个也是首选的方法是使用C++标准库提供的设施去等待事件本身。等待另一个线程触发事件的最基本机制(例如前面提到的在流水线中存在的额外工作)是条件变量(condition variable)。从概念上讲,条件变量与事件或其他条件(condition)相关联,一个或多个线程可以等待该条件满足。当一个线程确定满足条件时,它可以通知一个或多个等待条件变量的线程,以唤醒它们并允许它们继续处理。

4.1.1 使用条件变量等待条件

C++标准库对条件变量有两套实现:std::condition_variable和std::condition_variable_any。这两个实现都包含在<condition_variable>库头文件中。两者都需要与一个互斥锁一起才能工作,因为需要互斥锁提供适当的同步;前者仅限于使用std::mutex,而后者可以使用任何满足类似于互斥锁的最低标准的对象,因而带有_any后缀。由于std::condition_variable_any更通用,因此在大小、性能或操作系统资源方面有额外的潜在成本,所以除非需要额外的灵活性,否则应该首选std::condition_variable。

那么,如何使用std::condition_variable来处理简介中的示例呢?如何让正在等待工作的线程休眠,直到有数据要处理?下面的清单展示了使用条件变量实现的一种方法。

首先,有一个用来在两个线程之间传递数据的队列①。当数据准备好时,准备数据的线程使用std::lock_guard来保护队列,并把数据推入队列中②。然后它调用std::condition_variable实例的notify_one()成员函数通知等待线程 (如果有的话)③。注意,你把将数据推入队列的代码放在一个较小的作用域,所以你在解锁之后通知条件变量——这是为了,如果等待线程立即醒来,它没必要再被阻塞在等待你解锁互斥锁。

在栅栏的另一侧,有一个正在处理数据的线程,这个线程首先锁住互斥锁,但这次使用std::unique_lock而不是std::lock_guard④——你马上就会知道为什么。然后线程在std::condition_variable上调用wait()成员函数,并传入锁对象和表示等待条件的lambda函数⑤。Lambda函数是C++11添加的新特性,它可以让一个匿名函数作为另一个表达式的一部分,并且它们非常适合被指定为wait()这种标准库函数的谓词。在这个例子中,简单的Lambda函数[]{return !data_queue.empty();}会去检查data_queue是否非空——也就是说,队列中有数据准备要处理。附录A的A.5节有Lambda函数更多的细节。

wait()会去检查这些条件(通过调用所提供的lambda函数),当条件满足(lambda函数返回true)时返回。如果条件不满足(lambda函数返回false),wait()函数将解锁互斥锁,并且将这个线程置于阻塞或等待状态。当准备数据的线程调用notify_one()通知条件变量时,处理数据的线程从睡眠状态中醒来,获取互斥锁上的锁,并且再次检查条件是否满足。在条件满足的情况下,从wait()返回并仍然持有锁;当条件不满足时,线程将对互斥锁解锁,并且重新开始等待。这就是为什么用std::unique_lock而不使用std::lock_guard——等待中的线程必须在等待期间解锁互斥锁,并在这之后对互斥锁再次上锁,而std::lock_guard没有这么灵活。如果互斥锁在线程休眠期间保持锁住状态,准备数据的线程将无法锁住互斥锁,也就无法添加数据项到队列中,这样等待线程也永远看不到它的条件被满足。

清单4.1为等待使用了一个简单的lambda函数⑤,它检查队列是否非空,不过任何函数和可调用对象都可以担此责任。如果已经有了检查条件的函数(可能因为它比像这样简单的测试要复杂一些),那么可以直接传入此函数,不一定非要包在一个lambda中。在调用wait()期间,条件变量可以对提供的条件检查任意次数;但是它总是在锁住互斥锁的情况下才这么做,并且当(且仅当)用于测试条件的函数返回true时,它将立即返回。当等待的线程重新获得互斥锁并检查条件时,如果它不是直接响应来自另一个线程的通知,则称为伪唤醒(spurious wakeup)。因为根据定义,任何这种伪唤醒的数量和频率都是不确定的,所以不建议使用具有副作用的函数进行条件检查。如果你这样做,你必须为副作用发生多次做好准备。

基本上,std::condition_variable::wait是对忙-等待的优化。事实上,一个合格(虽然不太理想)的实现技术可以只是一个简单的循环:

template<typename Predicate>
void minimal_wait(std::unique_lock<std::mutex>& lk, Predicate pred){while(!pred()){lk.unlock();lk.lock();}
}

你的代码必须准备不但能使用这种最小的wait()实现,而且还能使用只有在调用notify_one()或notify_all()时才会唤醒的实现。

解锁std::unique_lock的灵活性,不仅适用于对wait()的调用;它还可以用在数据待处理但还未处理的时候⑥。处理数据可能是一个耗时的操作,正如你在第3章中看到的,在互斥锁上持有的时间超过必要的时间不是一个好主意。

像清单4.1这样,使用队列在多个线程间转移数据是很常见的。如果做得好,同步可以限制在队列本身,这将极大地减少同步问题和竞争条件的可能数量。鉴于此,现在让我们从清单4.1中提取一个通用的线程安全队列

4.1.2 使用条件变量构建线程安全队列

如果你准备设计一个通用队列,花点时间想想队列需要哪些操作是值得的,就像在3.2.3节线程安全栈中做的一样。我们可以从C++标准库中找灵感,形式为std::queue<>的容器适配器如下所示的:

如果忽略构造、赋值以及交换操作时,就只剩下了三组操作:查询整个队列的状态的操作(empty()和size());查询队列中元素的操作(front()和back());修改队列的操作(push(), pop()和emplace())。这和3.2.3中的栈一样,因此也会遇到在接口上固有的竞争条件。所以,需要将front()和pop()合成一个函数调用,就像之前在栈实现时合并top()和pop()一样。清单4.1中的代码加入一些细微的变化:当使用队列在线程之间传递数据时,接收线程通常需要等待数据。这里提供pop()函数的两个变种:try_pop()和wait_and_pop()。try_pop(),尝试从队列中弹出数据,它总会直接返回(带有失败指示),即使没有值可检索;wait_and_pop(),将会等到有值可检索的时候才返回。如果你以栈示例为指引,接口可能会是下面这样:

和栈一样,为了简化代码,减少了构造函数并删除了赋值操作符。和之前一样,也提供了两个版本的try_pop()和wait_for_pop()。第一个重载的try_pop()①把检索的值存储在引用变量中,所以它可以用返回值做状态;当检索到一个值时,它将返回true,否则返回false(参见A.2节)。第二个重载②就不能这样了,因为它直接返回检索到的值。不过,当没有值可检索时,这个函数可以返回NULL指针。

那么,所有这些与清单4.1有什么关系呢?嗯,你可以从中抽取代码用于push()和wait_and_pop(),如下面的清单所示。

互斥锁和条件变量现在包含在threadsafe_queue实例中,因此不再需要单独的变量①,并且调用push()也不需要外部同步②。另外,wait_and_pop()负责条件变量的等待③。

另一个重载的wait_and_pop()现在编写起来很简单,剩下的函数几乎可以逐字从清单3.5中的栈示例中拷贝。最终的队列实现展示如下。

尽管empty()是一个const成员函数,并且拷贝构造函数的other参数是一个const引用,但是其他线程可能有对该对象的非const引用,并且可能正在调用可变的成员函数,因此你仍然需要锁住互斥锁。因为锁住互斥锁是一种可变操作,所以互斥锁对象必须标记为可变的(mutable)①,这样就可以在empty()和拷贝构造函数中锁住它。

在多个线程等待同一事件时,条件变量也很有用。如果线程用于划分工作负载,因此只有一个线程应该响应通知,那么可以使用与清单4.1中所示完全相同的结构,只需运行多个数据处理线程实例。当新数据准备好时,调用notify_one()将会触发一个正在执行wait()的线程去检查它的条件并且从wait()函数返回(因为你刚向data_queue中添加一个数据项)。 不能保证哪个线程会被通知,甚至不能保证是否有线程在等待被通知,因为有可能所有的处理线程仍然在处理数据。

另一种可能是几个线程在等待同一事件,并且它们都需要响应该事件。这可能发生在共享数据初始化的情况下,所有的处理线程可以使用相同的数据,但是需要等待它被初始化(尽管可能有更好的机制,比如std::call once;关于这个选项的讨论,请参阅第3章的3.3.1节),或者线程需要等待共享数据的更新,比如定期的重新初始化。在这些情况下,准备数据的线程可以对条件变量调用notify_all()成员函数,而不是notify_one()。顾名思义,这将导致当前执行wait()的所有线程检查它们正在等待的条件。

如果等待线程只等待一次,因此当条件为真时,它将不再等待该条件变量,那么条件变量可能不是同步机制的最佳选择。如果等待的条件是某一特定数据的可用性,则尤其如此。在这种情况下,期望(future)可能更合适。

4.2 使用期望等待一次性事件

假设你要乘飞机去国外度假。一旦你到达机场,完成了各种登机手续,你还得等待你的航班准备登机的通知,这可能要等上好几个小时。是的,你也许能找到一些消磨时间的方式,比如看书、上网,或者在机场价格高昂的咖啡馆用餐,但基本上你只是在等待一件事:登机的信号。不仅如此,一个给定的航班只会有一次;下次你去度假时,你将等待不同的航班。

C++标准库将这种一次性事件建模为所谓的期望(future)。如果一个线程需要等待一个特定的一次性事件,它会以某种方式获得一个表示该事件的期望。然后,线程可以周期性地等待很短的一段时间,以查看事件是否已经发生(查看出发时刻表),同时在检查的间隙执行其他任务(在价格高昂的咖啡馆用餐)。或者,它可以执行另一个任务,直到它需要事件在它继续之前发生,然后就等待期望变成就绪(ready)。期望可能有与之相关的数据(比如你的航班在哪个登机口登机),也可能没有。一旦事件发生(因此期望已经变成就绪),期望就不能被重置。

C++标准库中,有两种期望,实现为两个类模板,声明在<future>库头文件中:唯一的期望(unique futures)(std::future<>)和共享的期望(shared futures) (std::shared_future<>)。它们仿照了std::unique_ptrstd::shared_ptr。一个std::future的实例是唯一一个引用其关联事件的实例,而多个std::shared_future实例可能引用同一事件。后一种情况中,所有实例会在同时变为就绪状态,然后他们可以访问与事件相关的任何数据。这些关联的数据是这些类成为模板的原因;就像std::unique_ptr和std::shared_ptr一样,模板参数是关联数据的类型。如果没有相关联的数据,可以使用std::future<void>std::shared_future<void>的特化模板。尽管期望用于线程间通信,但是期望对象本身不提供同步访问。如果多个线程需要访问一个期望对象,它们必须通过互斥锁或其他同步机制来保护访问,如第3章所述。但是,正如你将在4.2.5节中看到的,多个线程可以访问它们自己的std::shared_future<>副本,而无需进一步同步,即使它们都引用相同的异步结果。

并发技术规范在std::experimental名空间中提供了这些类模板的扩展版本:std::experimental::future<>和std::experimental:: shared_future<>。这些类的行为与std名空间中的对应类相同,但是它们有额外的成员函数来提供额外的功能。需要重点注意的是,名字std::experimental并非暗示代码的质量(我希望实现的质量和你的库供应商提供的其他东西是一样的),但是需要强调的是,这些都是非标准的类和函数,因此,如果它们最终被采用到未来的C++标准中,它们的语法和语义可能会有变化。如果想要使用这些设施,需要包含<experimental/future>头文件。

最基本的一次性事件是在后台运行的计算的结果。在第2章中,你看到std::thread并没有提供一种简单方法从这样的任务中返回一个值,并且我承诺过将在第4章中用期望来解决——现在是时候看看怎么解决了。

4.2.1 从后台任务返回值

假设有一个长时间运行的计算,你希望最终产生一个有用的结果,但当前不需要该值。也许你已经找到了一种方法来确定生命,宇宙和万物的答案——从道格拉斯·亚当斯[1]那取一个例子(译注:作者这里开玩笑,扯远了,可以无视)。你可以启动一个新的线程来执行计算,但这意味着你必须负责把结果传送回来,因为std::thread没有提供直接的机制来做这个事情。这就是需要std::async函数模板(也声明在<future>头文件中)的地方。

如果你不需要立即得到结果,可以使用std::async来启动一个异步任务(asynchronous task)。而不是给你一个std::thread对象去等待,std::async会返回一个std::future对象,它将最终持有函数的返回值。当你需要该值时,只需在期望上调用get(),线程就会阻塞,直到期望就绪(ready),然后返回该值。下面的清单显示了一个简单的示例。

std::async允许你通过向调用中添加更多的参数来传递额外的参数给函数,这与std::thread的方法相同。如果第一个参数是指向成员函数的指针,那么第二个参数提供了应用成员函数的对象(要么直接是对象,要么通过指针,亦或包装在std::ref中),其余的参数作为成员函数的参数传递。否则,第二个和随后的参数将作为函数或可调用对象的第一个参数。就如std::thread,当参数为右值时,拷贝操作将使用移动(moving)的方式转移原始数据。这就允许使用只支持移动的类型作为函数对象和参数。参见下面的清单:

默认情况下,当等待期望时,std::async是否启动一个新线程,还是同步执行任务,取决于实现。在大多数情况下,这是你想要的,但是你可以在调用函数之前,通过std::async的附加参数指定要使用哪种模式。这个参数的类型是std::launch,它可以是std::launch::defered,表明函数调用被推迟到wait()或get()函数调用时才执行,或者是std::launch::async,表明函数必须在它自己的线程上运行,还可以是std::launch::deferred | std::launch::async表明让具体实现来选择哪种方式。最后一个选项是默认的。如果函数调用是推迟的,它可能永远也不会运行。例如:

auto f6=std::async(std::launch::async,Y(),1.2);  // 在新线程上执行
auto f7=std::async(std::launch::deferred,baz,std::ref(x));  // 在wait()或get()调用时执行
auto f8=std::async(std::launch::deferred | std::launch::async,baz,std::ref(x));  // 实现选择执行方式
auto f9=std::async(baz,std::ref(x)); // 实现选择执行方式
f7.wait();  //  调用延迟函数

正如你将在本章后面以及第8章中看到的,使用std::async可以很容易地将算法划分为可以并发运行的任务。然而,这并不是将std::future与任务联系起来的唯一方法;你还可以通过将任务包装到std::packaged_task<>类模板的实例中,或者通过编写代码使用std::promise<>类模板显式地设置值来实现。std::packaged_task是一个比std::promise更高层次的抽象,所以我将从它开始。

c++高级编程(第4版).pdf_《C++并发编程实战第2版》第四章:同步并发操作(1/4)相关推荐

  1. python项目开发实战第2版pdf_《树莓派开发实战++第2版》.pdf

    [实例简介] [实例截图] [核心代码] D11章配置与管理1 1.0引言1 1.1xuan择树莓派型号1 1.2封装树莓派3 1.3xuan择电源4 1.4xuan择操作系统发行包6 1.5通过NO ...

  2. 罗宾斯管理学13版pdf_罗宾斯管理学(第13版)笔记和课后习题(含考研真题)详解...

    在线电子书:罗宾斯<管理学>(第13版)笔记和课后习题(含考研真题)详解 完整电子书下载:http://learning.100xuexi.com/Ebook/817637.html 本书 ...

  3. 图解leetcode初级算法python版 pdf_图解LeetCode初级算法(Python版)

    第1章 浅谈算法 1.1 算法概述 1.2 度量算法 1.2.1 时间复杂度 1.2.2 空间复杂度 1.3 Python&Pythonic 第2章 基础算法之排序 2.1 冒泡排序 2.1. ...

  4. GitHub上标星75k+超牛的《Java面试突击版》,java开发实战经典第二版答案

    运筹帷幄之后,决胜千里之外!不打毫无准备的仗,我觉得大家可以先从下面几个方面来准备面试: 1.自我介绍.(你可千万这样介绍: "我叫某某,性别,来自哪里,学校是哪个,自己爱干什么" ...

  5. 《IBM-PC汇编语言程序设计》(第2版)【沈美明 温冬婵】——第四章——自编解析与答案

    4.1 指出下列指令的错误: (1) MOV AH, BX :寄存器类型不匹配 (2) MOV [BX], [SI] :不能都是存储器操作数 (3) MOV AX, [SI][DI] :[SI]和[D ...

  6. 《软件工程教程》(第2版) 主编:吴迪 马宏茹 丁万宁 第四章课后习题参考答案

    第4章   总体设计  课后习题参考答案 一.选择题(单选或多选) (1)面向数据流的软件设计方法中,一般将信息流分为(A). A.变换流和事务流 B.变换流和控制流 C.事务流和控制流 D.数据流和 ...

  7. 【读书笔记】Java并发编程的艺术

    第一章 并发编程的挑战 上下文切换 上下文切换概述 切出:一个线程被剥夺处理器的使用权而暂定运行 切入:一个线程被选中占用处理器或者继续运行 上下文:在这种切入切出的过程中,操作系统需要保存和恢复相应 ...

  8. 全书重点总结 |《Java并发编程的艺术》| 持续更新

    写在前面 重点章节: 第二章:Java并发机制的底层实现原理 第四章:Java并发编程基础 第五章:Java中的锁 第六章:Java并发容器和框架(ConcurrentHashMap 高频考点) 第八 ...

  9. [转] 《Java并发编程的艺术》笔记

    转自https://gitee.com/Corvey/note 作者:Corvey 第一章 并发编程的挑战 略 第二章 Java并发机制的底层实现原理 volatile的两条实现原则: Lock前缀指 ...

  10. 多线程知识梳理(1) - 并发编程的艺术笔记

    第三章 Java内存模型 3.1 Java内存模型的基础 通信 在共享内存的模型里,通过写-读内存中的公共状态进行隐式通信:在消息传递的并发模型里,线程之间必须通过发送消息来进行显示的通信. 同步 在 ...

最新文章

  1. 《如何与面试官处朋友》系列-缓存击穿、穿透、雪崩场景原理大调解
  2. zookeeper基础知识整理
  3. lnmp.org + phpstorm + xdebug
  4. 八十五、Python | Leetcode数据结构之图和动态规划算法系列
  5. Flex Socket 安全沙箱问题解决
  6. 内核中_init,_exit中的作用
  7. 小程序 长按api_高质量的微信小程序样式模板应该长什么样?
  8. 云图说|AI开发难!难!难!端云协同多模态AI开发套件你需要了解一下
  9. 为什么envi镶嵌老是出错_10个数学考试老出错的根源和解决办法,你值得拥有
  10. 通过AT指令控制ESP8266
  11. Linux开发终端霓虹灯效果
  12. PHP设计模式——六大原则
  13. 《Spring Data实战》——2.2 定义查询方法
  14. EasyUI----增删改查
  15. visual studio for mac在线安装网络错误
  16. Android系统各个版本发布时间
  17. POI导出word文件中表格合并方法(行合并,列合并)
  18. [CAS]ServiceTicket [x] with service [x] does not match supplied service [x]
  19. 用WPF做一个简易浏览器
  20. JavaScript学习:利用第三方接口做手机归属地查询

热门文章

  1. 由一个小库存软件想到的
  2. JavaScript对TreeView的操作全解
  3. SQL2000 统计每周,每月,每季,每年的数据
  4. C#中对象的序列化与反序列化
  5. #pragma与__pragma的区别与联系2009-01-19 15:47__pragma与#pragma的功能相同,所不同的是:
  6. 数据结构排序、查找算法
  7. 【CyberSecurityLearning 64】SSRF
  8. 内中断---汇编学习笔记
  9. 【windows gdi+】GDI+ Image类加载图片时异常问题处理与分析
  10. Spring Ioc 之 Bean的加载(3):createBean()