这一篇的主题是启动/唤醒,切换协程,这是比较难的一部分。
启动/唤醒(resume)
void co_resume( stCoRoutine_t *co )
{
if( !co->cStart )
{
// 初始化对应的堆栈空间
coctx_make( &co->ctx, (coctx_pfn_t)CoRoutineFunc, co, 0 );
// 第一次启动要设置为1, 作为是否要初始化堆栈区的标志
co->cStart = 1;
}
// 从协程中获取env,就是那个每个线程共享的变量,其数组的最后有效的一个就是当前的协程
stCoRoutineEnv_t* env = co->env;
stCoRoutine_t* lpCurrRoutine = env->pCallStack[env->iCallStackSize - 1];
// resume 把新的协程加入数组
env->pCallStack[ env->iCallStackSize++ ] = co;
// 新的协程和当前协程切换
co_swap( lpCurrRoutine, co );
}
resume有两种情况
一种是第一次的启动,另一种是唤醒,以上代码调整了顺序。
启动:如果是第一次需要做一些额外的初始化,包括初始化co->ctx的堆栈空间置为标志
启动/唤醒:都需要从调用栈数组中取出当前协程与即将切入协程交换。
coctx_t 如何实现保存cpu的上下文,使用到coctx_make
int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1)
{
// sp 指向堆顶,一定要减去void*的大小,否则越界
char* sp = ctx->ss_sp + ctx->ss_size - sizeof(void*);
// sp 指向堆顶16位对齐后位置 gcc 默认的堆对齐是16字节对齐,sp的指针与操作后往下移
sp = (char*)((unsigned long)sp & -16LL);
// 置位寄存器为0
memset(ctx->regs, 0, sizeof(ctx->regs));
// sp位置为指向pfn指针
void** ret_addr = (void**)(sp);
*ret_addr = (void*)pfn;
// 设置rsp为返回的的寄存器13 rsp寄存器
ctx->regs[kRSP] = sp;
// 设置ret寄存器为pfn地址
ctx->regs[kRETAddr] = (char*)pfn;
// 设置第一个参数 rdi
ctx->regs[kRDI] = (char*)s;
// 设置第二个参数 rsi,目前没有使用
ctx->regs[kRSI] = (char*)s1;
return 0;
}
关于第一句sp指针的位置,为什么是这样呢,请看下图:
不难理解,我们原来co_create 调用了co_create_env,创建了stack_mem的buffer,而我们就是想用这块buffer,来作为我们存储栈的地方。而因为栈指针都是从高地址往低地址走,这就是我们的sp为什么要对应堆顶的原因。
sp = (char*)((unsigned long)sp & -16LL);
这个的目的是为了字节对齐,16的二进制是10000,一个数与-16相与,相当于屏蔽了低四位,也就是栈sp往下走到16字节对齐的地方。
关于coctx_t结构
struct coctx_t
{
#if defined(__i386__)
void *regs[ 8 ];
#else
void *regs[ 14 ];
#endif
size_t ss_size; // 栈的大小
char *ss_sp; // 栈的buffer
};
这里存储了regs[14],本来CPU通用寄存器有16个,这个只定义了14个。
16个cpu的通用寄存器:
libco定义的reg[14]
//-------------
// 64 bit
low | regs[0]: r15 |
// | regs[1]: r14 |
// | regs[2]: r13 |
// | regs[3]: r12 |
// | regs[4]: r9 |
// | regs[5]: r8 |
// | regs[6]: rbp |
// | regs[7]: rdi |
// | regs[8]: rsi |
// | regs[9]: rax |
// | regs[10]: rdx |
// | regs[11]: rcx |
// | regs[12]: rbx |
higt | regs[13]: rsp |
enum {
kRDI = 7, // 第一个参数
kRSI = 8, // 第二个参数
kRETAddr = 9, // 返回地址
kRSP = 13, // 栈指针
};
其实,cpu的寄存器有很多,有45个,但是通用寄存器16个,还有各种各样的寄存器,具体可以看看知乎的 一口气看完45个寄存器,CPU核心技术大揭秘 - 知乎
而协程其实只使用到了通用的寄存器,浮点运算使用到的libco也不做处理,据说libaco这方面做了,有兴趣的可以去围观。
co_swap
void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co)
{
stCoRoutineEnv_t* env = co_get_curr_thread_env();
//get curr stack sp
char c;
curr->stack_sp = &c;
// 切换前的预备动作
if (!pending_co->cIsShareStack) // 独立栈
{
env->pending_co = NULL;
env->occupy_co = NULL;
}
else // 共享栈
{
env->pending_co = pending_co;
// 获取原来占用共享栈的协程
stCoRoutine_t* occupy_co = pending_co->stack_mem->occupy_co;
// 切入协程的栈占有协程设置即将切入协程
pending_co->stack_mem->occupy_co = pending_co;
env->occupy_co = occupy_co;
if (occupy_co && occupy_co != pending_co)
{
// 如果上一个使用协程不为空,且不是原来的协程,则需要把它的栈内容保存起来
save_stack_buffer(occupy_co);
}
}
//切换cpu上下文,实现协程切换
coctx_swap(&(curr->ctx),&(pending_co->ctx) );
//stack buffer may be overwrite, so get again;
stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();
stCoRoutine_t* update_occupy_co = curr_env->occupy_co;
stCoRoutine_t* update_pending_co = curr_env->pending_co;
if (update_occupy_co && update_pending_co && update_occupy_co != update_pending_co)
{
//resume stack buffer
if (update_pending_co->save_buffer && update_pending_co->save_size > 0)
{
memcpy(update_pending_co->stack_sp, update_pending_co->save_buffer, update_pending_co->save_size);
}
}
}
又是一段好长的代码,分三个阶段
切换前:
切换前,获取当前的栈类型,是共享栈还是独立栈。
共享栈:所有协程共享该栈,这就会导致一个问题,当需要切换时,协程需要自己保存起来,否则栈就会被别人的栈盖掉,优点是我可以开辟很大的栈,这样就不太需要关心独立栈的大小问题。
独立栈:每个协程自己维护一个栈空间,所以需要分配好大小,速度比较快。不用每次自己保存和加载。
这就是代码中如果是共享栈,切换前需要保存到自己的空间里面的原因
其中:
char c;
curr->stack_sp = &c;
这个代码,用一种取巧的方式获取到了当前的栈帧,作为后面保存栈时知道要保存哪些数据用。
看看savebuffer时怎么搞的,就是利用了刚刚取到的sp,获取了长度后保存到协程自己的buffer。
看起来不太高效,据说libaco有高效的实现,我还没看。
void save_stack_buffer(stCoRoutine_t* occupy_co)
{
// 获取当前协程的栈内存
stStackMem_t* stack_mem = occupy_co->stack_mem;
// 当前协程的堆顶(栈底) - 实际上运行到的栈位置,那个取地址的作用&c
int len = stack_mem->stack_bp - occupy_co->stack_sp;
// 先析构
if (occupy_co->save_buffer)
{
free(occupy_co->save_buffer);
occupy_co->save_buffer = NULL;
}
// 将对应的栈保存起来
occupy_co->save_buffer = (char*)malloc(len); //malloc buf;
occupy_co->save_size = len;
memcpy(occupy_co->save_buffer, occupy_co->stack_sp, len);
}
切换:
这一下就来到了汇编的世界了,如果没有看懂,不急不急,偷偷告诉你,腾讯再2018年之前,这个汇编也是写得有bug的,别说,腾讯都跑了好几年的这二十来行程序,都有bug,你觉得一下子没看明白,那也正常。
#elif defined(__x86_64__)
leaq (%rsp),%rax
movq %rax, 104(%rdi)
movq %rbx, 96(%rdi)
movq %rcx, 88(%rdi)
movq %rdx, 80(%rdi)
movq 0(%rax), %rax
movq %rax, 72(%rdi)
movq %rsi, 64(%rdi)
movq %rdi, 56(%rdi)
movq %rbp, 48(%rdi)
movq %r8, 40(%rdi)
movq %r9, 32(%rdi)
movq %r12, 24(%rdi)
movq %r13, 16(%rdi)
movq %r14, 8(%rdi)
movq %r15, (%rdi)
xorq %rax, %rax
movq 48(%rsi), %rbp
movq 104(%rsi), %rsp
movq (%rsi), %r15
movq 8(%rsi), %r14
movq 16(%rsi), %r13
movq 24(%rsi), %r12
movq 32(%rsi), %r9
movq 40(%rsi), %r8
movq 56(%rsi), %rdi
movq 80(%rsi), %rdx
movq 88(%rsi), %rcx
movq 96(%rsi), %rbx
leaq 8(%rsp), %rsp
pushq 72(%rsi)
movq 64(%rsi), %rsi
ret
#endif
首先切换的目的,
1、先将当前cpu寄存器的值保存到要切出去的协程,你看到的,要切出去的协程时第一个参数curr,他的寄存器时rdi,所以第一段就是把一堆当前寄存器保存到第一个参数的curr->ctx的reg中
2、把要切进来的协程的寄存器保存到当前的cpu寄存器,即将切入的协程时第二个参数,再rsi中,然后根据rsi的偏移,实际获得了pending_co->ctx->reg的数据写入到当前的cpu寄存器。这样就完美的实现了切换。
切换后:
注意,切换完后,cpu就跑到切入的协程去了,不会再走到红线以下了,要等待下一次协程resume才会走到下面去
下一次协程唤醒resume,则需要把刚刚保存再协程buffer中如果时共享栈的资源的给重新设置到共享栈中。
这就完成了协程的启动,切换,唤醒。