写点什么

一个 cpp 协程库的前世今生(九)协程参数与返回值的处理

作者:SkyFire
  • 2022 年 1 月 03 日
  • 本文字数:5166 字

    阅读完需:约 17 分钟

一个cpp协程库的前世今生(九)协程参数与返回值的处理

为了防止大家找不到代码,还是先贴一下项目链接:


GitHub - skyfireitdiy/cocpp at cocpp-0.1.0


我们希望以任意一个函数作为协程的入口函数,并希望在他运行结束的时候获取它的返回值,这个需求看起来很简单,但是如果想要做到对用户透明,还是有一点难度的。


什么是对用户透明,就是说我的协程入口函数可以是任意函数,而不是特定函数,举个例子:


int add(int a,int b);
复制代码


或者:


std::map<int, string> get_map();
复制代码


为了实现这个功能,我们首先需要解决几个问题:


  • 协程创建的接口如何设计?

  • 协程入口函数的参数如何

  • 如何存储不同的返回值类型?

  • 如何传递返回值?


接下来我们自底向上分析下调度框架是如何将一个用户传入的任意函数调用起来,并将其返回值传递回上册层的。

ctx 的寄存器初始化

初始化一个 ctx 的函数如下(source/cocpp/core/co_vos_gcc_x86_64.cpp):


void init_ctx(co_stack* shared_stack, co_ctx* ctx){    auto      context = get_sigcontext_64(ctx);    co_stack* stack   = ctx->stack();    if (ctx->test_flag(CO_CTX_FLAG_SHARED_STACK))    {        stack = shared_stack;    }    auto config = ctx->config();
CO_SETREG(context, sp, stack->stack_top()); CO_SETREG(context, bp, stack->stack_top()); CO_SETREG(context, ip, &co_ctx::real_entry); CO_SETREG(context, di, ctx);}
复制代码


此函数会被 co_env::add_ctx 函数调用,有些读者朋友可能会问,ctx 的初始化,为何不放在 ctx 的构造函数中呢?这是因为 ctx 的初始化需要依赖栈空间,对于共享栈的协程,其栈空间需要加入 env 的时候才知道。


当然,这个问题肯定也可以解决,比如先选择 env,再创建 ctx。当前版本如此实现,只是一种选择,不必纠结于此。


从上面的函数我们可以看出,协程最底层的入口函数是 co_ctx::real_entry,参数为 ctx(di 寄存器存储了第一个参数),接下来我们看下 co_ctx::real_entry 函数中做了什么。

co_ctx::real_entry

co_ctx::real_entry 函数位于 source/cocpp/core/co_ctx.cpp:


void co_ctx::real_entry(co_ctx* ctx){    ctx->entry()(ctx->ret_ref());    // CO_DEBUG("ctx %s %p finished", ctx->config().name.c_str(), ctx);    ctx->set_state(co_state::finished);    ctx->finished().pub();    assert(ctx->env() != nullptr);    ctx->env()->schedule_switch(); // 此处的ctx对应的env不可能为空,如果为空,这个ctx就不可能被调度}
复制代码


此函数中,调用了 ctx::entry 函数的返回对象,并将 ctx::ret_ref 返回的对象作为参数传递进去。


运行结束后,设置了 ctx 的状态,并主动切换出去(此次切换出去后就不会再被调度了,因为状态已经变成 finished 了)。


所以接下来分别看一下 ctx::entry 和 ctx::ret_ref 返回的对象。

ctx::entry

ctx::entry 返回 ctx 成员 entry__(source/cocpp/core/co_ctx.cpp)。


std::function<void(co_any&)> co_ctx::entry() const{    return entry__;}
复制代码


而 entry__的赋值位于构造函数,是由上层传入的(source/cocpp/core/co_ctx.cpp),这个待分析完 ctx::ret_ref 之后再继续向上层分析。


co_ctx::co_ctx(co_stack* stack, const co_ctx_config& config, std::function<void(co_any&)> entry)    : stack__(stack)    , config__(config)    , entry__(entry){    set_priority(config.priority);}
复制代码

ctx::ret_ref

ctx::ret_ref 返回成员 ret__的引用(source/cocpp/core/co_ctx.cpp)。


co_any& co_ctx::ret_ref(){    return ret__;}
复制代码


再看一下 ret__成员,ret__成员的类型是 co_any,被设计为可以存储任意类型(include/cocpp/utils/co_any.h):



class co_any final //: private co_noncopyable{private: class base_type { public: virtual ~base_type() = default; };
template <typename T> class real_type : public base_type { T value__;
public: real_type(const T& value); T& get(); };
std::shared_ptr<base_type> data__ { nullptr };
public: co_any() = default; co_any(const co_any& value) = default; co_any& operator=(const co_any& value) = default; template <typename T> co_any(const T& value); template <typename T> co_any& operator=(const T& other); template <typename T> T& get();};
复制代码


