libco协程切换原理详解

本文详细解析了libco协程切换的汇编实现,涉及栈帧管理、寄存器操作,以及协程切换过程中的bug修复。同时介绍了协程的工作原理和libco的局限性,推荐使用libgo替代。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

一 备用知识

第一部分 汇编基础

第二部分 函数调用原理

二 什么是协程

三 libco协程切换实现原理

四 libco的问题

五 相关链接


一 备用知识

       之前不止一次的把libco的源码下下来看,但是每次看到协程切换汇编部分都放弃了,大学那点仅有的汇编基础也忘的一干二净,更不堪的是百度搜到的libco相关文章也看不懂,最近决定静下心来看一些相关的东西,终于像啃骨头一样把这块东西搞明白了。 建议在往下阅读之前请先务必看懂下面的内容。

第一部分 汇编基础

   1 CSAPP阅读笔记-汇编语言初探(数据传送类指令):CSAPP阅读笔记-汇编语言初探(数据传送类指令)
   2 CSAPP阅读笔记-汇编语言初探(算术和逻辑操作类指令):CSAPP阅读笔记-汇编语言初探(算术和逻辑操作类指令)
   3 CSAPP阅读笔记-汇编语言初探(控制类指令):CSAPP阅读笔记-汇编语言初探(控制类指令)
   4 CSAPP阅读笔记-栈帧:CSAPP阅读笔记-栈帧

    看完这些内容后你需要知道什么是栈帧,汇编的寻址方式,mov,push,pop,lea,ret,leave,jmp,xor等常用的指令以及不同平台下相关指令的不同写法,最重要的是不同指令和不同的寻址方式搭配起来不同的意义,到底是操作寄存器中的值还是操作寄存器中的值指向的内存里的值。

第二部分 函数调用原理

   1 操作系统知识:程序计数器(pc)、指令寄存器(IR)、通用寄存器(GR)、状态寄存器(SR)、程序状态字PSW:文章链接
   2 一条指令在cpu中的执行流程(理解CPU组成):一条指令在cpu中的执行流程(理解CPU组成)
   3 x86-64 下函数调用及栈帧原理:x86-64 下函数调用及栈帧原理
   4 程序执行的过程 - 一文看懂计算机执行程序的过程:程序执行的过程

    第二部分看完后需要知道cpu执行原理,操作系统通用寄存器,指令寄存器等寄存器的用途,特别是函数调用及栈帧原理一定要搞清。

    看完后我们了解到每次进入一个函数编译器为该函数创建一个新的栈帧,%rsp 是堆栈指针寄存器,通常会指向栈顶位置,堆栈的 pop 和push 操作就是通过改变 %rsp 的值即移动堆栈指针的位置来实现的。%rbp 是栈帧指针,用于标识当前栈帧的起始位置,这两个寄存器是最重要的两个寄存器,分别指向函数栈帧的头部和尾部。我们切换协程的主要手段也是通过保存和恢复这两个寄存器和其他一些相关的寄存器来完成的。只不过通常堆栈的切换和恢复是有编译器来完成的,编译器通过call,leave,ret等指令完成函数的调用和返回,而我们要说的协程切换实际上就是在一个函数执行完返回前的任意时刻绕过编译器随时地切换到别的函数,而且随时可以切回来接着执行。因此下面我们说的协程切换的原理核心就是通过汇编直接保存和恢复相关的寄存器信息模拟函数的调用和恢复的过程。

二 什么是协程

      协程可以很轻量的在子例程中进行切换,它由程序员进行子例程的调度(即切换)而不像线程那样需要内核参与,同时也省去了内核线程切换的开销,因为一个协程切换保留的就是函数调用栈和当前指令的寄存器,而线程切换需要陷入内核态,改变线程对象状态,通俗点来说就是用户级线程,不像线程那样由系统来调度执行,而是跑在线程之上有程序员来调度执行,当然还有其他很多的不同,这里就不重点说明了,自行百度,相关的文章很多。

