arm体系结构与编程(第2版)_《C++并发编程实战第2版》第四章:同步并发操作(3/4)

4.4 使用操作的同步来简化代码

使用到目前为止在本章中描述的同步设施作为构建块,可以让你专注于需要同步的操作,而不是机制。这可以帮助简化代码的一种途径是,它适应了一种更函数式(从函数式编程的意义上说)的并发编程方法。不同于在线程间直接共享数据,每个任务都被提供它需要的数据,并且结果可以通过使用期望将其散播给任何其他需要它的线程。

4.4.1 使用期望的函数式编程

术语函数式编程(FP,functional programming)指的是一种编程风格,其中函数调用的结果仅依赖于该函数的参数,而不依赖于任何外部状态。这与函数的数学概念有关,这意味着如果你使用相同的参数调用一个函数两次,结果将完全相同。这是C++标准库中许多数学函数的属性,比如sin,cos和sqrt,以及基本类型上的简单操作,比如3+3,6*9,或者1.3/4.7。一个纯函数也不修改任何外部状态;函数的效果完全局限于返回值。

这使得事情思考起来比较简单,特别是在涉及并发时,因为在第3章中讨论的与共享内存相关的许多问题都消失了。如果没有对共享数据进行修改,就不存在竞争条件,因而也不需要使用互斥锁来保护共享数据。这是个极大的简化,像Haskell(http://www.haskell.org/)这种编程语言中所有函数默认都是纯函数,它在并发编程中越来越受欢迎。因为大多数东西都是纯的,所以实际修改共享状态的非纯函数就更加突出,因此更容易思考它们如何适应应用程序的整体结构。

然而,FP的好处并不仅限于那些把它作为默认范式的语言。C++是一个多范式的语言,也可以写出FP风格的程序。这在C++11中比C++98更容易,因为C++11支持lambda函数(详见附录A,A.6节),还合入了Boost和TR1中的std::bind,以及引入变量的自动类型推导(详见附录A,A.7节)。期望是使FP风格的并发在C++中可行的最后一块拼图;一个期望可以在线程之间传递,允许一个计算的结果依赖于另一个计算的结果,而不需要显式地访问共享数据。

FP风格的快速排序

为了演示如何使用期望来实现FP风格的并发,让我们看一下快速排序算法的一个简单实现。该算法的基本思想很简单:给定一个值列表,将一个元素作为主元(pivot element),然后将列表划分为两个集合——小于主元的集合和大于或等于主元的集合。通过对两个集合排序,然后返回小于主元的有序列表,接着是主元,最后是大于等于主元的有序列表,可以获得列表的有序拷贝。图4.2展示了一个有10个整数的列表是怎么按照这个计划排序的。一个FP风格的顺序实现如下清单所示;它按值使用并返回列表,而不是像std::sort()那样在原地排序。

bec0d10e94337688cc40464647195859.png
图4.2 FP风格的递归排序

虽然接口是FP风格的,但如果你自始至终使用FP风格,你会做很多拷贝,所以你内部使用“普通”命令式的风格。使用splice()将第一个元素从列表的前面分割出来,以此作为主元①。尽管这可能会导致次优排序(就比较和交换的数量而言),但由于遍历列表,使用std::list执行任何其他操作都会增加相当多的时间。你知道你想要它出现在结果中,所以你可以直接将它拼接到你将要使用的列表中。现在,你也会想要使用它来进行比较,所以让我们引用它来避免拷贝②。然后可以使用std::partition将序列划分为小于主元的值和不小于主元的值③。指定分区标准的最简单方法是使用lambda函数;你可以使用引用捕获来避免拷贝主元值(更多有关lambda函数的信息详见附录A,A.5节)。

d044af93e0360fc2f732addc6153a209.png

std::partition()原地重排列表并返回一个迭代器,该迭代器标记第一个不小于主元值的元素。迭代器的完整类型可能相当冗长,因此只需使用auto类型说明符强制编译器帮你搞定(参见附录A, A.7节)。

现在,你选择了一个FP风格的接口,因此如果要使用递归对这两个“一半”进行排序,则需要创建两个列表。可以再次用splice()将input列表中小于divided_point的值移动到新列表lower_part④中。这将把其余的值单独留在input中。然后可以使用递归调用对这两个列表进行排序⑤⑥。通过使用std::move()来传递列表,你也可以在这里避免拷贝——结果无论如何都会隐式地移出。最后,你可以再次使用splice()以正确的顺序将结果拼在一起。new_higher里的值放在尾部⑦,在主元之后,并且new_lower放在前面,在主元之前⑧。

FP风格的并行快速排序

因为已经使用了函数式的风格,所以现在很容易使用期望将其转换为并行版本,如下一个清单所示。操作集和以前相同,只是其中一些操作现在并行运行。这个版本使用了一个使用了期望和函数式风格的快速排序算法的实现。

9483b7eaa078cbf488479f71b288c4c4.png

这里最大的变化是,不是在当前线程上排序值较小的部分,而是使用std::async()在另一个线程上排序①。与前面一样,使用直接递归对列表值较大的部分排序②。通过递归地调用parallel_quick_sort(),你可以利用可用的硬件并发。如果std::async()每次启动一个新线程,那么如果你递归三次,你将有八个线程在运行;如果递归10次(对于~1000个元素),如果硬件能够处理的话,那么将有1024个线程在运行。如果库认为生成的任务太多(可能是因为任务的数量超过了可用的硬件并发),它会切换成同步生成新任务。它们将在调用get()的线程中运行,而不是在一个新线程上运行,从而避免在无法提高性能时将任务传递给另一个线程的开销。值得注意的是,每个任务启动一个新线程是完全符合std::async实现的(即使面对巨大的超订oversubscription)除非std::launch::deferred显式指定了,或同步运行所有任务,除非显式指定了std::launch::async。如果你依赖库来实现自动伸缩,建议你检查实现的文档,看看它展示了什么行为。

不使用std::async()的话,你可以编写自己的spawn_task()函数,作为std::packaged_task和std::thread的简单包装器,如清单4.14所示;你将为函数调用的结果创建一个std::packaged_task,从它获得期望,在一个线程上运行它,然后返回期望。这也许不会提供太多的优势(实际上可能会导致大量的超订),但它将为迁移到更复杂的实现铺平道路,该复杂实现将任务添加到由工作线程池运行的队列中。我们将在第9章中讨论线程池。只有当你知道自己在做什么,并且希望完全控制线程池的构建和执行任务的方式时,这样做才可能比使用std::async更有价值。

0533165ea39f2483965960033b6f7336.png

不管怎样,还是回到parallel_quick_sort。因为你只是使用了直接递归来获得new_higher,所以可以像前面一样将其拼接到适当的位置③。但是,new_lower现在是std::future<std::list<T>>而非是一个列表,所以需要在调用splice()前调用get()来检索值④。然后等待后台任务完成,并将结果移动到splice()调用中;get()返回对所包含结果的一个右值引用,因此可以将其移出(请参阅附录A,A.1.1节了解更多关于右值引用和移动语义的信息)。

即使假设std::async()优化了可用硬件并发的使用,这仍然不是快速排序的理想并行实现。首先,std::partition做了很多工作,这仍然是一个顺序调用,但目前它已经足够好了。如果你对最快的并行实现感兴趣,请查阅学术文献。或者,你可以使用C++ 17标准库中的并行重载(参见第10章)。

FP不是唯一的避免共享可变数据的并发编程范式;另一个范式是通信顺序进程(CSP,Communicating Sequential Processer[2]),这里线程在概念上是完全独立的,没有共享数据,但有通信通道允许消息在它们之间传递。这种范式被Erlang(http://www.erlang.org/)编程语言所采用,并且在消息传递接口 (MPI,Message Passing Interface;http://www.mpi-forum.org/) 环境中常用来做C和C++的高性能运算。我相信现在你得知C++也可以通过一些规则来支持这一点也不会感到奇怪了;下一节将讨论实现这个的一种方法。

4.4.2 使用消息传递来同步操作

CSP的思想很简单:如果没有共享数据,则可以完全独立地思考每个线程,纯粹基于它对接收到的消息的响应方式。因此,每个线程实际上都是一个状态机:当它接收到一条消息时,它会以某种方式更新自己的状态,可能会向其他线程发送一条或多条消息,执行的处理则取决于初始状态。编写这种线程的一种方法是将其形式化并实现一个有限状态机模型,但这不是唯一的方法;状态机可以隐式地存在于应用程序的结构中。在任何给定的场景中,哪种方法工作得更好取决于具体情况的行为需求和编程团队的专业知识。无论你选择如何实现每个线程,将线程分离为独立的处理都有潜力消除许多共享数据并发的复杂性,从而使编程更容易,降低bug率。

真正的通信顺序处理没有共享数据,所有通信都通过消息队列传递,但是由于C++线程共享一个地址空间,因此不可能强制执行这一要求。这里就是规则的用武之地:作为应用程序或库的作者,我们有责任确保线程之间不共享数据。当然,为了让线程通信,必须共享消息队列,但是细节可以包装在库中。

假设你正在实现ATM的代码。该代码需要处理与试图取款的人的交互,和与相关银行的交互,以及控制物理机器来接受取款人的卡、显示相应的信息、处理按键、发钞和退卡。

处理这一切的一种方法是将代码分成三个独立的线程:一个处理物理机,一个处理ATM逻辑,一个与银行通信。这些线程可以纯粹通过传递消息而不是共享任何数据进行通信。例如,当一个人在机器上插卡或者按下一个按钮时,处理机器的线程可以发送一条消息给处理逻辑的线程,然后处理逻辑的线程可能发送一条消息给机器线程指示要分配多少钱,等等。

建模ATM逻辑的一种方法是将其作为状态机。在每个状态中,线程等待可接受的消息,然后处理该消息。这可能导致转移到一个新的状态,然后循环继续。一个简单实现所涉及的状态如图4.3所示。在这个简化的实现中,系统等待卡片被插入。一旦插入了卡,然后它就等待用户输入它们的PIN,一次一个数字。他们可以删除最后一个输入的数字。一旦输入了足够的数字就校验PIN。如果PIN不符合要求,则服务结束,因此把卡退回给客户,然后继续等待某个人插卡。如果PIN没问题,则要么等待他们取消事务或者选择一个取款金额。如果他们取消,服务就结束,然后退卡。如果他们选择一个金额,在发钞之前需要等待银行的确认,然后退卡或者显示一条“余额不足”的消息,再退卡。显然,一个真正的ATM要复杂得多,但这足以说明这种思想。

4798121c5582dfac9f5c74bdab1dd79c.png
图4.3 ATM机的一个简化的状态机模型

为ATM逻辑设计了状态机之后,就可以用一个类来实现它,这个类有成员函数表示每个状态。然后,每个成员函数可以等待特定的传入消息集,并在它们到达时处理它们,可能会触发到另一个状态的切换。每个不同的消息类型由一个独立的结构表示。清单4.15显示了这样一个系统中ATM逻辑的简单实现的一部分,其中主循环和第一个状态的实现都在等待插卡。

如你所见,消息传递所需的所有同步都完全隐藏在消息传递库中(附录C给出了该库的基本实现以及本示例的完整代码)。

79c61d5afe48c85fc3dfae932ab3bb7e.png

正如前面提到的,这里描述的实现是从ATM需要的实际逻辑中粗略地简化出来的,但是它确实让你对消息传递编程风格有一些感觉。不需要考虑同步和并发问题,只需要考虑在任何给定点接收哪些消息以及发送哪些消息即可。这种ATM逻辑的状态机运行在单个线程上,而系统的其他部分,如银行接口和终端接口运行在单独的线程上。这种风格的程序设计称为角色模型(Actor model)——系统中有几个离散的角色(每个角色运行在单独的线程上),它们互相发送消息来执行手头的任务,除了直接通过消息传递的状态外,没有共享状态。

执行从run()成员函数开始⑤,它设置初始状态为waiting_for_card⑥,然后反复执行代表当前状态(不管它是什么状态)的成员函数⑦。这些状态函数是atm类的简单成员函数。wait_for_card函数①也很简单:它发送一条消息到接口,以此来显示一条“等待插卡”的信息②,之后就等待消息进行处理③。这里唯一可以处理的消息类型是card_inserted消息,你可以使用lambda函数来处理它④。你可以传递任何函数或函数对象给处理函数,但是对于像这样简单的情况,使用lambda是最简单的。注意,handle()函数调用被链接到wait()函数上;如果接收到与指定类型不匹配的消息,则将其丢弃,并且线程继续等待,直到接收到匹配的消息。

lambda函数本身在成员变量中缓存了来自卡上的帐号,清除当前PIN,向接口硬件发送消息以显示要求用户输入PIN的内容,并更改状态为“获取PIN”状态。一旦消息处理程序完成后,状态函数返回,然后主循环调用新的状态函数⑦。

getting_pin状态函数稍微复杂一些,因为它可以处理三种不同类型的消息,如图4.3所示。下面的清单展示了它的实现。

77b142e6cb767d68682d04a7babdf52f.png

这一次,你可以处理三种消息类型,因此wait()函数在末尾链接了三个handle()调用,①②和③。每次对handle()的调用都将消息类型指定为模板参数,然后传入一个lambda函数,该函数将该特定消息类型作为参数。因为调用以这种方式链接在一起,wait()实现知道它在等待一个digit_ pressed消息、一个clear_last_pressed消息或一个cancel_pressed消息。任何其他类型的消息都将被丢弃。

这一次,你不必在收到消息时更改状态。例如,如果你得到一个digit_pressed消息,你将其添加到pin中,除非它是最后一位数字。清单4.15中的主循环⑦将会再次调用getting_pin()去等待下一个数字(或清除数字,或取消交易)。

这与图4.3中所示的行为相对应。每个状态框由一个独立的成员函数实现,该函数等待相关消息并适当地更新状态。

如你所见,这种编程风格可以极大地简化设计并发系统的任务,因为可以完全独立地处理每个线程。它是使用多个线程分离关注点的示例,因此需要你明确地决定如何在线程之间划分任务。

在4.2节中,我提到了并发技术规范提供了期望的扩展版本。扩展的核心部分是指定延续continuations)的能力——当期望就绪时,附带的函数自动运行。让我们借此机会探索这是如何简化我们的代码的。

4.4.3 延续风格的并发与并发技术规范

并发技术规范在std::experiment命名空间中提供了新版本的std::promisestd::packaged_taks。与std命名空间中类型完全不同,其返回实例类型为std::experimental::future,而不是std::future。这使得用户能够使用std::experimental::future中的关键新特性——延续continuations)。

