C ++ CoreHard 2018年秋季报告“ Actor vs CSP vs Tasks ...”的文本版本

11月初,明斯克主持了下一届C ++会议C ++ CoreHard 2018年秋季会议,并发表了船长的报告“ Actors vs CSP vs Tasks ...” ,其中谈到了比“可以在C ++中看起来”更高级别的应用程序。裸机多线程”,具有竞争力的编程模型。 在此报告的删节版下,转化为文章 精梳,修剪,补充。

我想借此机会感谢CoreHard社区在明斯克组织下一次大型会议并有发言的机会。 并且还可以在YouTube上迅速发布报告视频报告

因此,让我们继续讨论对话的主要主题。 也就是说,我们可以使用哪些方法来简化C ++中的多线程编程,其中一些方法在代码中的外观如何,特定方法固有的功能,它们之间的共同点,等等。

注意:在报告的原始演示文稿中发现了错误和错别字,因此本文将使用更新和编辑后的版本中的幻灯片 ,这些幻灯片可以在Google幻灯片SlideShare中找到

赤裸裸的多线程是邪恶的!


您需要从重复的平庸性开始,但是仍然很重要:
通过裸线程,互斥量和条件变量进行的多线程C ++编程是汗水痛苦鲜血

最近在Habré上的这篇文章中描述了一个很好的例子:“ 移动在线射击游戏Tacticool的元服务器的体系结构 ”。 在其中,他们谈到了他们如何设法收集与C和C ++中多线程代码开发相关的各种耙。 赛车的结果是“记忆传递”,而并行化失败则导致性能下降。

结果,一切自然结束了:
在花费了几周的时间来查找和修复最关键的错误之后,我们决定从头开始重写所有内容比尝试解决当前解决方案的所有缺点要容易得多。

人们在使用服务器的第一个版本时吃了C / C ++,然后用另一种语言重写了服务器。

在现实世界中,在舒适的C ++社区之外,开发人员如何拒绝使用C ++,即使在仍然适当且合理地使用C ++的情况下,也很好地证明了这一点。

但是为什么呢?


但是,为什么一再说C ++中的“裸多线程”是邪恶的,为什么人们继续以值得更好的应用程序的毅力继续使用它呢? 怪什么:

  • 无知?
  • 懒惰?
  • NIH综合征?

毕竟,经过时间和许多项目检验的方法远非一种。 特别是:

  • 演员
  • 沟通顺序流程(CSP)
  • 任务(异步,承诺,期货等)
  • 数据流
  • 反应式编程
  • ...

希望主要原因仍然是无知。 这不太可能在大学教授。 因此,进入该行业的年轻专业人士会使用他们已经了解的知识。 如果然后不补充知识存储,那么人们将继续使用裸线程,互斥对象和condition_variables。

今天,我们将讨论该列表中的前三种方法。 我们将不进行抽象讨论,而是以一项简单任务为例。 让我们尝试展示解决该问题的代码在使用Actor,CSP进程和通道以及使用Task时的外观。

实验挑战


需要实现以下HTTP服务器:

  • 接受请求(图片ID,用户ID);
  • 给出带有该用户唯一的“水印”的图片。

例如,某些通过订阅分发内容的付费服务可能需要这种服务器。 如果此服务中的图片随后“弹出”某处,则通过上面的“水印”,将有可能了解谁需要“阻止氧气”。

任务是抽象的,它是在我们的演示项目Shrimp(我们已经讨论过: No.1No.2No.3 )的影响下专门为这份报告制定的。

我们的HTTP服务器将按以下方式工作:

收到客户的请求后,我们转向两个外部服务:

  • 第一个返回我们用户信息。 包括从那里得到一张带有“水印”的图片;
  • 第二个返回我们原始图像

这两种服务都是独立工作的,我们可以同时访问它们。

由于请求的处理可以彼此独立进行,甚至在处理单个请求时甚至可以并行执行某些操作,因此使用竞争能力本身就很明显。 想到的最简单的事情是为每个传入请求创建一个单独的线程:

但是,“单请求=单工作流”模型过于昂贵,而且扩展性不佳。 我们不需要这个。

