目录
协程
线程切换
协程切换
问题
两个接口
初始化协程栈
举个例子
总结
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:
// 协程依赖的上下文,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 &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(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空间进行初始化。
// 用于构造协程栈
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(sp) = reinterpret_cast(f1);
sp -= sizeof(void *);
// 在CroutineEntry所在位置下一个地方放入arg参数地址
*reinterpret_cast(sp) = const_cast(arg);
}
初始化后的栈空间如下所示:
协程执行到某个地方执行yield挂起。
汇编代码:
//假设调用的是resume函数,执行
//ctx_swap(reinterpret_cast(src_sp), reinterpret_cast(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的方式,实现用户任务执行。
协程入口函数:
CoroutineEntry
参数:
void* args
刚创建协程,执行MakeContext,栈状态如左图所示。指定协程入口函数和参数,预留寄存器存储空间并置为0;
创建完成后,该协程第一次Resume,CoroutineEntry未执行,栈状态如中间图所示。此时cr_stack为空,而rsp指向该协程栈的栈顶,当该函数执行后,将控制从rsp执行的位置进行栈信息的存储。
CoroutineEntry开始执行后,栈状态如右图所示。此时执行栈已经切换为协程独有的栈。
由于rsp执行新的协程栈栈顶,因此CoroutineEntry的执行栈已经切换为为该协程所分配的执行栈空间,执行栈顶主要依赖于rsp的指向位置控制。
此时将当前寄存器的值保存至内部协程栈中。
本文主要介绍了CyberRt协程栈的切换过程。在调用Resume和Yield的接口前后的主栈和相关协程栈的变化情况。