假设你有一个正在运行的任务,它将产生一个结果,而期望将在该结果可用时持有该结果。然后需要运行一些代码来处理结果。对于std::future,你必须等待期望准备就绪,或者使用完全阻塞的wait()成员函数,或者使用wait_for()或wait_until()成员函数来允许超时等待。这可能不太方便,并可能使代码复杂化。你需要的是一种“当数据准备好了,就进行处理”的方法。这正是延续带给我们的;不出所料,用于向期望添加延续的成员函数叫做then()。给定一个期望fut,可以通过fut.then(continuation)添加一个延续。

std::future类一样, std::experimental::future存储的值也只能被检索一次。如果那个值被一个延续消费了,这意味着别的代码不能访问它。因此,当一个延续用fut.then()被添加时,原来的期望fut就失效了。相反,对fut.then()的调用会返回一个新的期望来保存延续调用的结果。如下代码所示:

std::experimental::future<int> find_the_answer;
auto fut=find_the_answer();
auto fut2=fut.then(find_the_question);
assert(!fut.valid());
assert(fut2.valid());

find_the_question 延续函数计划在初始期望就绪时在“未指定的线程上”运行。这使得实现可以自由地在线程池或另一个库管理的线程上运行它。就目前而言,这给了实现很大的自由;这是有意为之,目的是当延续被添加到将来的C++标准中时,实现者将能够利用他们的经验来更好地指定线程的选择,并为用户提供适当的机制来控制线程的选择。

