C++异步:asio的scheduler实现!

本文详细探讨了C++ Asio库的scheduler实现,从通用任务支持、版本现状、网络知识到operation、stand、timer的实现,揭示了Asio如何处理异步任务。Asio通过lambda和函数对象实现通用任务的post(),使用strand确保任务按顺序执行,同时提供高效的timer实现。文章还分析了不同运行模式和timer_queue的min-heap结构。此外,文章还提到了asio在C++11之后对execution提案的尝试,以及对property和coroutine的支持。最后,文章指出Asio的scheduler在跨平台和高性能方面的优势,适合用于C++后台开发。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前面也提到过,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);
 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值