即使我们浪费了许多工作流程,我们仍然需要少量的工作流程:

在这里,我们需要一个单独的流来接收传入的HTTP请求,一个单独的流来处理我们自己的传出HTTP请求,一个单独的流来协调接收到的HTTP请求的处理。 以及用于对图像执行操作的工作流池(由于对图像的操作完全并行,因此一次通过多个流处理图片将减少其处理时间)。

因此,我们的目标是在少量工作线程上处理大量并发传入请求。 让我们看看我们如何通过各种方法实现这一目标。

一些重要的免责声明


在继续讲故事和解析代码示例之前,需要做一些说明。

首先,以下所有示例均未绑定到任何特定框架或库。 API调用名称中的任何匹配都是随机的,并且是意料之外的。

其次,下面的示例中没有错误处理。 这是故意进行的,因此幻灯片紧凑且可见。 而且,以便使材料适合于为报告分配的时间。

第三,这些示例使用某个实体execution_context,该实体包含有关程序中还存在什么的信息。 填补这个实体取决于方法。 对于参与者,execution_context将具有到其他参与者的链接。 对于CSP,在execution_context中将存在用于与其他CSP进程进行通信的CSP通道。 等等

方法1:演员


演员模型简而言之


使用演员模型时,解决方案将由单独的对象演员构建,每个对象演员都有自己的私有状态,除了演员本人之外,任何人都无法访问该状态。

Actor通过异步消息相互交互。 每个参与者都有自己的唯一邮箱(消息队列),发送到参与者的消息将保存在该邮箱中,并从中检索消息以进行进一步处理。

参与者遵循非常简单的原则:

  • 演员是具有行为的实体;
  • 演员响应传入的消息;
  • 收到消息后,演员可以:
    • 向其他参与者发送一些(最终)消息;
    • 创建(最终)一些新演员;
    • 定义用于处理后续消息的新行为。

在应用程序内部,参与者可以通过不同的方式实现:

  • 每个角色可以表示为一个单独的OS流(例如,在C :::::: Thread Pro Actor Edition库中发生这种情况);
  • 每个演员可以被表示为一堆协程;
  • 每个参与者可以表示为一个对象,有人在其中调用回调方法。

在我们的决定中,我们将使用具有回调对象的形式的参与者,并将协程留给CSP方法。

基于参与者模型的决策方案


根据参与者,解决问题的一般方案如下所示:

我们将拥有在HTTP服务器启动时创建并在HTTP服务器工作期间一直存在的参与者。 这些参与者包括:HttpSrv,UserChecker,ImageDownloader,ImageMixer。

收到新的传入HTTP请求后,我们将创建RequestHandler actor的新实例,该实例在发出对传入HTTP请求的响应后将被销毁。

RequestHandler Actor代码


