先说结论:协程切换比线程切换快主要有两点:
(1)协程切换完全在用户空间进行,线程切换涉及特权模式切换,需要在内核空间完成;(2)协程切换相比线程切换做的事情更少。
协程切换只涉及基本的CPU上下文切换,所谓的 CPU 上下文,就是一堆寄存器,里面保存了 CPU运行任务所需要的信息:从哪里开始运行(%rip:指令指针寄存器,标识 CPU 运行的下一条指令),栈顶的位置(%rsp: 是堆栈指针寄存器,通常会指向栈顶位置),当前栈帧在哪(%rbp 是栈帧指针,用于标识当前栈帧的起始位置)以及其它的CPU的中间状态或者结果(%rbx,%r12,%r13,%14,%15 等等)。协程切换非常简单,就是把当前协程的 CPU 寄存器状态保存起来,然后将需要切换进来的协程的 CPU 寄存器状态加载的 CPU 寄存器上就 ok 了。而且完全在用户态进行,一般来说一次协程上下文切换最多就是几十ns 这个量级。
一、函数栈
我们知道,在函数执行过程中,其实就是在一块栈空间内运行罢了。这个栈空间由rsp、rbp(寄存器)指针来指定两端范围。函数中的局部变量的都会存放在栈中某一块内存中。
函数调用无非是从一个函数栈跳转到相邻另一个函数栈罢了,只是由于调用返回后还需恢复原函数栈的状态,因此必须在调用时通过寄存器和栈空间的配合来存储一些数据,方便调用完成后恢复。
会通过一个例子来详细的说明函数调用的过程。
首先是一个非常简单的C文件
- int add(int a, int b) {
- return a+b;
- }
-
- int main() {
- int a = 1;
- int b = 2;
- int x = add(a, b);
- return 0;
- }
在64位的Linux系统下,通过gcc编译器将其编译为汇编文件。
- gcc -S add.c -o add.S
- // -S参数说明只编译、不汇编。因此生成的文件就是汇编代码
去除掉一些编译器附加的额外信息,保留主要的汇编代码,并手动添加注释后如下:
- add:
- pushq %rbp ; 将main函数的rbp所存值压入栈
- movq %rsp, %rbp ; rsp的值赋值给rbp
- movl %edi, -4(%rbp) ; 取第1个参数
- movl %esi, -8(%rbp) ; 取第二个参数
- movl -8(%rbp), %eax
- movl -4(%rbp), %edx
- addl %edx, %eax
- popq %rbp ; 栈中弹出main函数栈的rbp地址,赋值给rbp寄存器,即恢复main函数的rbp
- ret ; 把main函数的返回地址赋值给rip寄存器,下一步CPU会调到该地址的指令执行
-
- main:
- pushq %rbp ; main上级函数rbp指针值压入栈
- movq %rsp, %rbp ; 把rsp寄存器值赋值给rbp寄存器
- subq $16, %rsp ; rsp指针下移16,为main函数栈预留空间
- movl $1, -4(%rbp) ; 为变量a赋值1
- movl $2, -8(%rbp) ; 为变量b赋值2
- movl -8(%rbp), %edx
- movl -4(%rbp), %eax
- movl %edx, %esi ; 变量b赋值给esi寄存器,即第2个参数
- movl %eax, %edi ; 变量a赋值给edi寄存器,即第二个参数
- call add ; 调用add函数,此步骤会将main函数的返回地址压入栈,然后将add函数入口地址赋值给rip寄存器,下一步CPU就会执行到add函数
- movl %eax, -12(%rbp) ; add函数返回后,结果存放在rax寄存器中,将其赋值给变量x
- movl $0, %eax ; rax清零
- leave
- ret
二、什么是协程?协程与线程的关系是什么?
提到协程就不得不提线程这个概念。我们都知道线程其实就是一段子程序,一个进程就是由多个线程来组成(类似将一个任务分割为多个子任务)。由于操作系统调度的最小单位是线程,当操作系统调度到某个线程时,去执行这段子程序就行了。
然而线程的执行也不是一帆风顺的,当线程执行过程中发生了阻塞(这里主要是阻塞IO操作),那么这个线程就会一直休眠直到条件就绪才会被重新调度执行。
那有没有办法让线程不休眠呢?
可以!我们假设存在一个任务池,里面包括很多个小任务。而线程就是去执行任务池中的某几个任务,当线程在执行其中某个任务时过程中发现IO条件未就绪时,该线程可以主动跳转去执行其他的任务;而当IO条件就绪后,线程又会跳回到之前的任务继续执行。这样线程就不因为阻塞在IO上而休眠了。而这些小任务就被称为协程。
当然,从以上过程可以看出,在单个线程中,协程一定是串行执行的,不可能存在一个线程同时在执行多个协程的情况。
线程的切换是操作系统实现的,此操作是会陷入内核态的,这无疑造成了一定程度的消耗。当程序中出现了大量的线程切换时,这对系统的损耗是难以接受的。因此线程的数量需要严格控制。
而协程正好也恰好可以解决这些问题。协程可以看作是轻量级的线程,也是用户态的线程。
用户态是指协程的切换完全是在用户态进行的,而不会陷入内核。协程的调度完全取决于用户的实现方式,而与操作系统的调度无关。
轻量级是指协程通常只需要一个小空间的栈就足够了,比如128k空间就足够了。因此,相比对线程而言,在操作系统中可以创建更多的协程用于多任务处理。
而且协程的切换是在用户态的,不会陷入内核,因此协程切换的开销也是相当小的。后面会讲到,协程的切换无非是改变几个寄存器的值即可。
正是因为协程的这种特性,使得协程更加适用于IO密集型的任务中,因为IO通常会伴随着大量的阻塞等待过程,而使用协程就可以在IO阻塞的同时让出CPU,而当IO就绪后再主动抢占CPU即可。
现如今,很多语言都实现了自己的协程库,如GO语言的Goroutine、C++20推出的无栈协程等等。
三、协程上下文
前面也说了协程不过是一段子程序(其实也就是个函数)罢了,因此只要保存下当前的函数栈状态、寄存器值,就可以描述出这个协程的全部状态,这个结构被称为协程上下文。
协程上下文其实就是保存了当前所有寄存器的值,这些寄存器描述了当前的函数栈,程序执行状态等信息。
- struct coctx_t
- {
- void *regs[ 14 ]; // 一个数组,保存了14个寄存器的值
- size_t ss_size; // 协程栈大小
- char *ss_sp; // 协程栈指针
- };
可以看到,在coctx_t这个结构体中。ss_size和ss_sp则描述了协程的栈空间大小。regs是一个数组,保存着14个寄存器当前的值。
四、协程切换
有了协程上下文之后,协程切换就很容易理解了。协程切换只需要两步就够了:
- 保存当前寄存器的值到协程上下文中的regs数组;
- 将新协程上下文的regs数组中值取出来赋值给对应的寄存器
完成这两步后,就切换到新的协程的函数栈上了。接下来就会在新的协程中运行了。