与直接调用std::async或std::thread不同,不能将参数传递给延续函数,因为参数已经由库定义——传递给延续的是一个就绪的期望,其中包含触发延续的结果。假设你的find_the_answer函数返回一个int,前面例子中引用的find_the_question函数必须使用std::experimental:: future<int>作为它的唯一参数;例如:

std::string find_the_question(std::experimental::future<int> the_answer);

这样做的原因是,延续链接的期望可能最终持有一个值或一个异常。如果期望被隐式解除引用以致直接将值传递给延续,则库必须决定如何处理异常,而通过将期望传递给延续,延续就可以处理异常。在简单的情况下,这可能通过调用fut.get()完成并且允许重新抛出的异常传播到延续函数的外面。就像传递给std::async的函数一样,从延续逃出的异常将存储在保存延续结果的期望中。

注意,并发技术规范没有指定std::async的等价物,尽管实现可能提供一个作为扩展。编写这样一个函数也是相当简单:使用std::experimental::promise获得一个期望,然后生成一个新线程运行lambda,该线程将承诺的值设置为所提供函数的返回值,如下一个清单所示。

0d21053923f494f6bbc0924b16448248.png

这里在期望中存储该函数的结果,或者捕获该函数抛出的异常并将其存储在期望中,就像std::async所做的那样。另外,它使用set_value_at_thread_exit和set_exception_at_thread_exit来确保线程在期望就绪之前,线程局部变量已经被正确地清除了。