协调传入的HTTP请求的处理的request_handler actor的实现可以如下所示:
class request_handler final : public some_basic_type { const execution_context context_; const request request_; optional<user_info> user_info_; optional<image_loaded> image_; void on_start(); void on_user_info(user_info info); void on_image_loaded(image_loaded image); void on_mixed_image(mixed_image image); void send_mix_images_request(); ... //     . }; void request_handler::on_start() { send(context_.user_checker(), check_user{request_.user_id(), self()}); send(context_.image_downloader(), download_image{request_.image_id(), self()}); } void request_handler::on_user_info(user_info info) { user_info_ = std::move(info); if(image_) send_mix_images_request(); } void request_handler::on_image_loaded(image_loaded image) { image_ = std::move(image); if(user_info_) send_mix_images_request(); } void request_handler::send_mix_images_request() { send(context_.image_mixer(), mix_images{user_info->watermark_image(), *image_, self()}); } void request_handler::on_mixed_image(mixed_image image) { send(context_.http_srv(), reply{..., std::move(image), ...}); } 

让我们分析一下这段代码。

我们有一个类,该类存储或将要存储处理请求所需的属性。 同样在该类中,有一组回调将在一次或另一次调用。

首先,在刚刚创建一个actor时,将调用on_start()回调。 在其中,我们向其他参与者发送了两条消息。 首先,这是一条check_user消息,用于验证客户端ID。 其次,这是一条download_image消息,用于下载原始图像。

在发送的每条消息中,我们都传递一个指向自己的链接(对self()方法的调用将返回指向为其调用self()的actor的链接)。 这是必需的,以便我们的演员可以发送消息作为响应。 例如,如果我们没有在check_user消息中发送链接到我们的actor,则UserChecker actor将不知道将用户信息发送给谁。

当将包含用户信息的user_info消息发送给我们作为响应时,将调用on_user_info()回调。 当image_loaded消息发送给我们时,on_image_loaded()回调将在我们的actor上调用。 现在,在这两个回调中,我们看到了行为者模型固有的功能:我们不确切知道接收响应消息的顺序。 因此,我们必须编写代码,使其不依赖于消息到达的顺序。 因此,在每个处理程序中,我们首先将接收到的信息存储在相应的属性中,然后检查是否已经收集了所需的所有信息? 如果是这样,那么我们可以继续。 如果没有,那么我们将进一步等待。

这就是为什么如果调用send_mix_images_request()时在on_user_info()和on_image_loaded()中具有ons的原因。

原则上,在行为者模型的实现中,可以有诸如从Erlang进行选择性接收或从Akka进行隐藏等机制,通过这些机制,您可以操纵传入消息的处理顺序,但是我们今天不讨论这一点,以免深入探讨模型的各种实现的细节演员们。

因此,如果从UserChecker和ImageDownloader获得了我们所需的所有信息,则将调用send_mix_images_request()方法,其中mix_images消息将发送到ImageMixer actor。 当我们收到包含结果图像的响应消息时,将调用on_mixed_image()回调。 在这里,我们将该图像发送给HttpSrv actor,然后等待HttpSrv形成HTTP响应并销毁不必要的RequestHandler(尽管从原理上讲,没有什么阻止RequestHandler actor在on_mixed_image()回调中自毁)。

仅此而已。

事实证明,RequestHandler actor的实现非常繁琐。 但这是由于以下事实:我们需要使用属性和回调来描述一个类,然后再实现回调。 但是RequestHandler的工作逻辑很琐碎,尽管request_handler类中的代码量很大,但理解它很容易。

演员固有的功能


现在我们可以谈谈演员模型的功能。

电抗器


通常,参与者仅响应传入的消息。 有消息-演员处理它们。 没有消息-演员什么也不做。

对于Actors模型的那些实现尤其如此,其中Actor表示为带有回调的对象。 该框架提取参与者的回调,如果参与者没有从回调中返回控制,则该框架无法在同一上下文中为其他参与者提供服务。

演员超载


在actor上,我们可以非常轻松地使actor-producer为消费者-actor生成消息,其速度比actor-consumer能够处理的速度快得多。

这将导致以下事实:参与者/消费者的传入消息队列将不断增长。 队列增长,即 应用程序中增加的内存消耗将降低应用程序的速度。 这将导致队列更快地增长,结果,应用程序可能降级以完全无法操作。

所有这些都是参与者之间异步交互的直接结果。 因为发送操作通常是非阻塞的。 而且要使其阻塞并不容易,因为 演员可以自言自语。 如果演员的队列已满,则在发给自己的演员上,演员将被阻止,这将停止他的工作。

因此,在与演员一起工作时,必须特别注意过载问题。

许多参与者并不总是解决方案。


通常,参与者是轻量级的实体,因此倾向于在​​其应用程序中大量创建它们。 您可以创建一万个演员,十万个,一百万个演员。 如果铁允许的话,甚至还有一亿演员。

但是问题在于,很难跟踪大量参与者的行为。 即 您可能有一些演员可以正常工作。 有些演员显然工作不正确或根本不工作,而且您肯定知道。 但是可能有很多演员对您一无所知:他们根本在工作,他们是正确还是不正确地工作。 所有这些都是因为当程序中有亿万个具有自己行为逻辑的自治实体时,对每个人进行监视都是非常困难的。

因此,事实证明,在应用程序中创建大量参与者时,我们没有解决所应用的问题,而是遇到了另一个问题。 因此,抛弃解决单个任务的简单角色对我们有利,而放弃执行多个任务的更复杂和沉重的角色,这可能对我们有益。 但是,这样应用程序中的此类“繁重”参与者将更少,我们可以更轻松地跟踪它们。

去哪里看,拿什么?


如果有人想尝试使用C ++与Actor合作,那么建造自己的自行车毫无意义,有几种现成的解决方案,尤其是:


这三个选项是生动的,不断发展的,跨平台的,已记录的。 您也可以免费试用。 另外,可以在Wikipedia上的列表中找到更多不同程度的[not]新鲜度选项。

SObjectizer和CAF旨在用于可以应用异常和动态内存的相当高级的任务。 QP / C ++框架可能会吸引那些参与嵌入式开发的人员,例如 在这个利基环境下,他被“囚禁”。

方法2:CSP(传达顺序流程)


手指上的CSP,无Matan


CSP模型与参与者模型非常相似。 我们还从一组自治实体构建解决方案,每个自治实体都有自己的私有状态,并且仅通过异步消息与其他实体进行交互。

CSP模型中只有这些实体称为“流程”。

CSP中的进程是轻量级的,内部没有任何并行工作。 如果我们需要并行化某些东西,那么我们只需要启动几个CSP进程,而在此内部就不再有并行化了。

CSP进程通过异步消息相互交互,但是消息不像Actor模型中那样发送到邮箱,而是发送到通道。 可以将通道视为消息队列,通常具有固定的大小。

与Actors模型不同,在Actor Model中,将为每个actor自动创建一个邮箱,而CSP中的通道必须显式创建。 而且,如果我们需要两个流程相互交互,那么我们必须自己创建频道,然后告诉第一个流程“您将在此处编写”,第二个流程应说:“您将从此处阅读”。

同时,通道具有至少两个必须显式调用的操作。 第一个是将消息写入通道的写入(发送)操作。

其次,是从通道读取消息的读取(接收)操作。 显式调用读/接收的需要将CSP与Actors模型区分开来,因为 对于参与者,通常可以从参与者隐藏读取/接收操作。 即 Actor框架可以从actor队列中检索消息,并为检索到的消息调用处理程序(回调)。

CSP流程本身必须选择读取/接收呼叫的时间,而CSP流程必须确定接收到的消息并处理提取的消息。

在我们的“大型”应用程序中,可以用不同的方式来实现CSP流程:

