std::packaged_task 源码级详解

十二月 27, 2023 [C/C++] #C++ #obsidian

std::packaged_task 源码级详解

packaged_task,故名思义打包任务,是用来进行任务封装的,即将任务封装起来,将任务封装起来的目的是为了进行线程之间的传递,进行任务的异步处理

下面我们先看一下CPP Reference网站,看看对packaged_task对此的描述

(迁移注:原图片缺失 assets/v2-3140f0b3a127615e30edb8fe5d23d26d_b-20231227143335-f40ckln.jpg,已移除)

std::packaged_task类模版可以用来包装任何可调用目标(函数、labmbda表达式、bind表达式或者其他函数对象),其可以被异步调用。其调用完成之后返回执行结果的值或者执行过程中抛出的异常,这个值或者异常保存在一个共享状态中,可以通过其关联的std::future对象来进行获取

像std::function一样,std::packaged_task是一个多态的,能够感知内存分配的容器:这个被std::packaged_task存储的可调用目标可以被分配到堆上,也可以通过提供的内存分配器进行来定义存储方式

std::future,顾名思义是一个用来获取未来的值的对象,通过该对象就可以获取异步调用在未来执行完成之后返回的结果信息。如果之前没有了解,建议了解一下,因为packaged_task的实现,使用到了promise和future这两个模版类,下面的讨论需要这两个模版类的知识并且限于篇幅,不会对这两个模版类进行展开讨论,具体可以参考如下文章

(迁移注:原图片缺失 assets/v2-28849aa5bc7f67f5fae600809ccfd208_180x120-20231227143335-jx982yo.jpg,已移除)孙梓轩:std::future和std::promise详解(原理、应用、源码)11 赞同 · 0 评论文章

下面,我们通过一个示例,来看一下packaged_task的使用和作用

示例

我们将通过lambda和function来构建两个被packaged_task可调用对象,并且将这两个可调用对象交给两个线程来进行执行。并在主线程中通过std::future来进行执行结果的获取,代码如下

(迁移注:原图片缺失 assets/v2-69543c4b3d2efe282105c6cd4a45cb33_b-20231227143335-wbdtqoc.jpg,已移除)

终端输出如下图所示

 15
 test exception

下面我们以上面代码行13为例,来具体看一下,package_task的构建。为了方便阅读,将上面的代码行13重新贴出如下

 std::packaged_task<int(const std::vector<int>&)> lambda_pask_task(sum_labmda);

首先,类模版packaged_task需要传入一个模版参数,这里传入的是int(const std::vector<int>&),该参数是用来告诉packaged_task其接受的可调用对象对应的函数调用签名是什么,这里第1个int是用来代表可调用对象返回值类型是int,其接受一个const std::vector<int>&类型的实参。然后,将这个可调用对象通过构造函数的形参传入

下面,我们从源码的角度讨论一下,packaged_task对应的源码实现

源码实现分析

下面,我们基于clang平台中关于packaged_task的代码实现。下面的源码分析仅仅讨论上面用例相关的代码,其他代码如果有感兴趣的可以到网络上自行下载源码查看

数据成员

首先,我们先看一下packaged_task的内部数据成员的定义,具体如下截图

(迁移注:原图片缺失 assets/v2-a54ef5ea08e20753ed083a9da7e9ff8b_b-20231227143335-t9udyp6.jpg,已移除)

从上面代码可知,其内部有两个数据成员在代码行8~9定义,具体说明如下:

通过上面的讨论,目前有几个关键对象:thread线程对象、future对象、packaged_task对象、promise对象以及共享状态对象(关联状态对象),下面通过一个图示,来讨论一下这些对象之间的关系

(迁移注:原图片缺失 assets/v2-372c6e2d46c4fb36d9cb1a243b05ae01_b-20231227143335-69g4ixb.jpg,已移除)

如上图所示,主线程中持有的future对象和用于执行异步任务的线程中持有的promise对象,都关联了一个相同的共享状态对象。future对象和promise对象,通过其共同关联的共享状态来进行异步调用结果同步