如何实现context切换的呢,目前主要有以下几种方式:
   1使用ucontext系列接口,例如:libtask
   2使用setjmp/longjmp接口,例如:libmill
   3使用windows的GetThreadContext/SetThreadContext接口
   4使用windows的CreateFiber/ConvertThreadToFiber/SwitchToFiber接口
   5使用纯汇编实现,boost.context,包括我们下面要分析的libco实现都是使用纯汇编实现的,本文也主要分析libco的实现方式。

三 libco协程切换实现原理

   读到这里,应该知道要实现功能核心就是要模拟函数的调用,也就是寄存器信息,堆栈上下文的保存和恢复。我们先看libco中用来保存这些数据的数据结构(下面我们主要讨论__x86_64__相关内容)。

struct coctx_t
{
#if defined(__i386__)
	void *regs[ 8 ];
#else
	void *regs[ 14 ];
#endif
	size_t ss_size;
	char *ss_sp;
};

    这个结构用来保存函数栈的寄存器信息和堆栈上下文,其中regs[ 14 ]对应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]: ret |  //ret func addr, 对应 rax
//    | regs[10]: rdx |
//    | regs[11]: rcx | 
//    | regs[12]: rbx |
//hig | regs[13]: rsp |

     接着我们看如何从一个协程切换到另外一个协程。先看函数原型

extern "C"
{
	extern void coctx_swap( coctx_t * from,coctx_t* to) asm("coctx_swap");
};

      coctx_swap负责把当前的运行栈从from切换到to中执行,也可以说从运行栈from所属的函数中切换到运行栈to所属的函数中。原理上面已经提过了,下面看具体实现。