  • 可以将CSP-shny进程实现为单独的线程OS。 事实证明,这是一个昂贵的解决方案,但具有抢先式多任务处理能力。
  • CSP流程可以通过协程(堆叠的协程,纤维,绿线等)实现。 它便宜得多,但是多任务只是协作。

此外,我们假设CSP进程以堆栈协程的形式呈现(尽管下面显示的代码很可能在OS线程上实现)。

基于CSP的解决方案图


基于CSP模型的解决方案非常类似于Actors模型的方案(这绝非偶然):

还将有一些实体从HTTP服务器的启动处开始并一直工作-这些是CSP进程HttpSrv,UserChecker,ImageDownloader和ImageMixer。 对于每个新的传入请求,将创建一个新的RequestHandler CSP进程。 此过程发送和接收与使用Actors模型时相同的消息。

RequestHandler CSP流程代码


这可能看起来像实现RequestHandler的CSP害羞过程的函数的代码:
 void request_handler(const execution_context ctx, const request req) { auto user_info_ch = make_chain<user_info>(); auto image_loaded_ch = make_chain<image_loaded>(); ctx.user_checker_ch().write(check_user{req.user_id(), user_info_ch}); ctx.image_downloader_ch().write(download_image{req.image_id(), image_loaded_ch}); auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); auto image_mix_ch = make_chain<mixed_image>(); ctx.image_mixer_ch().write( mix_image{user.watermark_image(), std::move(original_image), image_mix_ch}); auto result_image = image_mix_ch.read(); ctx.http_srv_ch().write(reply{..., std::move(result_image), ...}); } 