then()调用返回的值本身就是一个成熟的期望本身。这意味着你可以链结延续。

4.4.4 链接延续

假设你有一系列耗时的任务要执行,并且希望异步地执行它们,以便为其他任务释放主线程。例如,当用户登录到你的应用程序时,你可能需要将凭据发送到后端进行身份认证;然后,当详细信息经过身份认证后,进一步向后端请求有关用户帐户的信息;最后,当检索到该信息时,用相关信息更新显示。作为顺序代码,你可以编写如下清单所示的内容。

b98b4fb7c297b687727480cdf88ee767.png

但是,你不想要顺序代码;你需要异步代码,这样你就不会阻塞用户界面(UI)线程。使用普通的std::async,你可以像下面的清单一样将其全部推给一个后台线程,但是这仍然会阻塞该线程,在等待任务完成时消耗资源。如果你有许多这样的任务,那么你最终可能会有大量线程,它们除了等待之外什么也不做。

163a6a56dede583568d0c22c3cba5c1b.png

为了避免所有这些阻塞线程,你需要某种机制来在每个任务完成时链接它们:延续。下面的清单显示了相同的整个流程,但这次分解为一系列任务,每个任务都链接到前一个任务上作为延续。

3e059b81cfc908a848f4baa4f2b1c005.png

