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

本章主要内容

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

上一章中,我们看到各种在线程间保护共享数据的方法。但有时,你不仅需要保护数据,还需要同步不同线程上的操作。例如,一个线程可能需要等待另一个线程完成一个任务,然后第一个线程才能完成自己的任务。一般来说,通常希望线程等待特定事件发生或一个条件变为真。尽管可以通过定期检查共享数据中存储的“任务完成”标记或类似的东西来实现这一点,但这远不够理想。像这样需要在线程之间同步操作的场景是如此的常见,以至于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 休眠100ms
        lk.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来处理简介中的示例呢?如何让正在等待工作的线程休眠,直到有数据要处理?下面的清单展示了使用条件变量实现的一种方法。

b1b1e9359a98c48dd11b59746017b8e9.png

首先,有一个用来在两个线程之间传递数据的队列①。当数据准备好时,准备数据的线程使用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<>的容器适配器如下所示的:

d81b582e035abf61ab885ceb00481f42.png

如果忽略构造、赋值以及交换操作时,就只剩下了三组操作:查询整个队列的状态的操作(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(),将会等到有值可检索的时候才返回。如果你以栈示例为指引,接口可能会是下面这样:

8d3181cde66dcfd0958e813cac953fd3.png

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

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

dfd320d3d4f7f15544b2bd933d6addb2.png

466a561928a79e37f238b291ab94428b.png

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

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

86defdb11e420cb5559a13e16110afa4.png

7919dda98a1c081e5749c2283ee72a9c.png

尽管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),然后返回该值。下面的清单显示了一个简单的示例。

8df62cb12eb838e11764848cb3f370e3.png

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

6691b67364f4256eff56ff4f7034eabf.png

默认情况下,当等待期望时,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更高层次的抽象,所以我将从它开始。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值