目录
协程
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的地方继续执行。
- 切出协程时会将寄存器存储至协程栈内,并恢复主栈信息。切入协程时将寄存器加载至物理寄存器。通过这种方式在用户态模拟出了类似于线程切换的机制。