这里的一切都很琐碎,并定期重复相同的模式:

  • 首先,我们创建一个接收响应消息的渠道。 这是必要的,因为 CSP进程没有自己的默认邮箱(如参与者)。 因此,如果CSP-shny进程想要接收某些内容,那么应该为创建将写入“某些内容”的通道而感到困惑。
  • 然后我们将消息发送给CSP主进程。 在此消息中,我们指示响应消息的通道;
  • 然后我们从应该发送响应消息的通道执行读取操作。

在与ImageSPixer CSP流程进行通信的示例中可以很清楚地看出这一点:
 auto image_mix_ch = make_chain<mixed_image>(); //  . ctx.image_mixer_ch().write( //  . mix_image{..., image_mix_ch}); //     . auto result_image = image_mix_ch.read(); //  . 

但值得分别关注此片段:
  auto user = user_info_ch.read(); auto original_image = image_loaded_ch.read(); 

在这里,我们看到了与演员模型的另一个严重差异。 对于CSP,我们可以按照适合自己的顺序接收响应消息。

是否要先等待user_info? 没问题,在出现user_info之前,请按读取睡眠。 如果此时已经将image_loaded发送给我们,那么它将在其频道中等待直到我们读取它。

实际上,这就是上面显示的代码所能提供的全部。 基于CSP的代码比其基于actor的代码更加紧凑。 这并不奇怪,因为 在这里,我们不必使用回调方法来描述一个单独的类。 而CSP敏感进程RequestHandler的部分状态以参数ctx和req的形式隐式存在。

CSP功能


CSP过程的反应性和前摄性


与参与者不同,CSP流程可以是反应性的,主动的或两者兼有。 假设CSP进程检查了它的传入消息;如果有,则对其进行处理。 然后,看到没有传入的消息,他承诺将矩阵相乘。

一段时间后,矩阵的CSP过程已厌倦了乘法运算,他再次检查了传入消息。 没有新的? 好吧,让我们进一步将矩阵相乘。

而且即使在没有传入消息的情况下,CSP流程也可以执行某些工作,这使得CSP模型与Actors模型有很大的不同。

本机过载保护机制


通常,由于通道是大小有限的消息队列,并且尝试将消息写入已填充的通道会阻止发送者,因此在CSP中,我们具有内置的过载保护机制。

确实,如果我们的生产者流程灵活而消费者流程缓慢,那么生产者流程将迅速填充通道,并且将被暂停以进行下一个发送操作。 生产者进程将休眠,直到消费者进程释放通道中的新消息空间。 该地点一出现,生产者流程就会醒来,并将新消息引发到频道中。

因此,使用CSP时,与Actor模型相比,我们不必担心过载问题。 没错,这里有一个陷阱,我们稍后再讨论。

CSP流程如何实施


我们必须决定如何实施CSP流程。

这样做可以使每个CSP-shny进程都由一个单独的OS线程表示。 事实证明,这是一种昂贵且不可扩展的解决方案。 但是另一方面,我们获得了抢占式多任务处理能力:如果我们的CSP进程开始将矩阵相乘或进行某种阻塞调用,那么OS最终会将其推出计算核心,并有可能使其他CSP进程正常工作。

可以使每个CSP流程用协程(堆栈协程)表示。 这是一个便宜得多且可扩展的解决方案。 但是在这里,我们将只有协作式多任务处理。 因此,如果突然CSP进程占用了矩阵乘法,则与此CSP进程以及与其相连的其他CSP进程的工作线程将被阻塞。

可能还有另一把戏。 假设我们使用第三方库,在该库中我们无法影响。 在库中,使用了TLS变量(即thread-local-storage)。 我们对库函数进行了一次调用,并且库设置了一些TLS变量的值。 然后我们的协程“移动”到另一个工作线程,这是可能的,因为 原则上,协程可以从一个工作线程迁移到另一个工作线程。 我们对库函数进行以下调用,并且库尝试读取TLS变量的值。 但是可能已经有了不同的含义! 寻找这样的错误将非常困难。

因此,您需要仔细考虑选择用于实现CSP-shnyh进程的方法。 每个选项都有其优点和缺点。

许多过程并不总是解决方案。


与参与者一样,在程序中创建许多CSP流程的能力并不总是解决所应用问题的方法,而是为您自己创建其他问题的方法。

此外,对程序内部发生的情况的不良可见性只是问题的一部分。 我想关注另一个陷阱。