其设计很简单,co_any 是一个普通类,同时,它拥有一个空的内部基类 base_type(内部基类存在的目的是屏蔽不同类型的差异),同时拥有一个可以存储数据的模板类 real_type ,其继承自内部基类 base_type。


因为 real_type 继承自 base_type,因此使用 std::shared_ptr<base_type>就可以存储 real_type 类型的指针。


可以简单看一下其赋值操作符的实现:


template <typename T>co_any& co_any::operator=(const T& value){    data__ = std::shared_ptr<real_type<T>>(new real_type<T>(value));    return *this;}
复制代码


当我们要取得其存储的值时,需要指定其类型,内部会使用 std::dynamic_pointer_cast 做转型,这里会有一定的性能损失,不过易用性与性能有时候就是背道而驰的。好在返回值的获取一般不会特别频繁。


有的读者可能会问,co_any 与 std::any 的功能应该是相同的呀,为什么不使用 std::any 呢?


在之前的版本却是适用的是 std::any,但是在调试过程中发现有一定几率(极小)发生段错误,经排查是发生在 std::any 实现内部,std::any 的具体实现没有深入了解,不好定位,于是就实现了一个类似功能的类,实现比较简单,但是一般简单的东西不容易出问题。


很明显成员 ret__就是被用来存放协程的返回值的,可以接受不同类型的返回值。


分析完 ctx::entry 和 ctx::ret_ref,接下来看下再上一层的调用吧。

co_ctx_factory ::create_ctx

ctx 的创建是由工厂完成的(source/cocpp/core/co_ctx_factory.cpp):


co_ctx* co_ctx_factory ::create_ctx(const co_ctx_config& config, std::function<void(co_any&)> entry){    auto ret = ctx_pool__.create_obj(config.shared_stack ? nullptr : co_stack_factory::instance()->create_stack(config.stack_size), config, entry);    assert(ret != nullptr);    if (config.bind_env != nullptr)    {        ret->set_flag(CO_CTX_FLAG_BIND);    }    if (config.shared_stack)    {        ret->set_flag(CO_CTX_FLAG_SHARED_STACK);    }    return ret;}
复制代码


工厂函数的第二个参数就是协程入口,对应 co_ctx 构造函数的第三个参数。这里没有直接调用 co_ctx 的构造函数,而是通过一个对象池 ctx_pool__调用的,查看头文件 include/cocpp/core/co_ctx_factory.h 可以看到 ctx_pool__的类型是 co_object_pool<co_ctx>,那么简单分析一下 co_object_pool 的实现(include/cocpp/mem/co_object_pool.h)。