构造函数

下面,我们看一下packaged_task的构造相关的源码,如下图所示

(迁移注:原图片缺失 assets/v2-4427adaffe3be4e6136b72c29a86a19e_b-20231227143335-ypv5u9i.jpg,已移除)

该函数接受一个_Fp类型的一个万能引用的参数,并且在函数的内部通过forward函数对改万能引用参数进行完美转发,对内部成员_f进行赋值。这里_Fp类型是一个模版参数,是一个泛型,使得类模版packaged_task包装不同类型的可调用对象,比如前边示例中提到的lambda和function

代码行2~8:用来限制传入传入的实参对应的类型_Fp的。首先在代码行4~7,通过is_same检测通过对_Fp类型移除const、voliate以及reference信息之后得到的最基本的类型和packaged_task类型进行比较,如果是相同的类型则is_same模版实例中的value值为true,否则为false

当_Fp对应的基础类型不是packaged_task类型的时候,此时enable_if接受到的nontype模版参数的值就是true,此时内部有type类型定义,此时可以匹配到该构造函数

当_Fp对应的基础类型不是packaged_task类型的时候,此时enable_if接受到的nontype模版参数的值就是false,此时内部没有type,此时匹配该构造函数失败

即通过代码行2~8来限制了传入参数的类型,不能接受一个packaged_task可调用类型,即不能通过其他packaged_task类型来构造自身

可移动,不可复制

之所以要限制不能从其他的packaged_task来构造(以复制的方式)自身的原因是因为,packaged_task对象往往都关联一个共享状态对象,这里存在着一个链式的关联关系,即packaged_task -> 共享状态对象 -> future。而通过其他的packaged_task对象构建新的packaged_task对象,将会打破这个关联关系。从而影响数据的传递和线程之间的同步

实际上,可以通过移动的方式从其他的packaged_task对象来构造自身,因为移动的方式不会破坏上面提到的那个链式的关联关系。对应的源代码如下

(迁移注:原图片缺失 assets/v2-b0b1f4866274bbc258bb3a55763df27b_b-20231227143335-niel9xw.jpg,已移除)

函数调用操作符

当一个可调用对象包装成了一个packaged_task对象之后,该对象将交给thread线程对象来进行执行。thread对象在通过packaged_task对象调用其内部保存的可调用对象的时候,就是通过调用packaged_task的函数调用操作符号来实现的

本质上,packaged_task对象也是一个可调用对象,thread对象接受并执行的就是一个可调用对象

下面,我们看一下代码实现,如下图所示

(迁移注:原图片缺失 assets/v2-373226d0bfbc101eec1d9f8793119006_b-20231227143335-jys2953.jpg,已移除)

get_future函数

通过调用packaged_task的成员函数get_future,来获取用来查询异步调用结果信息的future对象,源码如下所示

(迁移注:原图片缺失 assets/v2-2e3000666651d506fcbf7853021dd7ec_b-20231227143335-h8v4se2.jpg,已移除)

通过调用内部的promise对象_p的成员函数,来获取一个和该promise对象关联同一个共享状态对象的future对象并返回

总体流程

最后,经过上面的讨论我们已经讨论了完整的packaged_task的工作流程,下面我们总体的梳理一下

  1. 在主线程中,通过packaged_task类模版对可调用对象进行封装,封装成一个packaged_task对象
  2. 通过调用packaged_task可调用对象的get_future函数,获取对应的future对象,用来之后获取异步调用的结果信息
  3. 将该packaged_task对象交给其他线程执行
  4. 其他线程执行该packaged_task可调用对象
  5. 在packaged_task可调用对象执行过程中,对内部存储的异步操作对应的可操作对象进行函数调用。调用完成之后,将调用结果信息设置到共享状态对象中
  6. 主线程中通过调用future对象的get成员函数,从其关联的共享状态对象中获取异步调用的结果信息。如果此时异步调用已经执行完成,则该函数立刻返回异步调用结果信息。如果没有执行完成,此时将阻塞,直到异步调用执行完成