事实是,在CSP-shnyh通道上,您可以轻松获得死锁的类似物。 进程A尝试将消息写入完整的通道C1,并且进程A暂停。 从通道C1应该读取试图写入已满的通道C2的进程B,因此进程B被挂起。 然后从通道C2读取进程A,仅此而已,我们陷入了僵局。

如果我们只有两个CSP进程,那么我们可以在调试甚至代码审查过程中发现这种死锁。 但是,如果程序中有数百万个进程,它们之间会相互主动通信,那么此类死锁的可能性就会大大增加。

去哪里看,拿什么?


如果有人想在C ++中使用CSP,那么不幸的是,这里的选择并不像参与者那么大。 好吧,或者我不知道在哪里看,怎么看。 在这种情况下,我希望评论将共享其他链接。

但是,如果要使用CSP,首先需要考虑Boost.Fiber 。 有光纤(例如协程)和通道,甚至还有互斥,condition_variable,barrier等低级基元。 所有这些都可以采用。

如果您对线程形式的CSP流程感到满意,则可以查看SObjectizer 。 还有CSP通道的类似物,并且可以在没有任何参与者的情况下编写SObjectizer上的复杂多线程应用程序。

演员vs CSP


参与者和CSP彼此非常相似。 我再三遇到过这样的说法:这两个模型彼此等效。 即 在CSP流程中,可以对参与者执行的操作几乎可以1对1重复进行,反之亦然。 他们说,它甚至在数学上得到了证明。 但是在这里我什么都不懂,所以我什么也不能说。 但是从我日常日常常识水平上的想法来看,所有这些看起来都很合理。 实际上,在某些情况下,参与者可以由CSP流程代替,而CSP流程可以由参与者替换。

但是,参与者和CSP之间存在一些差异,可以帮助确定这些模型在哪些方面是有利还是不利的。

频道与信箱


演员有一个用于接收传入消息的“通道”-这是他的邮箱,为每个演员自动创建。 然后,actor从那里依次准确地按照邮件在邮箱中的顺序检索邮件。

这是一个非常严重的问题。 假设演员邮箱中有三则消息:M1,M2和M3。 演员目前仅对M3感兴趣。但是在进入M3之前,演员将首先提取M1,然后提取M2。他将如何对待他们?

同样,在此对话中,我们将不涉及来自Erlang的选择性接收机制和来自Akka的隐藏。

而CSP-shny进程可以选择当前要从中读取消息的通道。因此,CSP进程可以具有三个通道:C1,C2和C3。当前,CSP进程仅对来自C3的消息感兴趣。进程读取的就是该通道。如果对此感兴趣,他将返回通道C1和C2的内容。

反应性和积极性


通常,参与者是反应性的,只有在收到传入消息时才起作用。

CSP流程即使在没有传入消息的情况下也可以完成某些工作。在某些情况下,这种差异可能起重要作用。

状态机


实际上,参与者是有限状态机(KA)。因此,如果您的主题区域中有许多有限状态机,即使它们是复杂的,分层的有限状态机,那么与在CSP流程中添加航天器实现相比,基于角色模型来实现它们也要容易得多。

在C ++中,尚无本机CSP支持。


Go语言的经验表明,在编程语言和其标准库的级别上实现CSP模型的支持时,使用CSP模型是多么容易和方便。

在Go中,可以轻松创建“ CSP流程”(又称goroutine),也可以轻松创建和使用渠道,并且内置语法可一次处理多个渠道(Go-select不仅可用于阅读,而且可用于写作)标准库了解goroutins,并且当goroutin发出来自stdlib的阻止调用时可以切换它们。

到目前为止,在C ++中,不支持堆栈协程(在语言级别)。因此,在某些地方使用C ++使用CSP看起来可能不是拐杖,但是……与同一个Go语言相比,这当然需要更多的注意力。

方法3:任务(异步,将来,wait_all等)


关于最常见的基于任务的方法


基于任务的方法的含义是,如果我们有一个复杂的操作,那么我们会将这个操作划分为单独的任务步骤,其中每个任务(这是一个任务)执行一个单独的子操作。

