std::packaged_task 源码级详解
十二月 27, 2023 [C/C++] #C++ #obsidianstd::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,已移除)
- 代码行
1~5:创建了一个内部抛出异常的函数,用来测试packaged_task关于异常的传递 - 代码行
10~15:创建了一个用于对容器内元素进行求值的labmda表达式对象,然后将这个对象包装成packaged_task对象,最后将这个对象交给std::thread类型的线程对象来执行,该线程对象创建完成之后,就会在内部创建一个线程,然后启动这个线程开始执行传入的packaged_task对象。在代码行14通过调用packaged_task对象的get_future成员函数,来获取用于访问异步调用结果信息的future对象 - 代码行
17~19:将代码行1~5中定义的内部抛出异常的函数,包装成packaged_task对象,然后将这个对象交给一个std::thread对象来进行异步调用执行(在内部创建的新的线程中进行执行)。在代码行14通过调用packaged_task对象的get_future成员函数,来获取用于访问异步调用结果信息的future对象 - 代码行
21:通过调用future对象的内部成员函数get来获取上面通过lambda对象创建的packaged_task对象异步执行的结果,即函数的返回值 - 代码行
22~29:同样,通过调用future对象内部的成员函数get来获取上面那个通过函数创建的packaged_task对象异步执行的结果信息,由于函数内部抛出了异常,这个异常信息会存储在future对象关联的对象中,当代码行24行调用get方法的时候,该异常会在当前线程被重新抛出,所以这里用try-catch对这个异常信息进行捕获,并且在代码行28对该异常信息进行打印 - 代码行
32~33:调用进行异步调用执行的两个线程的join成员函数,对这两个线程进行join,此时主线程(main函数执行的线程)会等待这两个线程退出之后,再接着向下运行
终端输出如下图所示
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定义,具体说明如下:
- 代码行
8:定义一个用来保存构建时外部传入的可调用对象的内部成员变量_f - 代码行
9:定义一个promise对象,该对象用来设置异步调用结果信息(异步调用的返回值或者异步调用过程中抛出的异常)到其关联的共享状态对象(关联状态对象)中,以供其他线程通过future::get来从其和该promise对象共同关联的共享状态对象中获取异步调用结果信息
通过上面的讨论,目前有几个关键对象: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,已移除)
- 代码行
2~3:删除了拷贝构造函数和复制赋值函数,从而避免packaged_task的复制 - 代码行
7~15:定义了移动构造函数和移动赋值函数,支持package_task的移动
函数调用操作符
当一个可调用对象包装成了一个packaged_task对象之后,该对象将交给thread线程对象来进行执行。thread对象在通过packaged_task对象调用其内部保存的可调用对象的时候,就是通过调用packaged_task的函数调用操作符号来实现的
本质上,packaged_task对象也是一个可调用对象,thread对象接受并执行的就是一个可调用对象
下面,我们看一下代码实现,如下图所示
(迁移注:原图片缺失
assets/v2-373226d0bfbc101eec1d9f8793119006_b-20231227143335-jys2953.jpg,已移除)
- 代码行
5~6:判断内部promise对象_p是否关联共享状态,如果未关联共享状态,即_p_.__state指针为空,则抛出异常。在这正常构建packaged_task对象的时候,是不会出现_p_.__state为空的情况,因为promise在默认构造函数中,会创建共享状态对象,并将该共享状态对象的地址赋值给内部的_state指针。只有在移动package_task对象的时候,才会出现这种情况 - 代码行
7~8:判断内部的promise对象是否已经被设置值,如果已经被设置值,则抛出异常。这种情况通常会出现在一个packaged_task对象被多次执行的时候 - 代码行
10~19:在代码行13中,对_f进行函数调用操作,执行异步调用任务。当函数调用完成的时候,将返回值设置到_p共享状态对象中。如果在可调用对象执行过程中出现了异常抛出的情况是,通过try-catch对该异常进行捕获,并且将该异常信息设置到_p共享状态对象中
get_future函数
通过调用packaged_task的成员函数get_future,来获取用来查询异步调用结果信息的future对象,源码如下所示
(迁移注:原图片缺失
assets/v2-2e3000666651d506fcbf7853021dd7ec_b-20231227143335-h8v4se2.jpg,已移除)
通过调用内部的promise对象_p的成员函数,来获取一个和该promise对象关联同一个共享状态对象的future对象并返回
总体流程
最后,经过上面的讨论我们已经讨论了完整的packaged_task的工作流程,下面我们总体的梳理一下
- 在主线程中,通过
packaged_task类模版对可调用对象进行封装,封装成一个packaged_task对象 - 通过调用
packaged_task可调用对象的get_future函数,获取对应的future对象,用来之后获取异步调用的结果信息 - 将该
packaged_task对象交给其他线程执行 - 其他线程执行该
packaged_task可调用对象 - 在
packaged_task可调用对象执行过程中,对内部存储的异步操作对应的可操作对象进行函数调用。调用完成之后,将调用结果信息设置到共享状态对象中 - 主线程中通过调用
future对象的get成员函数,从其关联的共享状态对象中获取异步调用的结果信息。如果此时异步调用已经执行完成,则该函数立刻返回异步调用结果信息。如果没有执行完成,此时将阻塞,直到异步调用执行完成