注意,每个延续都采用std::experimental::future作为唯一参数,然后使用.get()检索包含的值。这意味着异常可以在整条链上向下传播,所以在最后的延续中的info_to_display.get()调用将抛出异常,如果链中的任何函数抛出一个异常的话,这里的catch块可以处理所有的异常,就像清单4.18中的catch块所做的那样。

如果到后端的函数调用内部阻塞了,因为它们在等待消息通过网络或数据库操作完成,那么你还没有完成操作。你可能已经将任务分割成单独的部分,但是它们仍然会阻塞调用,所以你仍然得到阻塞的线程。你需要的是后端调用在数据准备好时返回就绪的期望,而不阻塞任何线程。在本例中,backend.async_authenticate_user(username,password)现在将返回std::experimental::future<user_id>,而不是普通的user_id。

你可能认为这会使代码复杂化,因为从延续返回一个期望会得到future<future<some_value>>,否则你必须将then调用放入延续中。幸运的是,如果你这么想,那么你就错了:延续支持有一个叫做期望展开(future-unwrapping)的漂亮特性。如果传递给then()调用的延续函数返回一个future<some_type>,则then()调用将依次返回一个future<some_type>。这意味着你最终的代码类似于下一个清单,并且在异步函数链中没有阻塞。

2498aff3eaf05dafdc6e1cd430a1614b.png

这几乎和清单4.18中的顺序代码一样简单,只是在.then调用和lambda声明周围多了点样板。如果你的编译器支持C++14泛型lambda,那么lambda参数中的期望类型可以用auto替换,这将进一步简化了代码:

return backend.async_authenticate_user(username, password).then(
    [](auto id){
      return backend.async_request_current_info(id.get());
    });

如果你需要比简单的线性控制流程更复杂的东西,那么你可以通过把逻辑放到其中的一个lambda来实现;对于真正复杂的控制流,可能需要写一个单独的函数。

到目前为止,我们主要关注std::experimental::future中的延续支持。如你所料,std::experimental::shared_future也支持延续。这里的区别是std::experimental::shared_future对象可以有多个延续,并且延续的参数是std::experimental::shared_future,而不是std::experimental::future。这自然源于std::experimental::shared_future的共享性质——因为多个对象可以引用相同的共享状态,如果只有一个延续被允许,就会有两个线程之间的竞争条件,它们都试图添加延续到它们自己的std::experimental::shared_future对象中。这显然是不希望的,因此允许使用多个延续。一旦允许多个延续,你也可以允许通过相同的std::experimental::shared_future实例添加它们,而不是每个对象只允许一个延续。此外,你不能将共享状态打包在一次性的std::experimental::future中,把它传递给第一个延续,当你还想将其传递给第二个延续时。因此传递给延续函数的参数也必须是std::experimental::shared_future:

auto fut = spawn_async(some_function).share();
auto fut2 = fut.then([](std::experimental::shared_future<some_data> data){
      do_stuff(data);
    });
auto fut3 = fut.then([](std::experimental::shared_future<some_data> data){
      return do_other_stuff(data);
    });

由于share()调用,fut是一个std::experimental::shared_future,因此延续函数必须采用std::experimental::shared_future作为参数。但是,从延续返回的值是一个普通的std::experimental::future——该值目前还没有共享,直到你做了一些事情来共享它——因此fut2和fut3都是std::experimental::future。

延续并不是并发技术规范中对期望的唯一增强,尽管它们可能是最重要的。也提供了两个重载函数,允许你等待一堆期望中的任何一个变成就绪,或者等待所有的期望准备就绪。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值