template <typename ObjectType>class co_object_pool final : private co_noncopyable{private:    std::deque<void*> pool__;                                     // 内存池    co_spinlock       mu__ { co_spinlock::lock_type::in_thread }; // 互斥锁    size_t            max_cap__;                                  // 最大容量public:    co_object_pool(size_t max_cap); // 构造函数    template <typename... ConstructParam>    ObjectType* create_obj(ConstructParam&&... params); // 创建对象    void        destroy_obj(ObjectType* obj);           // 销毁对象    void        clear_free_object();                    // 清空空闲对象    ~co_object_pool();                                  // 析构函数};
复制代码


成员 pool__提供对象使用的内存池。


当调用 create_obj 创建一个对象的时候,会使用 placement new 在现有内存上创建一个新的对象,从而避免新的内存分配。


template <typename ObjectType>template <typename... ConstructParam>ObjectType* co_object_pool<ObjectType>::create_obj(ConstructParam&&... params){    std::lock_guard<co_spinlock> lck(mu__);    void*                        mem = nullptr;    if (pool__.empty())    {        mem = std::aligned_alloc(alignof(ObjectType), sizeof(ObjectType));    }    else    {        mem = pool__.front();        pool__.pop_front();    }    assert(mem != nullptr);    return new (mem) ObjectType(std::forward<ConstructParam>(params)...);}
复制代码


此函数会先检测内存池中是否有空闲内存,如果没有,就申请一块,否则就从内存池取一块。然后调用 placement new 操作符。


在对象销毁的时候,并不是直接 delete,而是手动调用类的析构函数,然后尝试将内存插入内存池的首部,之所以插入首部而不是尾部,是因为这样可以让刚释放的内存在短时间内可以重复使用,这利用了缓存的时间局部性。


template <typename ObjectType>void co_object_pool<ObjectType>::destroy_obj(ObjectType* obj){    std::lock_guard<co_spinlock> lck(mu__);    obj->~ObjectType();    if (pool__.size() > max_cap__)    {        free(obj);    }    else    {        pool__.push_front(obj); // 尽快用到这块内存    }}
复制代码


那么 ctx 的工厂函数是谁调用的呢?从源码可以看到,是由 co_manager::create_and_schedule_ctx 函数调用的。

co_manager::create_and_schedule_ctx

manager 中的协程创建接口是这样的(include/cocpp/core/co_manager.cpp):


co_ctx* co_manager::create_and_schedule_ctx(const co_ctx_config& config, std::function<void(co_any&)> entry, bool lock_destroy){    auto ctx = factory_set__.ctx_factory->create_ctx(config, entry);    subscribe_ctx_event__(ctx);    if (lock_destroy)    {        ctx->lock_destroy();    }    auto bind_env = ctx->config().bind_env;    if (bind_env != nullptr)    {        bind_env->add_ctx(ctx);    }    else    {        get_best_env__()->add_ctx(ctx);    }
ctx_created().pub(ctx); return ctx;}
复制代码


此处对上层传递下来的协程入口仅仅是做了一层透传,没有做任何处理,那么,在向上一层。

co::init__

co_manager::create_and_schedule_ctx 函数是被 co::init__调用的,此处是生成和规范化协程入口函数的地方(include/cocpp/interface/co.h):


template <typename Func, typename... Args>void co::init__(co_ctx_config config, Func&& func, Args&&... args){    std::function<void(co_any&)> entry;    if constexpr (std::is_same_v<std::invoke_result_t<std::decay_t<Func>, std::decay_t<Args>...>, void>)    {        entry = [... args = std::forward<Args>(args), func = std::forward<Func>(func)](co_any& ret) mutable {            std::forward<Func>(func)(std::forward<Args>(args)...);        };    }    else    {        entry = [... args = std::forward<Args>(args), func = std::forward<Func>(func)](co_any& ret) mutable {            ret = std::forward<Func>(func)(std::forward<Args>(args)...);        };    }
ctx__ = manager__->create_and_schedule_ctx(config, entry, true);}
复制代码


前面提到的 entry,其本质上是这里面两个 lambda 表达式中的一个。


对于是否有返回值,这里做了区分。当入口函数有返回值的时候,在 lambda 中会将传入的 ret 引用赋值,ret 引用在上文分析可以知道是 ctx 中的 ret__成员,所以协程入口函数运行结束之后,ctx 的成员 ret__就会被赋值。而对于无返回值的入口函数,就没有返回值的赋值操作了。


最后我们看下 co 的构造函数,他是直接调用 co::init__的地方。

co::co

co 的构造函数有两个重载,其中一个使用默认配置,另一个可以自定义一些配置。这里我们仅看一下使用默认配置的重载,另一个的分析也是相同的。


include/cocpp/interface/co.h


template <typename Func, typename... Args>co::co(Func&& func, Args&&... args){    init__(co_ctx_config {}, std::forward<Func>(func), std::forward<Args>(args)...);}
复制代码


构造函数将参数直接转发到 co::init__函数中,这样就完成了从任意入口函数到规范化协程入口函数的转换了。

总结

接下来我们梳理一下一个任意函数到协程入口函数再到函数执行的完成流程,如下图。整个过程涉及了函数包装和多次函数转发,对照源码查看可以理解得更深刻。



本文从协程函数执行开始分析,自底向上分析了协程函数的每一次封装与转发,阐述了一个任意的函数怎样每协程框架调起来的。

发布于: 53 分钟前
用户头像

SkyFire

关注

这个cpper很懒,什么都没留下 2018.10.13 加入

会一点点cpp的苦逼码农

评论

发布
暂无评论
一个cpp协程库的前世今生(九)协程参数与返回值的处理