libco协程库上下文切换原理详解

缘起

libco 协程库在单个线程中实现了多个协程的创建和切换。按照我们通常的编程思路,单个线程中的程序执行流程通常是顺序的,调用函数同样也是 “调用——返回”,每次都是从函数的入口处开始执行。而libco 中的协程却实现了函数执行到一半时,切出此协程,之后可以回到函数切出的位置继续执行,即函数的执行可以被“拦腰斩断”,这种在函数任意位置 “切出——恢复” 的功能是如何实现的呢? 本文从libco 代码层面对协程的切换进行了剖析,希望能让初次接触 libco 的同学能快速了解其背后的运行机理。

函数调用与协程切换的区别

下面的程序是我们常规调用函数的方法:

void A() {

cout << 1 << " ";

cout << 2 << " ";

cout << 3 << " ";

}

void B() {

cout << "x" << " ";

cout << "y" << " ";

cout << "z" << " ";

}

int main(void) {

A();

B();

}

在单线程中,上述函数的输出为:

1 2 3 x y z

如果我们用 libco 库将上面程序改造一下:

void A() {

cout << 1 << " ";

cout << 2 << " ";

co_yield_ct();  // 切出到主协程

cout << 3 << " ";

}

void B() {

cout << "x" << " ";

co_yield_ct();  // 切出到主协程

cout << "y" << " ";

cout << "z" << " ";

}

int main(void) {

...  // 主协程

co_resume(A);  // 启动协程 A

co_resume(B);  // 启动协程 B

co_resume(A);  // 从协程 A 切出处继续执行

co_resume(B);  // 从协程 B 切出处继续执行

}

同样在单线程中,改造后的程序输出如下:

1 2 x 3 y z

可以看出,切出操作是由 co_yield_ct() 函数实现的,而协程的启动和恢复是由 co_resume 实现的。函数 A() 和 B() 并不是一个执行完才执行另一个,而是产生了 “交叉执行“ 的效果,那么,在单个线程中,这种 ”交叉执行“,是如何实现的呢?

Read the f**king source code!

Talk is cheap, show me code.

下面我们就深入 libco 的代码来看一下,协程的切换是如何实现的。通过分析代码看到,无论是 co_yield_ct() 还是 co_resume,在协程切出和恢复时,都调用了同一个函数co_swap,在这个函数中调用了 coctx_swap 来实现协程的切换,这一函数的原型是:

void coctx_swap( coctx_t *,coctx_t* ) asm("coctx_swap");

两个参数都是 coctx_t *指针类型,其中第一个参数表示要切出的协程,第二个参数表示切出后要进入的协程。

在上篇文章 “x86-64 下函数调用及栈帧原理” 中已经指出,调用子函数时,父函数会把两个调用参数放入了寄存器中,并且把返回地址压入了栈中。即在进入 coctx_swap 时,第一个参数值已经放到了 %rdi 寄存器中,第二个参数值已经放到了 %rsi 寄存器中,并且栈指针 %rsp 指向的位置即栈顶中存储的是父函数的返回地址。进入 coctx_swap 后,堆栈的状态如下:

<img src="https://pic1.zhimg.com/v2-cfc041baaed11e6e8e97953f68b01e98_b.png" data-rawwidth="1113" data-rawheight="970" class="origin_image zh-lightbox-thumb" width="1113" data-original="https://pic1.zhimg.com/v2-cfc041baaed11e6e8e97953f68b01e98_r.jpg">

由于coctx_swap 是在 co_swap() 函数中调用的,下面所提及的协程的返回地址就是 co_swap() 中调用 coctx_swap() 之后下一条指令的地址:

void co_swap(stCoRoutine_t* curr, stCoRoutine_t* pending_co) {

....

// 从本协程切出

coctx_swap(&(curr->ctx),&(pending_co->ctx) );

// 此处是返回地址,即协程恢复时开始执行的位置

stCoRoutineEnv_t* curr_env = co_get_curr_thread_env();

....

}

coctx_swap 函数是用汇编实现的,我们这里只关注 x86-64 相关的部分,其代码如下:

coctx_swap:

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

可以看出,coctx_swap 中并未像常规被调用函数一样创立新的栈帧。先看前两条语句:

leaq 8(%rsp),%rax

leaq 112(%rdi),%rsp

leaq 用于把其第一个参数的值赋值给第二个寄存器参数。第一条语句用来把 8(%rsp) 的本身的值存入到 %rax 中,注意这里使用的并不是 8(%rsp) 指向的值,而是把 8(%rsp) 表示的地址赋值给了 %rax。这一地址是父函数栈帧中除返回地址外栈帧顶的位置。

在第二条语句 leaq 112(%rdi), %rsp 中,%rdi 存放的是coctx_swap 第一个参数的值,这一参数是指向 coctx_t 类型的指针,表示当前要切出的协程,这一类型的定义如下:

