高并发与协程

高并发与协程

普通的函数

首先以一个普通的函数为例,这个函数非常简单:

def func():
   print("a")
   print("b")
   print("c")

这是一个简单的普通函数,当我们调用这个函数时会发生什么?

  • 1)调用func;
  • 2)func开始执行,直到return;
  • 3)func执行完成,返回函数A。

函数func执行直到返回,并打印出:

a
b
c

注意这段代码是用python写的,但本篇关于协程的讨论适用于任何一门语言,因为协程并不是某种语言特有的。而我们只不过恰好使用了python来用作示例,因其足够简单。

从普通函数到协程

接下来,我们就要从普通函数过渡到协程了。和普通函数只有一个返回点不同,协程可以有多个返回点。
就像下面的示例:

void func() {
  print("a")
  暂停并返回
  print("b")
  暂停并返回
  print("c")
}

普通函数下,只有当执行完print(“c”)这句话后函数才会返回,但是在协程下当执行完print(“a”)后func就会因“暂停并返回”这段代码返回到调用函数。
当然有人可能会说写一个return也能返回,就像这样

void func() {
  print("a")
  return
  print("b")
  暂停并返回
  print("c")
}

直接写一个return语句确实也能返回,但这样写的话return后面的代码都不会被执行到了。

协程的特点在当我们从协程返回后还能继续调用该协程,并且是从该协程的上一个返回点后继续执行。

需要注意的是:当普通函数返回后,进程的地址空间中不会再保存该函数运行时的任何信息,而协程返回后,函数的运行时信息是需要保存下来的。

图形化解释

下面我们通过图形的方式再分析一下协程的工作流程

在这里插入图片描述

在该图中:方框内表示该函数的指令序列,如果该函数不调用任何其它函数,那么应该从上到下依次执行,但函数中可以调用其它函数,因此其执行并不是简单的从上到下,箭头线表示执行流的方向。

从上图中我们可以看到:我们首先来到funcA函数,执行一段时间后发现调用了另一个函数funcB,这时控制转移到该函数,执行完成后回到main函数的调用点继续执行。这是普通的函数调用。

接下来是协程:
在这里插入图片描述

在这里:我们依然首先在funcA函数中执行,运行一段时间后调用协程,协程开始执行,直到第一个挂起点,此后就像普通函数一样返回funcA函数,funcA函数执行一些代码后再次调用该协程。

注意:协程这时就和普通函数不一样了,协程并不是从第一条指令开始执行而是从上一次的挂起点开始执行,执行一段时间后遇到第二个挂起点,这时协程再次像普通函数一样返回funcA函数,funcA函数执行一段时间后整个程序结束。

在这里插入图片描述

和普通函数不同的是,协程能知道自己上一次执行到了哪里。

现在应该明白了,协程会在函数被暂停运行时保存函数的运行状态,并可以从保存的状态中恢复并继续运行。

仔细想一想,协程其实与操作系统对线程的调度相似,线程也可以被暂停,操作系统保存线程运行状态然后去调度其它线程,此后该线程再次被分配CPU时还可以继续运行,就像没有被暂停过一样。只不过线程的调度是操作系统实现的,这些对程序员都不可见,而协程是在用户态实现的,对程序员可见。这就是为什么有的人说可以把协程理解为用户态线程的原因。
也就是说我们可以自己控制协程在什么时候运行,什么时候暂停,协程的调度权在你自己手上。

协程的实现原理

让我们从问题的本质出发来思考这个问题:协程的本质是什么呢?

其实就是可以被暂停以及可以被恢复运行的函数。那么可以被暂停以及可以被恢复意味着什么呢?

协程之所以可以被暂停也可以继续,那么一定要记录下被暂停时的状态,也就是上下文,当继续运行的时候要恢复其上下文(状态)另外:函数运行时所有的状态信息都位于函数运行时栈中。

函数运行时栈就是我们需要保存的状态,也就是所谓的上下文。
如图所示:

在这里插入图片描述

从上图中我们可以看出:该进程中只有一个线程,栈区中有四个栈帧,main函数调用A函数,A函数调用B函数,B函数调用C函数,当C函数在运行时整个进程的状态就如图所示。

既然函数的运行时状态保存在栈区的栈帧中,那么如果我们想暂停协程的运行就必须保存整个栈帧的数据,那么我们该将整个栈帧中的数据保存在哪里呢?

我们需要将整个栈帧的数据保存在整个进程的内存区中可以长时间(进程生命周期)存储数据的区域,即堆区。我们需要做的就是:在堆区中申请一段空间,让后把协程的整个栈区保存下,当需要恢复协程的运行时再从堆区中copy出来恢复函数运行时状态。
当然可能有人会想,为什么我们要这么麻烦的来回copy数据呢?

实际上:我们需要做的是直接把协程的运行需要的栈帧空间直接开辟在堆区中,这样都不用来回copy数据了

在这里插入图片描述

从上图中我们可以看到:该程序中开启了两个协程,这两个协程的栈区都是在堆上分配的,这样我们就可以随时中断或者恢复协程的执行了。

有的同学可能会问,那么进程地址空间最上层的栈区现在的作用是什么呢?

答案是:这一区域依然是用来保存函数栈帧的,只不过这些函数并不是运行在协程而是普通线程中的。
在上图中实际上共有3个执行流:

1)一个普通线程;
2)两个协程。
虽然有3个执行流但我们仅创建了一个线程,

这就是为什么我们为什么要使用协程:使用协程理论上我们可以开启无数并发执行流,只要堆区空间足够,同时还没有创建线程的开销,所有协程的调度、切换都发生在用户态,这就是为什么协程也被称作用户态线程的原因所在。
因此:即使你创建了N多协程,但在操作系统看来依然只有一个线程,也就是说协程对操作系统来说是不可见的。

总结

协程是比线程更小的执行单元
协程是比线程更小的一种执行单元,可以认为是轻量级的线程。

之所以说轻:其中一方面的原因是协程所持有的栈比线程要小很多,java当中会为每个线程分配1M左右的栈空间,而协程可能只有几十或者几百K,栈主要用来保存函数参数、局部变量和返回地址等信息。

我们知道:而线程的调度是在操作系统中进行的,而协程调度则是在用户空间进行的,是开发人员通过调用系统底层的执行上下文相关api来完成的。有些语言,比如nodejs、go在语言层面支持了协程,而有些语言,比如C,需要使用第三方库才可以拥有协程的能力

由于线程是操作系统的最小执行单元,因此也可以得出,协程是基于线程实现的,协程的创建、切换、销毁都是在某个线程中来进行的。

使用协程是因为线程的切换成本比较高,而协程在这方面很有优势。

协程的切换到底为什么很廉价?

线程切换的过程:

  • 1)线程在进行切换的时候,需要将CPU中的寄存器的信息存储起来,然后读入另外一个线程的数据,这个会花费一些时间;
  • 2)CPU的高速缓存中的数据,也可能失效,需要重新加载;
  • 3)线程的切换会涉及到用户模式到内核模式的切换,据说每次模式切换都需要执行上千条指令,比较耗时。

实际上协程的切换之所以快的原因:

  • 1)在切换的时候,寄存器需要保存和加载的数据量比较小;
  • 2)高速缓存可以有效利用;
  • 3)没有用户模式到内核模式的切换操作;
  • 4)更有效率的调度,因为协程是非抢占式的,前一个协程执行完毕或者堵塞,才会让出CPU,而线程则一般使用了时间片的算法,会进行很多没有必要的切换(为了尽量让用户感知不到某个线程卡)。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值