协程切换和线程切换

先说结论:协程切换比线程切换快主要有两点:

(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个寄存器当前的值。

四、协程切换

有了协程上下文之后,协程切换就很容易理解了。协程切换只需要两步就够了:

  1. 保存当前寄存器的值到协程上下文中的regs数组;
  2. 将新协程上下文的regs数组中值取出来赋值给对应的寄存器

完成这两步后,就切换到新的协程的函数栈上了。接下来就会在新的协程中运行了。

参考:协程篇(二)-- 协程切换篇 - 知乎

为什么协程切换的代价比线程切换低? - 知乎

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值