我们以特殊的异步操作开始这些任务。异步操作返回一个将来的对象,任务完成后,将放置任务返回的值。

在启动N个任务并接收到N个对象以后,我们需要以某种方式将所有这些编织在一起。看来当第1号和第2号任务完成时,它们返回的值应该属于第3号任务。当第3号任务完成时,返回的值应传送到第4号,第5号和第6号任务。等等

对于这种“领带”,使用特殊的手段。例如,未来对象的.then()方法,以及函数wait_all(),wait_any()。

这样的“手指上的”解释可能不是很清楚,所以让我们继续进行代码。也许在关于特定代码的对话中,情况会变得更加清楚(但事实并非如此)。

基于任务的方法的Request_handler代码


用于根据任务处理传入的HTTP请求的代码如下所示:
 void handle_request(const execution_context & ctx, request req) { auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); when_all(user_info_ft, original_image_ft).then( [&ctx, req](tuple<future<user_info>, future<image_loaded>> data) { async(ctx.image_mixer_ctx(), [&ctx, req, d=std::move(data)] { return mix_image(get<0>(d).get().watermark_image(), get<1>(d).get()); }) .then([req](future<mixed_image> mixed) { async(ctx.http_srv_ctx(), [req, im=std::move(mixed)] { make_reply(...); }); }); }); } 

让我们尝试弄清楚这里发生了什么。

首先,我们创建一个任务,该任务应在我们自己的HTTP客户端的上下文中启动,并请求有关用户的信息。返回的将来对象存储在user_info_ft变量中。

接下来,我们创建一个类似的任务,该任务也应在我们自己的HTTP客户端的上下文中运行,并加载原始图像。返回的将来对象存储在original_image_ft变量中。

接下来,我们需要等待前两个任务完成。我们直接写下的内容是:when_all(user_info_ft,original_image_ft)。当两个将来的对象都获得其值时,我们将运行另一个任务。该任务将获取带有水印和原始图像的位图,并在ImageMixer的上下文中运行另一个任务。该任务将混合图像,完成后,将在HTTP服务器上下文中启动另一个任务,这将生成HTTP响应。

也许对代码中正在发生的事情的这种解释还不够清楚。因此,让我们为任务编号:

让我们看一下它们之间的依赖关系(任务的顺序从这些依赖关系开始):

而且,如果我们现在将此图像覆盖在我们的源代码上,那么我希望它变得更加清晰:


基于任务的方法的功能


能见度


应该已经很明显的第一个功能是Task上的代码的可见性。并不是她的一切都很好。

在这里,您可以提及回调地狱。Node.js程序员对此非常熟悉。但是与Task紧密配合的C ++昵称也陷入了这种回调地狱。

错误处理


另一个有趣的功能是错误处理。

一方面,在使用异步和future并将错误信息传递给相关方的情况下,与参与者或CSP相比,它甚至更加容易。毕竟,如果在CSP中,进程A向进程B发送了一个请求并等待响应消息,那么当B在执行请求时遇到错误时,我们需要决定如何将错误传递给进程A:

  • 或者我们将制作单独的消息类型和接收消息的渠道;
  • 或者我们只返回一条消息返回结果,这将是标准错误结果的std ::变体。

在未来的情况下,一切都变得更加简单:我们从未来中提取正常结果或抛出异常。

但是,另一方面,我们很容易遇到一系列错误。例如,任务1中发生了异常,该异常落入了将来的对象中,该对象已传递给任务2。在第2个任务中,我们尝试从未来中获取价值,但遇到了一个例外。而且,很可能我们将抛出相同的异常。因此,它将落在下一个未来,它将去做任务3。还有一个例外,很有可能也会被释放。等等

如果记录了我们的异常,则在日志中我们可以看到相同异常的重复重复,该异常从链中的一个任务变为另一个任务。

取消任务和计时器/超时


基于任务的活动的另一个非常有趣的功能是,如果出现问题,则取消任务。实际上,假设我们创建了150个任务,完成了前10个任务,并且意识到继续工作毫无意义。我们如何取消剩余的140个?这是一个非常非常好的问题:)

