CyberRt协程介绍

目录

协程

线程切换

协程切换

问题

两个接口

初始化协程栈

举个例子

总结


协程

CyberRt作为百度阿波罗的中间件,采用了比较有特色的协程调度框架。

本文主要是为了更详细的介绍下,CyberRT协程的实现原理。

线程切换

linux下线程的调度属性,主要包括,SCHED_FIFO/SCHED_RR/SCHED_OTHER等方式。

一般来说,线程切换有如下场景:

  • 时间片用完,线程主动放弃CPU的使用权,给其他线程使用。如RR策略。

  • 该线程被其他更加高优线程抢占,如FIFO策略。

  • 该线程主动调用阻塞接口,典型的,如IO相关操作:Read/Write/。互斥量相关操作,如lock/wait。主动休眠Sleep等操作。

而对于这些操作而言,每次的切换均需要进行用户态与内核态之间的切换,导致了CPU更多的浪费在了无效的指令中。

线程切换:本质是由操作系统保存当前的寄存器的值,以及线程函数执行到的那个切换点的独立的线程栈。

如果有一种方式,可以既能够完成多路任务的切换,又能够避免内核态与用户态的开销。随之而来的解决方案就是协程。

协程切换

参照线程切换的逻辑,协程在切换过程中也需要保存当前寄存器的值,以及当前协程函数所执行到的切换点的协程栈。

问题

1. 协程切换了,寄存器保存在哪里

        CyberRt是有栈协程实现方式,将寄存器直接保存至协程的栈空间内

2. 协程切换了,如何知道上一次执行到协程栈上的具体位置

        在RoutineContext结构体预留sp指针,该指针标志着每个协程栈空间,程序所执行到了的栈顶的位置,如果协程切换出去,后续将依赖于sp指针的位置进行上下文恢复

两个接口

协程的Yield和Resume:

  • Yield:在协程函数内调用,保存当前执行上下文,主要是寄存器和栈顶指针,随后让出线程的使用权,跳出协程函数执行逻辑。
  • Resume:在线程主逻辑调用。第一次调用将进入CoroutineEntry入口处执行。后续调用将切换至上次协程Yield的地方,恢复执行上下文后继续执行协程的逻辑。

// 协程依赖的上下文,stack标识的是该协程任务依赖的运行栈
// sp标志着当前执行到的栈顶,通过sp可以在切换回协程后,找到上一次程序执行到的地方
struct RoutineContext {
  char stack[STACK_SIZE];
  char* sp = nullptr;
#if defined __aarch64__
} __attribute__((aligned(16)));
#else
};
#endif

初始化协程栈

CyberRT会根据配置的component数目,从内存池申请大块内存。随后会在创建协程的时候,从内存池内提供协程上下文空间,包括协程栈(2M)和栈顶指针(char*)。

初始化时在协程栈内存预留寄存器的存储空间,栈顶指针,协程入口函数,执行参数等信息。

CRoutine::CRoutine(const std::function<void()> &func) : func_(func) {
  std::call_once(pool_init_flag, [&]() {
    uint32_t routine_num = common::GlobalData::Instance()->ComponentNums();
    auto &global_conf = common::GlobalData::Instance()->Config();
    if (global_conf.has_scheduler_conf() &&
        global_conf.scheduler_conf().has_routine_num()) {
      routine_num =
          std::max(routine_num, global_conf.scheduler_conf().routine_num());
    }
    // 整体的空间分配。从内存池分配routine_num*RoutineContext的空间大小
    // 后续协程栈从内存空间获取一块Context即可
    context_pool.reset(new base::CCObjectPool<RoutineContext>(routine_num));
  });
  // 获取可用的buffer
  context_ = context_pool->GetObject();
  if (context_ == nullptr) {
    AWARN << "Maximum routine context number exceeded! Please check "
             "[routine_num] in config file.";
    // 若可用buffer用完,则直接从堆内存申请RoutineContext
    context_.reset(new RoutineContext());
  }
  // 初始化协程栈,指定协程入口函数为CRoutineEntry
  MakeContext(CRoutineEntry, this, context_.get());
  // 协程初始态为Ready,可直接由Processor调度
  state_ = RoutineState::READY;
  updated_.test_and_set(std::memory_order_release);
}

以X86_64为例,其栈顶指针寄存器为rsp。

MakeContext函数:此时协程Context已经分配好了,即2M内存空间+指针sp。在MakeContext里对这2M空间进行初始化。

  • 计算出sp的位置为从栈底-2*sizeof(void*)-REGISTERS_SIZE
