前面也提到过,libunifex的scheduler实现离实用级其实还有一些差距。对比asio相关的实现,处理细节和完备度上都有较大落差,基于总览篇提到的整体实践思路,我们将更多使用asio的scheduler来作为execution的底层调度器。所以从本篇开始,我们将详细介绍asio相关的实现,本篇主要介绍asio传统的lambda post调度器。
一、asio对通用任务的支持
大部分时候我们使用asio更多的是将它用作一个网络库,但实际上asio本身对通用任务的支持做得也是非常棒的。利用c++11引入的lambda和函数对象,我们的通用任务可以很好的包装成lambda之后post()到某个io_context上,然后在io_context::run()的时候执行。执行流程如下图所示:
特定情况下,任务与任务之间存在依赖关系,这点上asio本身提供的strand 的支持,利用strand,我们可以在业务层尽可能少的使用锁等同步原语的情况下,对一个流水线式的组合任务进行编码,如图所示:
这种多part的流水线式的任务,是很适合使用strand进行封装,得到预期的结果的。使用asio作为通用的并发框架,肯定是一种可行的方式,实际上网易不少项目就是这么做的,最早是他们的服务器使用asio作为底层的并发框架,后来国内知名度较高的messiah引擎,也借鉴并发扬了这种方式,使用asio作为底层基础的并发框架。本文我们也是更多的从源码着手,集中在asio scheduler这部分的实现代码上,来深入了解它的实现和特点。
二、asio版本现状介绍
从1.17(2020)开始尝试向当时的execution提案靠拢,当时的execution也就是那版一开始就Api数量爆炸,后面引入property对api复杂度进行所谓“简化”,跟大部分想的相去甚远的那版,现在的execution提案抛弃了原先的这套规划,但asio当时努力的方向还是原版,所以代码中大量存在了property相关的设施和使用,这里简单列出一些相关的示例代码:
asio::static_thread_pool pool(1);
auto ex1 = ctx.get_executor();
// Get the number of available threads in the pool.
std::size_t n = asio::query(ex1, asio::execution::occupancy);
// Require an executor with blocking.never property.
auto ex2 = asio::require(ex1, asio::execution::blocking.never);
asio::execution::execute(ex2, []{ /*...*/ });
// Prefer an executor that uses a custom allocator.
auto ex3 = asio::prefer(ex2, asio::execution::allocator(my_allocator));
asio::execution::execute(ex3, []{ /*...*/ });
如上面的代码所示,property主要通过三个模板函数来工作:
-
query(): 查询某属性的值。
-
require(): 获取满足对应属性的对象。
-
prefer(): 获取包含定制内容的对象。
对于系统本身特别复杂,需要适应的场景特别多的情况,这种设计本身确实会简化部分业务侧的使用理解复杂度,原来对多种不同Api的记忆,变成了property的选择。
但其实对于库本身的实现来说,我们也容易看到,利用property对多种并发泛式进行约束的方式,本身就具备一定的复杂度,实际上并不那么成熟。对于库的构建来说,很难说它提供的是一个简单易扩展的机制。这个其实tag_invoke机制本身也有跟property相关的对比,个人认为,同样是对库的定制和对泛型的支持的目的,基于cpo的tag invoke本身应该是更值得选择的,而property本身我感觉就比cpo的理解成本要高,用于构建库代码,也会导致库代码本身的复杂度变高,在它没有成为C++标准的一部分之前,这种复杂度的引入肯定是不那么合适的。
这种复杂度的增加我们从当前asio 1.22代码仓库可以比较容易看出,主体功能变化不大(对比1.16版本),但引入了相当多的代码用于在兼容低版本c++的情况下对property等基础功能进行支持,导致整体代码复杂度剧增, 但实际带来的便利性基本看不到。如果抛开对新特性的实验本身,这些调整对asio的版本迭代来说,绝对跟优雅本身相去甚远。
对比向早期execution的靠拢,asio对c++20 coroutine的支持还是可圈可点的,这个从作者近期的实例代码讲解中也能感受到,像awaitable的“||” “&&”等支持,很好的扩展了协程中多任务处理的语义,更容易用更少的代码实现出简单易理解,易维护的异步代码。
回到scheduler本身,我们本篇的重点是asio的scheduler部分实现,这部分在asio加入property机制前后其实变化不大,但由于加入property后,相关的scheduler部分耦合了大量的property相关的机制和代码,带来了比较高的复杂度,本文我们直接选择不包含property的asio1.16的代码进行展开,方便以更低的复杂度分析相关的实现。
三、一些额外的网络知识
首先asio原本的设计是针对网络任务为主的,区别于主流的Reactor模型,asio本身的设计和架构使用了Proactor模型。
这张图可以说完全就是IOCP的一个工作情况了,新出的io_uring,概念上与此都略有出入,目前看到的最新版的1.22的实现中,io_uring的实现本身依然还是使用了跨平台的scheduler,并没有像iocp一样,利用操作系统本身提供的API完成整个scheduler的实现。
此处我们需要注意的是,真正比较完整的实现了高效的操作系统级的AysncIO,并被大家接受使用的,也就只有Windows平台的IOCP,当然,这种情况最近几年得到了改善,linux平台的新秀io_uring,也被越来越多的人关注和使用起来,不过此处我们选的是1.16的版本,并未包含io_uring的实现,我们先暂时不考虑它的存在。操作系统级的async io实现制约了asio本身Proactor模型的跨平台实现,相关的异步任务调度,也自然的分裂成了两套实现:
-
对于windows来说,因为IOCP的存在,asio的Proactor模型本身可以完全使用IOCP本身来实现,而对于其他平台,asio就只能选用妥协的方式,使用Reactor+外围Scheduler的模式,来模拟Proactor模型,最终实现一个业务层与IOCP使用体验完全一致的跨平台的Proactor模型。
-
对于我们当前的项目来说,因为优先选择的是跨平台的一致性和维护的简洁性,所以我们当前阶段,主要使用的是第2种方法中的scheduler,这也是本文分析的重点。而且Reactor本身的实现也跟scheduler的工作是解耦的,所以我们分析中可以直接略过reactor部分,只关注scheduler整体机制的实现了。
因为本篇我们主要关注asio的调度器设计部分,本章网络模型相关的只是简单给出相关的概念,了解它背后的实现思路,方便大家更好的理解它整个调度器的设计和实现思路。
【文章福利】另外小编还整理了一些C/C++后台开发教学视频,相关面试题,后台学习路线图免费分享,需要的可以自行添加:Q群:720209036 点击加入~ 群文件共享
小编强力推荐C++后台开发免费学习地址:C/C++Linux服务器开发高级架构师/C++后台开发架构师
四、operation的实现
作为一个比较函数式的调度器实现,首先要打理的,肯定是相关的函数对象如何投递,如何保存,如何执行了。我们先来看看这一切的基础,operation的实现。
asio的arbitrary task的投递是通过post来完成的,我们也会以此作为起点,来分析一个函数对象,是如何被asio进行处理最终存储起来的。
(一)函数对象的投递-post()过程
我们先以一个代码片断的执行过程来看一下整个post()的过程:
asio::io_context ctx{};
auto wg = asio::make_work_guard(ctx);
std::thread tmp_thread([&ctx] { ctx.run(); });
std::allocator<void> alloc;
ctx.get_executor().post([] {
std::cout << "task run!" << std::endl;
}, alloc);
std::this_thread::sleep_for(1s);
上面的代码片断中,我们简单构建了一个io_context的执行环境,并向其post()了一个简单的lambda到其上执行。我们以此为基础,来分析一下具体的post()过程,主要包含以下步骤:
-
io_context->scheduler的传递过程
template <typename Function, typename Allocator>
void io_context::executor_type::post(Function&& f, const Allocator& a) const
{
typedef typename std::decay<Function>::type function_type;
// Allocate and construct an operation to wrap the function.
typedef detail::executor_op<function_type, Allocator, detail::operation> op;
typename op::ptr p = { detail::addressof(a), op::ptr::allocate(a), 0 };
p.p = new (p.v) op(static_cast<Function&&>(f), a);
ASIO_HANDLER_CREATION((this->context(), *p.p,
"io_context", &this->context(), 0, "post"));
io_context_.impl_.post_immediate_completion(p.p, false);
p.v = p.p = 0;
}
中间的ASIO_HANDLER_CREATION()宏是用于辅助handler调试的,对代码的实际执行没有任何影响,我们直接忽略。
op::ptr-operation的内存分配与释放:这部分代码比较晦涩的是op::ptr相关的使用,ptr本身其实是asio通过宏生成的一个用于定制allocator的辅助结构体,我们直接展开宏来看下它的定义:
struct ptr
{
const Alloc* a;
void* v;
op* p;
~ptr()
{
reset();
}
static op* allocate(const Alloc& a)
{
typedef typename ::asio::detail::get_recycling_allocator<
Alloc, purpose>::type recycling_allocator_type;
ASIO_REBIND_ALLOC(recycling_allocator_type, op) a1(
::asio::detail::get_recycling_allocator<
Alloc, purpose>::get(a));
return a1.allocate(1);
}
void reset()
{
if (p)
{
p->~op();
p = 0;
}
if (v)
{
typedef typename ::asio::detail::get_recycling_allocator<
Alloc, purpose>::type recycling_allocator_type;
ASIO_REBIND_ALLOC(recycling_allocator_type, op) a1(
::asio::detail::get_recycling_allocator<
Alloc, purpose>::get(*a));
a1.deallocate(static_cast<op*>(v), 1);
v = 0;
}
}
}
这个地方的recycling_allocator我们就不具体展开了,主要的作用是asio自己写了一个recycling_allocator,如果外面传入的分配器是std::allocate<>,则自动将分配器替换为asio内部实现的recycling_allocator。ASIO_REBIND_ALLOC是用于编译期判断分配器是否包含rebind_alloc<T>的类型,如果有,则使用这个作为分配器,否则还是直接使用传入的分配器,感兴趣的可以自行了解:
-
std::allocator_traits
-
以及它的member alias templates : rebind_alloc<T>
所以对于op::ptr来说,它实现了特定对象(这里就是我们的executor_op)的内存申请,以及reset()时对特定对象调用析构函数并进行内存释放操作。
库作者都比较喜欢写内存分配器,但一般位于业务层之下的库,特性需求都容易接近通用分配器,并没有太多“银弹”可供库作者摘取,正常来说,通用型的内存分配器,简单实现,也是几千行的代码量了,不是在明确业务使用场景下,没有太多取巧的方法。相关复杂度的引入感觉对于库本身不一定是好事。对于asio来说,allocator用户层可定制,基本已经就提供了业务层所有需要的内容了。
理解了op::ptr的类型定义,再来看post的主体代码,就比较好理解了:
typedef typename std::decay<Function>::type function_type;
// Allocate and construct an operation to wrap the function.
typedef detail::executor_op<function_type, Allocator, detail::operation> op;
typename op::ptr p = { detail::addressof(a), op::ptr::allocate(a), 0 };
p.p = new (p.v) op(static_cast<Function&&>(f), a);
io_context_.impl_.post_immediate_completion(p.p, false);