struct coctx_t {

void *regs[ 14 ];

size_t ss_size;

char *ss_sp;

};

因而 112(%rdi) 表示的就是第一个协程的 coctx_t 中 regs[14] 数组的下一个64位地址。而接下来的语句:

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

第一条语句 pushq %rax 用于把 %rax 的值放入到 regs[13] 中,resg[13] 用来存储第一个协程的 %rsp 的值。这时 %rax 中的值是第一个协程 coctx_swap 父函数栈帧除返回地址外栈帧顶的地址。由于 regs[] 中有单独的元素存储返回地址,栈中再保存返回地址是无意义的,因而把父栈帧中除返回地址外的栈帧顶作为要保存的 %rsp 值是合理的。当协程恢复时,把保存的 regs[13] 的值赋值给 %rsp 即可恢复本协程 coctx_swap 父函数堆栈指针的位置。第一条语句之后的语句就是用pushq 把各CPU 寄存器的值依次从 regs 尾部向前压入。即通过调整%rsp 把 regs[14] 当作堆栈,然后利用 pushq 把寄存器的值和返回地址存储到 regs[14] 整个数组中。regs[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 |

接下来的汇编语句:

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

这里用的方法还是通过改变%rsp 的值,把某块内存当作栈来使用。第一句 movq %rsi, %rsp 就是让%rsp 指向 coctx_swap 第二个参数,这一参数表示要进入的协程。而第二个参数也是coctx_t 类型的指针,即执行完 movq 语句后,%rsp 指向了第二个参数 coctx_t 中 regs[0],而之后的pop 语句就是用 regs[0-13] 中的值填充cpu 的寄存器,这里需要注意的是popq 会使得 %rsp 的值增加而不是减少,这一点保证了会从 regs[0] 到regs[13] 依次弹出到 cpu 寄存器中。在执行完最后一句 popq %rsp 后,%rsp 已经指向了新协程要恢复的栈指针(即新协程之前调用 coctx_swap 时父函数的栈帧顶指针),由于每个协程都有一个自己的栈空间,可以认为这一语句使得%rsp 指向了要进入协程的栈空间。

coctx_swap 中最后三条语句如下:

pushq %rax

xorl %eax, %eax

ret

pushq %rax 用来把 %rax 的值压入到新协程的栈中,这时 %rax 是要进入的目标协程的返回地址,即要恢复的执行点。然后用 xorl 把 %rax 低32位清0以实现地址对齐。最后ret 语句用来弹出栈的内容,并跳转到弹出的内容表示的地址处,而弹出的内容正好是上面 pushq %rax 时压入的 %rax 的值,即之前保存的此协程的返回地址。即最后这三条语句实现了转移到新协程返回地址处执行,从而完成了两个协程的切换。可以看出,这里通过调整%rsp 的值来恢复新协程的栈,并利用了 ret 语句来实现修改指令寄存器 %rip 的目的,通过修改 %rip 来实现程序运行逻辑跳转。注意%rip 的值不能直接修改,只能通过 call 或 ret 之类的指令来间接修改。

整体上看来,协程的切换其实就是cpu 寄存器内容特别是%rip 和 %rsp 的写入和恢复,因为cpu 的寄存器决定了程序从哪里执行(%rip) 和使用哪个地址作为堆栈 (%rsp)。寄存器的写入和恢复如下图所示:

<img src="https://pic2.zhimg.com/v2-9d61b78ba678726a26082226e8e1f175_b.png" data-rawwidth="843" data-rawheight="535" class="origin_image zh-lightbox-thumb" width="843" data-original="https://pic2.zhimg.com/v2-9d61b78ba678726a26082226e8e1f175_r.jpg">

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

结语

为实现单线程中协程的切换,libco 使用汇编直接读写了 cpu 的寄存器。由于通常我们在高级语言层面很少接触上下文切换的情形,因而会觉得在单线程中切换上下文的方法会十分复杂,但当我们对代码抽丝剥茧后,发现其实现机理也是很容易理解的。从libco 上下文切换中可以看出,用汇编与 cpu 硬件寄存器配合竟然可以设计出如此神奇的功能,不得不惊叹于 cpu 硬件设计的精妙。

libco 库的说明中提及这种上下文切换的方法取自 glibc,看来基础库中隐藏了不少 “屠龙之技”。

看来,想要提高编程技能,无他,Read the f**king source code !

The End.

https://zhuanlan.zhihu.com/p/27409164

原文地址:https://www.cnblogs.com/feng9exe/p/10479142.html

时间: 2024-08-09 13:19:18

libco协程库上下文切换原理详解的相关文章

C高级 跨平台协程库

1.0 协程库引言 协程对于上层语言还是比较常见的. 例如C# 中 yield retrun, lua 中 coroutine.yield 等来构建同步并发的程序. 本文就是探讨如何从底层实现开发级别的协程库. 在说协程之前, 简单温故一下进程和纤程关系. 进程拥有一个完整的虚拟地址空间,不依赖于线程而独立存在. 线程是进程的一部分,没有自己的地址空间, 与进程内的其他线程一起共享分配给该进程的所有资源.进程和纤程是1对多关系, 协程同线程关系也是类似. 一个线程中可以有多个协程. 协程同线程相

协程库st(state threads library)原理解析

协程库state threads library(以下简称st)是一个基于setjmp/longjmp实现的C语言版用户线程库或协程库(user level thread).基本介绍在这 http://state-threads.sourceforge.net/docs/st.html.这里有一个基本的协程例子 http://www.csl.mtu.edu/cs4411.ck/www/NOTES/non-local-goto/coroutine.html, 可以了解setjmp和longjmp的

Java虚拟机工作原理详解

原文地址:http://blog.csdn.net/bingduanlbd/article/details/8363734 一.类加载器 首先来看一下java程序的执行过程. 从这个框图很容易大体上了解java程序工作原理.首先,你写好java代码,保存到硬盘当中.然后你在命令行中输入 [java] view plaincopy javac YourClassName.java 此时,你的java代码就被编译成字节码(.class).如果你是在Eclipse IDE或者其他开发工具中,你保存代码

Linux下FFMPEG--H264--编码&&解码的C实现与相关原理详解

FFMPEG是很强大的一套视频音频处理库,不过,强大的功能一般免不了复杂的实现,或者更加现实地说,"麻烦"的部署和使用流程 关于"FFMPEG怎么部署"这事就放在另一篇文章啦,下面入正题.. 编码encoder模块和解码decoder模块都有init初始化方法和资源free方法 init初始化方法主要是进行ffmpeg所必需的编解码器的初始化和部分功能方法的参数配置,而free资源释放方法则是相应地进行必要的回收 Encoder模块的实现和细节分析 #include

LAMP平台搭建及其原理详解

LAMP平台搭建及其原理详解 LAMP平台搭建基础概念 LAMP:提到LAMP很多人会认为LAMP是Linux ,Apache,Mysql,PHP.但是随着技术的不断发展,当今的Lamp,已经不仅仅是这么简单了,这里我们的P除了PHP其实还包括:phython,perl    .而M也不仅仅指的是mysql,也包括mariadb. LAMP平台顾名思义就是Linux,apache,mysql(mariadb),php(phython,perl)的结合.按照他们的结合方式不同,大致可以分成三类:

ucontext-人人都可以实现的简单协程库

1.干货写在前面 协程是一种用户态的轻量级线程.本篇主要研究协程的C/C++的实现. 首先我们可以看看有哪些语言已经具备协程语义: 比较重量级的有C#.erlang.golang* 轻量级有python.lua.javascript.ruby 还有函数式的scala.scheme等. c/c++不直接支持协程语义,但有不少开源的协程库,如: Protothreads:一个"蝇量级" C 语言协程库 libco:来自腾讯的开源协程库libco介绍,官网 coroutine:云风的一个C语

协程库的一些笔记

因为协程的好处,所以协程库现在有好多libtask,boost::coroutine,libco...... libtask很不错,以后或许会用.  boost我个人基本很少用. 腾讯的libco自己用汇编实现了swapcontext函数,不明觉厉(libtask也有ASM).而且把epoll整合在了里面. 微信后台就用到了它.在chinaunix.net上的一个帖子中就说到了这个.不过暂不适合我. 还好云风实现过一个coroutine库,用的context系列函数,很精巧的封装,代码量很小,

(转)MySQL备份原理详解

MySQL备份原理详解 原文:http://www.cnblogs.com/cchust/p/5452557.html 备份是数据安全的最后一道防线,对于任何数据丢失的场景,备份虽然不一定能恢复百分之百的数据(取决于备份周期),但至少能将损失降到最低.衡量备份恢复有两个重要的指标:恢复点目标(RPO)和恢复时间目标(RTO),前者重点关注能恢复到什么程度,而后者则重点关注恢复需要多长时间.这篇文章主要讨论MySQL的备份方案,重点介绍几种备份方式的原理,包括文件系统快照(LVM),逻辑备份工具M

android消息机制原理详解

android消息机制原理详解 因为之前使用的是CSDN默认的文本编辑器,而且也因为懒得学用MarkDown来写博客,所以排版上有一些问题.就上一篇写的设计模式之抽象工厂模式提出了这个问题(一个android群的群友提出来的,没有在评论里评论),所以以后的文章都用MarkDown来写了. 好了,言归正传,这篇文章我来介绍一下android消息机制的原理 Android消息机制概述 说到Android的消息机制,Android初级工程师(不包括那些初学者)肯定会想到Handler.是的,Andro