// 用于构造协程栈
void MakeContext(const func &f1, const void *arg, RoutineContext *ctx) {
  // 计算出ctx->sp的位置为从栈底-2*sizeof(void*)-REGISTERS_SIZE
  // 预留出CroutineEntry+14个通用寄存器的存储空间
  ctx->sp = ctx->stack + STACK_SIZE - 2 * sizeof(void *) - REGISTERS_SIZE;
  std::memset(ctx->sp, 0, REGISTERS_SIZE);
#ifdef __aarch64__
  char *sp = ctx->stack + STACK_SIZE - sizeof(void *);
#else
  char *sp = ctx->stack + STACK_SIZE - 2 * sizeof(void *);
#endif
  // 在栈底位置填入CroutineEntry函数地址
  *reinterpret_cast<void **>(sp) = reinterpret_cast<void *>(f1);
  sp -= sizeof(void *);
  // 在CroutineEntry所在位置下一个地方放入arg参数地址
  *reinterpret_cast<void **>(sp) = const_cast<void *>(arg);
}

初始化后的栈空间如下所示:

  • 第一次调用该croutine的resume函数。rsp通过char* sp,获取栈顶地址,然后popq获取栈中栈帧值至物理寄存器,直至执行CroutineEntry。

 协程执行到某个地方执行yield挂起。

  • 挂起后,将当前CPU寄存器中的值保存至该协程栈中。将sp指向协程栈顶,用于resume后rsp找到栈顶。

 汇编代码:

//假设调用的是resume函数,执行
//ctx_swap(reinterpret_cast<void**>(src_sp), reinterpret_cast<void**>(dest_sp));
//此时src_cp代表main_stack的sp指针,dest_sp代表croutine_stack的sp指针。

//pushq:将后面的寄存器数据放入到sp所指向的栈当中。
//movq:将后面的值赋值给前面的
//popq:sp所指向的栈空间从取出对应的数据放入后面的寄存器当中。

.globl ctx_swap
.type  ctx_swap, @function
ctx_swap: 
      pushq %rdi
      pushq %r12
      pushq %r13
      pushq %r14
      pushq %r15
      pushq %rbx
      pushq %rbp
      movq %rsp, (%rdi) 
      //1. 将当前物理寄存器内容保存至main_stack。
      //2. rdi代表函数第一个入参,将当前线程栈信息保存至main_stack。

      movq (%rsi), %rsp 
      //1. rsi代表函数第二个入参,
      //2. 第二个入参为ctx->sp,保存着协程栈的栈顶地址
      //3. 将rsp指向ctx->sp,也就是指向待运行croutine_stack栈顶。
      popq %rbp
      popq %rbx
      popq %r15
      popq %r14
      popq %r13
      popq %r12
      popq %rdi
      ret //执行协程入口函数CRoutineEntry,ret 的作用就是把 %rsp 上移一个位置,并跳转到返回地址执行

举个例子

假设某线程执行函数:

void funA() {
    funB() {
        funC() {
            ...
            coroutine->Resume();
        }
    }
}

其对应的栈结构如下所示:

协程将在创建完成后,由Processor通过resume调用。main_stack代表Processor线程的主栈。Processor实现协程的调度逻辑,通过由main_stack切换至指定协程stack的方式,实现用户任务执行。

  • 由main_stack切换至就绪协程,即resume。在main_stack内保存当前CPU物理寄存器的值。主栈切换后的变化:

  • 切换至就绪协程的栈

协程入口函数:

CoroutineEntry

参数:

void* args

刚创建协程,执行MakeContext,栈状态如左图所示。指定协程入口函数和参数,预留寄存器存储空间并置为0;

创建完成后,该协程第一次Resume,CoroutineEntry未执行,栈状态如中间图所示。此时cr_stack为空,而rsp指向该协程栈的栈顶,当该函数执行后,将控制从rsp执行的位置进行栈信息的存储。

CoroutineEntry开始执行后,栈状态如右图所示。此时执行栈已经切换为协程独有的栈。

由于rsp执行新的协程栈栈顶,因此CoroutineEntry的执行栈已经切换为为该协程所分配的执行栈空间,执行栈顶主要依赖于rsp的指向位置控制。

  • 执行中的协程Yield切换回主栈。

此时将当前寄存器的值保存至内部协程栈中。

  • 协程Yield后主栈变化。修改rsp寄存器的值,将执行栈恢复至主栈,然后pop恢复寄存器的值,此时程序可以继续从主栈上次Resume的地方继续往下执行。

总结

本文主要介绍了CyberRt协程栈的切换过程。在调用Resume和Yield的接口前后的主栈和相关协程栈的变化情况。

  • CyberRT固定为协程分配的栈大小为2M,通过sp指针保存栈顶位置。
  • 初次Resume协程,将调用CroutineEntry函数,参数为void* args。后续Resume该协程将跳到上一次协程Yield的地方继续执行。
  • 切出协程时会将寄存器存储至协程栈内,并恢复主栈信息。切入协程时将寄存器加载至物理寄存器。通过这种方式在用户态模拟出了类似于线程切换的机制。

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值