static int CoRoutineFunc( stCoRoutine_t *co,void * )
{
	if( co->pfn )
	{
		co->pfn( co->arg );
	}
	co->cEnd = 1;

	stCoRoutineEnv_t *env = co->env;

	co_yield_env( env );

	return 0;
}

    在看之前先说明一点在进入coctx_swap后,%rdi寄存器已经指向可第一个参数from,%rsi寄存器已经指向可第二个参数to,准备知识中已经提过相关寄存器的作用了,不懂得回头看相关内容。

   进入函数后栈帧情况如图(图片出自https://zhuanlan.zhihu.com/p/27409164):

   

下面我给相关汇编的每一句都加了注释。

    //片段1主要作用 即要切出协程上下文信息(相关的寄存器数据和栈帧数据)保存到coctx_t* from中
    //!!!!!!!!!!!!!!寄存器rdi ==> coctx_t* from  rsi==>- coctx_t* to!!!!!!!!!!!!!!!!
	  leaq (%rsp),%rax      //把rsp寄存器中的值存到rax寄存器中(这个值是一个地址也就是一个指针,记为char*addr1后面会用,指向是父函数栈帧栈顶的位置,也就是要切出协程返回的地址ret func addr )
    movq %rax, 104(%rdi)  //rdi寄存器保存第一个参数的地址即from,104(%rdi)即(char*(from) + 13 * sizeof(void*)),即regs[13],把rax寄存器中的值保存到regs[13]中,这里先把复函数的返回地址保存下来
    movq %rbx, 96(%rdi)   //把 rbx 寄存器中的值保存到regs[12]中
    movq %rcx, 88(%rdi)   //把 rcx 寄存器中的值保存到regs[11]中
    movq %rdx, 80(%rdi)   //把 rdx 寄存器中的值保存到regs[10]中
	movq 0(%rax), %rax    //0(%rax)即指针解引用上面提到的addr1 *(addr1 + 0),把rax寄存器指向的内容存到rax寄存器中,即返回函数的地址:ret func addr
	movq %rax, 72(%rdi)   //把 rax 寄存器中的值(ret func addr)保存到regs[9]中
    movq %rsi, 64(%rdi)   //把 rsi 寄存器中的值保存到regs[8]中
	movq %rdi, 56(%rdi)   //把 rdi 寄存器中的值保存到regs[7]中
    movq %rbp, 48(%rdi)   //把 rbp 寄存器中的值保存到regs[6]中
    movq %r8, 40(%rdi)    //把 r8  寄存器中的值保存到regs[5]中
    movq %r9, 32(%rdi)    //把 r9  寄存器中的值保存到regs[4]中
    movq %r12, 24(%rdi)   //把 r12 寄存器中的值保存到regs[3]中
    movq %r13, 16(%rdi)   //把 r13 寄存器中的值保存到regs[2]中
    movq %r14, 8(%rdi)    //把 r14 寄存器中的值保存到regs[1]中
    movq %r15, (%rdi)     //把 r15 寄存器中的值保存到regs[0]中
	xorq %rax, %rax       //把 rax 中的值异或rax的值并存到rax寄存器中(其实就是清0 ~^o^~)

    //片段2主要作用恢复要切入协程的上下文 把要切入协程栈数据(保存在coctx_t* to中)恢复到寄存器中和切入协程的栈帧中(其实就是把上面的汇编指令操作数交换下位置~^o^~)
    movq 48(%rsi), %rbp    //把to.regs[6]中的值即*(to.regs[6])(指向要切入协程栈帧的栈底位置,就是栈的起始位置)保存到 rbp 寄存器中(寄存器的功能就是用于标识当前栈帧的起始位置)
    movq 104(%rsi), %rsp   //把to.regs[13]中的值即*(to.regs[13])(不考虑字节对齐指向 to->ss_sp + to->ss_size - sizeof(void*);!!!!!!为什么指向这里下面解释!!!!!!)保存到rsp 寄存器中
    movq (%rsi), %r15      //把to.regs[0]中的值即*(to.regs[0])保存到 r15 寄存器中
    movq 8(%rsi), %r14     //把to.regs[1]中的值即*(to.regs[1])保存到 r14 寄存器中
    movq 16(%rsi), %r13    //把to.regs[2]中的值即*(to.regs[2])保存到 r13 寄存器中
    movq 24(%rsi), %r12    //把to.regs[3]中的值即*(to.regs[3])保存到 r12 寄存器中
    movq 32(%rsi), %r9     //把to.regs[4]中的值即*(to.regs[4])保存到 r9  寄存器中
    movq 40(%rsi), %r8     //把to.regs[5]中的值即*(to.regs[5])保存到 r8  寄存器中
    movq 56(%rsi), %rdi    //把to.regs[7]中的值即*(to.regs[7])保存到 rdi 寄存器中
    movq 80(%rsi), %rdx    //把to.regs[10]中的值即*(to.regs[10])保存到 rdx 寄存器中
    movq 88(%rsi), %rcx    //把to.regs[11]中的值即*(to.regs[11])保存到 rcx 寄存器中
    movq 96(%rsi), %rbx    //把to.regs[12]中的值即*(to.regs[12])保存到 rcx 寄存器中
	leaq 8(%rsp), %rsp     //把 rsp 中的地址向下移动8位,to->ss_sp + to->ss_size - sizeof(void*) + 8(__x86_64__ sizeof(void*) == 8)指向栈顶
	pushq 72(%rsi)         //把 to.regs[9]中的值入栈(to.regs[9]中此时指向的是要切入协程的执行函数CoRoutineFunc的地址!!!!!为什么指向这个函数下面说!!!!!!),堆栈地址由高到低增长入栈后rsp回到to->ss_sp + to->ss_size)- sizeof(void*)

    movq 64(%rsi), %rsi    //把to.regs[8]中的值即*(to.regs[8])保存到 rsi 寄存器中
	ret                      //ret 语句用来弹出栈的内容,并跳转到弹出的内容表示的地址处,而弹出的内容正好是上面pushq 72(%rsi)的值:CoRoutineFunc的地址,成功进入执行新协程

      可以看出,这里通过调整%rsp 的值来恢复新协程的栈,并利用了 ret 语句来实现修改指令寄存器 %rip 的目的,通过修改 %rip 来实现程序运行逻辑跳转。注意%rip 的值不能直接修改,只能通过 call 或 ret 之类的指令来间接修改。整体上看来,协程的切换其实就是cpu 寄存器内容特别是%rip 和 %rsp 的写入和恢复,因为cpu 的寄存器决定了程序从哪里执行(%rip) 和使用哪个地址作为堆栈 (%rsp)。寄存器的写入和恢复如下图所示(图片出自:https://zhuanlan.zhihu.com/p/27409164):

     执行完上图的流程,就将之前 cpu 寄存器的值保存到了协程A 的 regs[14] 中,而将协程B regs[14] 的内容写入到了寄存器中,从而使执行逻辑跳转到了 B 协程 regs[14] 中保存的返回地址处开始执行,即实现了协程的切换(从A 协程切换到了B协程执行)。

    接着解释下面注释中被!!!!标记的内容,因为我们要从from切换到to中from的信息就是当前函数栈的信息,如果to协程是第一次创建并执行,那么to的信息在进入to之前我们是没有的,所以我们要创建to的堆栈信息,并且创建相关的寄存器信息,当这些信息被恢复到寄存器中可以使cpu真正的跳转到to中去执行。具体的实现在coctx_make中。

int coctx_make(coctx_t* ctx, coctx_pfn_t pfn, const void* s, const void* s1) {
  /*ctx->ss_sp 对应的空间是在堆上分配的,地址是从低到高的增长,而堆栈是由高到低增长的
   *所以要使用这一块人为改变的栈帧区域,首先地址要调到最高位,即ss_sp + ss_size的位置
   *为什么sp指针要往前移动 sizeof(void*),空出来sizeof(void*)个位置?(问题1)
   *这里我看了老半天才看懂〒_〒,下面会回答这个问题
  */
  char* sp = ctx->ss_sp + ctx->ss_size - sizeof(void*);
  /**
   * -16除符号位的低位是10000,(sp二进制) & 10000会让sp前移动 (16 - (ctx->ss_size % 16))个字节,从而达到 16字节对齐的效果
   * 注意这里是向前移动而不是向后移动,就是减少多出的内存空间来进行字节对齐
   */
  sp = (char*)((unsigned long)sp & -16LL); 

  memset(ctx->regs, 0, sizeof(ctx->regs));
  /**
   * 看后面有一句ctx->regs[kRSP] = sp 而ctx->regs[kRSP]在进行协切换的时候是会被写入rsp 堆栈指针寄存器中的,所以sp指针本身就代表了rsp 是堆栈指针寄存器的值
   * void** ret_addr = (void**)(sp); 把(char*sp)强转为(void**) 然后 *ret_addr = (void*)pfn; 这里的目的是把sp转为双层指针即指向指针的指针,然后改指针的
   * 指针指向的内容为pfn,然后把ctx->regs[kRSP] = sp,当进行协程切换的时候把ctx->regs[kRSP]写入rsp寄存器中,pfn就是我们即将切入的协程调用函数,
   * 然后说刚开始的问题
   */
  /*
   *(问题1)回答:*ret_addr = (void*)pfn;这里我们要在栈的末尾保存pfn的指针,需要sizeof(void*)大小的空间,所以sp要向前移动空出空间来
   */
  void** ret_addr = (void**)(sp);
  *ret_addr = (void*)pfn;
  ctx->regs[kRSP] = sp;   //此时sp指针指向了pfn,即*sp == pfn

  ctx->regs[kRETAddr] = (char*)pfn; //保存pfn的地址

  ctx->regs[kRDI] = (char*)s;  //保存pfn的参数1地址到 ctx->regs[kRDI]中对应rdi寄存器(该寄存器用来保存第一个参数的地址),这个参数很重要决定了要执行的协程
  ctx->regs[kRSI] = (char*)s1; //保存pfn的参数1地址ctx->regs[kRSI]中对应rsi寄存器(该寄存器用来保存第二个参数的地址),这个参数目前好像没用到因为pfn只有一个参数
  return 0;
}

    在这里我们可以看到ctx->regs[kRSP] = sp,让%rsp指向了ctx->ss_sp + ctx->ss_size - sizeof(void*),并且ctx->regs[kRETAddr] = (char*)pfn,成功的保存了协程的执行函数,也就是我们上面恢复堆栈信息的时候为什么指向这里的原因了。

    接着我们回头再看这个函数

static int CoRoutineFunc( stCoRoutine_t *co,void * )
{
	if( co->pfn )
	{
		co->pfn( co->arg );
	}
	co->cEnd = 1;

	stCoRoutineEnv_t *env = co->env;

	co_yield_env( env );

	return 0;
}

     当设置了跳转函数切换到次函数中时,因为ctx->regs[kRDI] = (char*)s,s就是我们要切入的协程的相关数据,他作为第一个参数被放到了rdi寄存器对应的数组中,第二个实际上没什么用不过也被放到了rsi对应的数组中,所以就co->pfn( co->arg );就会执行我们的要切入的协程函数(pfn保存了我们创建协程时候的运行函数)。当我们不是第一次切入某个协程的时候,比如协程a切到b,a切到c,c又切到b,c切到b的时候,libco会找到之前创建的b相关的stCoRoutine_t 结构(这里我们就拿出来分析了,有兴趣乐意看libco源码解读专栏),取出他的coctx_t函数栈数据调用coctx_swap来实现。所以协程切换的核心功能就是coctx_swap这个函数的实现。

四 libco的问题

 1 在libco老版本的实现中有一个bug:https://github.com/Tencent/libco/issues/90

bug 1: ABI规范中规定用户空间程序的栈指针必须时刻指到运行栈的栈顶,而coctx_swap.S中却使用栈指针直接对位于堆中的数据结构进行寻址内存操作,这违反了ABI约定。

By default, the signal handler is invoked on the normal process stack. It is possible to arrange that the signal handler uses an alternate stack; see sigalstack(2) for a discussion of how to do this and when it might be useful.

— man 7 signal : Signal dispositions

当coctx_swap正在用栈指针对位于堆中的数据结构进行寻址内存操作时,若此时执行线程收到了一个信号,接着内核抢占了该执行线程并开始准备接下来用户空间线程的信号处理执行环境,由于在默认情况下,内核将会选择主栈作为信号处理函数的执行栈,但此时栈已经被指向了堆中(用户空间的程序违反ABI约定在先),那么信号处理函数的执行栈就会被错误的放置到堆中,这样,堆中的数据结构在接下来就极有可能会被破坏。

    老版本的实现coctx_swap.S,上面图片的来源的文章中分析的版本也是这个有bug的版本。

	leaq 8(%rsp),%rax
	leaq 112(%rdi),%rsp
	pushq %rax
	pushq %rbx
	pushq %rcx
	pushq %rdx

	pushq -8(%rax) //ret func addr

	pushq %rsi
	pushq %rdi
	pushq %rbp
	pushq %r8
	pushq %r9
	pushq %r12
	pushq %r13
	pushq %r14
	pushq %r15
	
	movq %rsi, %rsp
	popq %r15
	popq %r14
	popq %r13
	popq %r12
	popq %r9
	popq %r8
	popq %rbp
	popq %rdi
	popq %rsi
	popq %rax //ret func addr
	popq %rdx
	popq %rcx
	popq %rbx
	popq %rsp
	pushq %rax
	
	xorl %eax, %eax
	ret

      关于这个问题刚开始我非常困惑,我看了两天才弄明白其中的原因。

      首先bug描述:“当coctx_swap正在用栈指针对位于堆中的数据结构进行寻址内存操作时,若此时执行线程收到了一个信号,接着内核抢占了该执行线程并开始准备接下来用户空间线程的信号处理执行环境,由于在默认情况下,内核将会选择主栈作为信号处理函数的执行栈,但此时栈已经被指向了堆中(用户空间的程序违反ABI约定在先),那么信号处理函数的执行栈就会被错误的放置到堆中,这样,堆中的数据结构在接下来就极有可能会被破坏。”
      问题的关键在于主栈因为默认情况下,内核将会选择主栈作为信号处理函数的执行栈,而我们在创建协程的时候协程有可能也是从主栈切出的,也就是说有可能出现coctx_swap( mainco(主栈),coctx_t*to )的调用,而恰恰coctx_swap中汇编的第一个片断就是把要切出的栈保存自定义的结构中,此时在有bug的branch中通过 leaq 112(%rdi),%rsp把主栈的rsp指针指向了(就是我们自定义的结构regs[ 13 ]),而push指令会把相关的寄存器rsp指向的地址然后向下移动rsp指针(函数的堆栈由高到底生长),所以就通过push指令不停的向下移动rsp把寄存器中的数据存入到regs[ 13 ]<==>regs[ 0]中,达到把要切出的栈保存自定义的结构中的目的。但是按照规范这种操作是不允许的。

     所以如果我的理解是正确的话,实际上如果我们不在主栈中往其他的协程切是不会有这个问题的,并且最新版本改成movq后就不再存在这个问题了,因为保存from栈帧数据的时候不再把rsp指向堆内存,而是通过movq直接操作寄存器rdi来实现的(rdi指向coctx_swap第二个参数to的地址)。相关连接 https://github.com/Tencent/libco/issues/90#issuecomment-725465690

BUG 2: x87与MXCSR控制字的保存与恢复违反Sys V ABI约定
     在Intel386及AMD64 Sys V ABI约定中,x87与MXCSR控制字是“callee saved”的,但是coctx_swap中并没有相关的保存与恢复实现。根据libaco的性能基准测试报告得到的结论,在上下文切换代码中增加对x87与MXCSR控制字的保存与恢复只会给切换汇编带来约0.87%的性能损耗。

     我们之前提到,coroutine 可以使用ucontext 来实现用户态的上下文切换,这也是实现协程的关键。而 libco 基于性能优化的考虑,没有使用 ucontext,而是自行编写了一套汇编来处理上下文的切换。libco 的上下文切换大体只保存和交换了两类东西:
寄存器:函数参数类寄存器、函数返回值、数据存储类寄存器等。栈:rsp 栈顶指针相比于 ucontext,缺少了浮点数上下文和 sigmask(信号屏蔽掩码)。具体可对比 glibc 的相关源码。取消 sigmask 是因为 sigmask 会引发一次 syscall,在性能上会所损耗。
取消浮点数上下文,主要是在服务端编程几乎用不到浮点数计算。此外,libco 的上下文切换只支持 x86,不支持其他架构的 cpu,这是因为在服务端也几乎都是 x86 架构的,不用太考虑 CPU 的通用性。

     总体上看除了上面提到的2处bug和CPU 的通用性不高外,libco是完全可以用于生产环境中的,但是要注意,如果你不熟悉库本身的底层实现机制和原理很容易掉坑里,我们平时用的线程间同步,线程间通信机制都不能再用,要做好协程间通讯、协程同步,不是很容易,并且libco只hook了部分系统函数的调用,hook机制也不是很完善,最重要的一点libco本身没有实现多线程中协程的调用策略,如果我们要在多线程中使用它需要我们自己实现多线程环境下的协程调度,我感觉微信自己用libgo的时候应该跟自己的项目有些耦合代码(他们用了协程池),比如多线程下协程的管理和调度,内存管理等,这部分应该没有开源放出。所以如果不是非常的熟悉libco或者有相关的开发经验,如果要把libco在开发环境中需要谨慎一点。

    最后,我的建议是用魅族开源协程库libgo替换libco(仅限c++项目,估计写c项目也用不到协程),两者的协程切换的原理都是一样的,但是libgo支持的平台更多,可移植性更好,另外实现了多线程下的协程调度,总的来说在协程调度,协程管理,hook,协程同步通信等方面都给出了比较完善的解决方案,可以直接使用,比libco更加快捷和安全,使用起来稳定性也更高,不会有很多的坑。


五 相关链接

  1 协程库boost.coroutine2、魅族libgo、腾讯libco、ibaco的比较:协程库boost.coroutine2、魅族libgo、腾讯libco、开源libaco的比较
  2 libco协程库上下文切换原理详解:libco协程库上下文切换原理详解
  3 微信libco协程库源码分析:微信 libco 协程库源码分析 or 微信libco协程库源码分析
  4 libaco: 高性能协程与网络编程libaco: 高性能协程与网络编程
  5 libco 源码解读:https://zhuanlan.zhihu.com/p/40207313

  

 

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值