另一个类似的问题是如何通过计时器和超时使朋友任务。假设我们正在访问某个外部系统,并且希望将等待时间限制为50毫秒。我们如何设置计时器,如何对超时时间做出反应,如果超时时间过长,如何中断任务链?同样,问比回答容易:)

作弊


好了,然后谈谈基于任务的方法的功能。在所示的示例中,应用了一些作弊:
  auto user_info_ft = async(ctx.http_client_ctx(), [req] { return retrieve_user_info(req.user_id()); }); auto original_image_ft = async(ctx.http_client_ctx(), [req] { return download_image(req.image_id()); }); 

在这里,我向我们自己的HTTP服务器的上下文发送了两个任务,每个任务都在内部执行阻止操作。实际上,为了能够并行处理对第三方服务的两个请求,在这里您必须创建自己的异步任务链。但是,我这样做并不是为了使解决方案或多或少可见并适合演示幻灯片。

演员/ CSP与任务


我们研究了三种方法,发现如果参与者和CSP流程彼此相似,那么基于任务的方法就不会像其中任何一种。似乎应该将Actor / CSP与Task进行对比。

但就我个人而言,我喜欢另一种观点。

当我们谈论行为者模型和CSP时,我们正在谈论任务的分解。在我们的任务中,我们挑选出单独的独立实体,并描述这些实体的接口:它们发送哪些消息,它们接收哪些消息,消息通过哪些渠道。

与参与者和CSP合作,我们正在谈论接口。

但是,假设我们将任务分为单独的参与者和CSP流程。他们到底如何工作?

当我们采用基于任务的方法时,我们开始谈论实现。关于如何执行特定工作,执行哪些子操作,以什么顺序进行操作,如何根据数据连接这些子操作等。

与Task合作,我们正在谈论实施。

因此,Actor / CSP与Tasks并没有那么多地相互对立,而是相互补充。Actor / CSP可用于分解任务并定义组件之间的接口。然后可以使用Tasks来实现特定的组件。

例如,当使用Actor时,我们具有诸如ImageMixer的实体,需要使用线程池上的图像对其进行操作。通常,没有什么可以阻止我们使用ImageMixer actor来使用基于任务的方法。

去哪里看,拿什么?


如果您想使用C ++的Tasks,可以查看即将发布的C ++ 20的标准库。他们已经在将来增加了.then()方法,以及自由函数wait_all()和wait_any。有关详细信息,请参见cppreference

离新的异步++库也已经很远了。从原则上讲,其中有您需要的一切,而只是一点点不同的调味料。

而且还有一个更老的Microsoft PPL。这也可以满足您的所有需求,但可以自己调味。

关于英特尔TBB库的其他补充。在基于任务的方法的故事中没有提到它,因为我认为,TBB的任务图已经是一种数据流方法。而且,如果这份报告继续下去,那么有关英特尔TBB的讨论肯定会到来,只是在有关数据流的故事的背景下。

更有趣


最近在这里,在哈布雷(Habré)上,安东·波卢欣(Anton Polukhin)发表了一篇文章:“我们正在使用一个真实的示例为C ++ 20做准备

它讨论将基于任务的方法与C ++ 20中的无堆栈协程相结合。结果表明,基于任务可读性的代码接近了CSP流程中代码的可读性。

因此,如果有人对基于任务的方法感兴趣,那么阅读本文是有意义的。

结论


好吧,是时候继续研究结果了,因为结果并不多。

我要说的主要事情是,在现代世界中,仅当您正在开发某种框架或解决某些特定的低级任务时,才可能需要裸露的多线程。

而且,如果您正在编写应用程序代码,则几乎不需要裸线程,低级同步原语或某种无锁算法以及无锁容器。长期以来,有些方法经过了时间的考验,并且已经证明自己很不错:

  • 演员
  • 沟通顺序流程(CSP)
  • 任务(异步,承诺,期货等)
  • 数据流
  • 反应式编程
  • ...

最重要的是,在C ++中有针对它们的现成工具。您不需要循环任何东西,可以尝试一下,并尝试将其投入运行。

如此简单:采取,尝试并投入运行。

Source: https://habr.com/ru/post/zh-CN430672/


All Articles