协程为什么是轻量级线程+go和python的协程

协程

我们先来说一下大家对协程的了解,就是比线程还轻量级的
那思考的点有如下:
1、线程和进程的区别是什么
2、为什么协程就比线程轻量级(创建销毁切换成本更低)
3、python和go对于协程的实现

在解释上面这些问题之前,要先了解一下基本的概念

用户空间和内核空间

在这里插入图片描述
学习 Linux 时,经常可以看到两个词:User space(用户空间)和 Kernel space(内核空间)。

简单说,Kernel space 是 Linux 内核的运行空间,User space 是用户程序的运行空间。为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。

用户空间和内核空间是操作系统的两个重要概念。

用户空间是一个独立的内存空间,其中存储着用户进程(包括应用程序)运行时使用的数据和代码。用户空间是安全的,因为进程之间相互独立,不能直接访问对方的内存空间。

内核空间是操作系统内核代码和数据结构所使用的内存空间。内核空间是不安全的,因为内核代码运行时有最高的权限,可以访问系统的所有资源,并且可以直接控制其他所有进程。

用户空间和内核空间之间的分界线是很重要的,因为它保护了操作系统的安全和稳定。例如,如果一个用户进程的代码出现问题,它可能会崩溃,但是它不会影响整个系统的稳定性。

在这里插入图片描述

用户空间

用户空间按照访问属性一致的地址空间存放在一起的原则,划分成 5个不同的内存区域。 访问属性指的是“可读、可写、可执行等 。

代码段
代码段是用来存放可执行文件的操作指令,可执行程序在内存中的镜像。代码段需要防止在运行时被非法修改,所以只准许读取操作,它是不可写的。

数据段
数据段用来存放可执行文件中已初始化全局变量,换句话说就是存放程序静态分配的变量和全局变量。

BSS段
BSS段包含了程序中未初始化的全局变量,在内存中 bss 段全部置零。

堆 heap
堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。当进程调用malloc等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)

栈 stack
栈是用户存放程序临时创建的局部变量,也就是函数中定义的变量(但不包括 static 声明的变量,static意味着在数据段中存放变量)。除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。由于栈的先进后出特点,所以栈特别方便用来保存/恢复调用现场。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。

上述几种内存区域中数据段、BSS 段、堆通常是被连续存储在内存中,在位置上是连续的,而代码段和栈往往会被独立存放。堆和栈两个区域在 i386 体系结构中栈向下扩展、堆向上扩展,相对而生。

在这里插入图片描述

内核空间

在 x86 32 位系统里,Linux 内核地址空间是指虚拟地址从 0xC0000000 开始到 0xFFFFFFFF 为止的高端内存地址空间,总计 1G 的容量, 包括了内核镜像、物理页面表、驱动程序等运行在内核空间 。

在这里插入图片描述

寄存器的种类

  • 调用者保存寄存器(caller saved registers)
    也叫易失性寄存器,在程序调用的过程中,这些寄存器中的值不需要被保存(即压入到栈中再从栈中取出),如果某一个程序需要保存这个寄存器的值,需要调用者自己压入栈;

  • 被调用者保存寄存器(callee saved registers)
    也叫非易失性寄存器,在程序调用过程中,这些寄存器中的值需要被保存,不能被覆盖;当某个程序调用这些寄存器,被调用寄存器会先保存这些值然后再进行调用,且在调用结束后恢复被调用之前的值;

什么是上下文切换

什么是上下文切换?上下文切换的时机?
CPU通过分配时间片来执行任务,当一个任务的时间片用完,就会切换到另一个任务。在切换之前会保存上一个任务的状态,当下次再切换到该任务,就会加载这个状态。
——任务从保存到再加载的过程就是一次上下文切换。

按导致上下文切换的因素划分,可将上下文切换分为两点:

自发性上下文切换
非自发性上下文切换
自发性上下文切换指线程由于自身因素导致的切出。

非自发性上下文切换指线程由于线程调度器的原因被迫切出。如:

切出线程的时间片用完
有一个比切出线程优先级更高的线程需要被运行
虚拟机的垃圾回收动作

上下文切换的开销

上下文切换的开销包括直接开销和间接开销。
直接开销有如下几点:

操作系统保存回复上下文所需的开销
线程调度器调度线程的开销
间接开销有如下几点:

处理器高速缓存重新加载的开销
上下文切换可能导致整个一级高速缓存中的内容被冲刷,即被写入到下一级高速缓存或主存

协程的上下文切换和线程的上下文切换的区别

- 切换时保存上下文信息的不同

  • 协程: 协程在同一个线程中, 所以不需要用来守卫临界区段的同步性原语(primitive)比如互斥锁、信号量等,并且不需要来自操作系统的支持,协程切换只涉及基本的CPU上下文切换,CPU上下文是一堆寄存器,里面保存了CPU运行任务所需要的信息。
  • 线程:线程的调度只有拥有最高权限的内核空间才可以完成,所以线程的切换涉及到用户空间和内核空间的切换,也就是特权模式切换,然后需要操作系统调度模块完成线程调度(task_strcut),而且除了和协程相同基本的 CPU 上下文,还有线程私有的栈和寄存器等,上下文比协程多一些,简单比较下 task_strcut 和 任何一个协程库的 coroutine 的 struct 结构体大小就能明显区分出来。而且特权模式切换的开销确实不小。
    -线程是被内核所调度,线程被调度切换到另一个线程上下文的时候,需要保存一个用户线程的状态到内存,恢复另一个线程状态到寄存器,然后更新调度器的数据结构,这几步操作设计用户态到内核态转换,开销比较多。

协程的调度完全由用户控制,协程拥有自己的寄存器上下文和栈,协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作用户空间栈,完全没有内核切换的开销。

效率及其他不同

  • 线程有上下文切换,存在变量的拷贝,
  • 协程:协程只是轻量级的方法中断,所以切换效率是高于线程。

协程切换需要保存的CPU上下文信息

协程切换非常简单,就是把当前协程的CPU寄存器状态保存起来,然后将需要切换进来的协程的CPU寄存器状态加载的CPU寄存器上就可以了。
而且完全在用户态进行,一般来说一次协程上下文切换最多就是几十ns这个量级

为什么线程切换会导致用户态与内核台的切换

因为线程的调度是在内核态运行的,而线程中的代码是在用户态运行。

线程和进程的区别是什么

这个问题比较笼统,通过上面的一些概念的描述,我们已经得出了

为什么协程就比线程轻量级(创建销毁切换成本更低)

的结论

但是还可以再补充一下
协程是用户主动,协程的中断,去执行其他协程,不是函数调用,有点类似CPU的中断,主动中断,重点在于主动
这一点我们可以从python中对于协程的使用
async、yiled的关键词来体验,yiled就说明我把权限交出去了

go对协程的实现

go语言中文文档
有兴趣的可以看一下上面这篇文章,本模块内容全来自于此,只是做了一些精简
在这里插入图片描述
这样,我们再去细化去分类一下,内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”.

在这里插入图片描述
​ 看到这里,我们就要开脑洞了,既然一个协程 (co-routine) 可以绑定一个线程 (thread),那么能不能多个协程 (co-routine) 绑定一个或者多个线程 (thread) 上呢。

在这里插入图片描述
N 个协程绑定 1 个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上

缺点:

某个程序用不了硬件的多核加速能力

一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。

1 个协程绑定 1 个线程,这种最容易实现。协程的调度都由 CPU 完成了,不存在 N:1 缺点,
缺点:

协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。
在这里插入图片描述
M 个协程绑定 N 个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。

在这里插入图片描述
Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go 中,协程被称为 goroutine,它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 goroutine 分配。

Goroutine 特点:

占用内存更小(几 kb)
调度更灵活 (runtime 调度)

​好了,既然我们知道了协程和线程的关系,那么最关键的一点就是调度协程的调度器的实现了。

Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题,所以使用 4 年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的?
在这里插入图片描述
下面我们来看看被废弃的 golang 调度器是如何实现的?

在这里插入图片描述
M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。

老调度器有几个缺点:

创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

Goroutine 调度器的 GMP 模型的设计思想

在这里插入图片描述
Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

(1) GMP 模型
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。
在这里插入图片描述
1、全局队列(Global Queue):存放等待运行的 G。
2、P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
3、P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
4、M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。

python对协程的实现

但是相对于python来说,貌似看到的所有文档都是一个线程对应多个协程,而不是多个线程对应多个线程
Python对协程的支持是通过generator实现的。

在generator中,我们不但可以通过for循环来迭代,还可以不断调用next()函数获取由yield语句返回的下一个值。

但是Python的yield不但可以返回一个值,它还可以接收调用者发出的参数。
来看例子:

传统的生产者-消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。

如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:

def consumer():
    r = ''
    while True:
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        r = '200 OK'

def produce(c):
    c.send(None)
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()

c = consumer()
produce(c)

执行结果

[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK

注意到consumer函数是一个generator,把一个consumer传入produce后:

首先调用c.send(None)启动生成器;

然后,一旦生产了东西,通过c.send(n)切换到consumer执行;

consumer通过yield拿到消息,处理,又通过yield把结果传回;

produce拿到consumer处理的结果,继续生产下一条消息;

produce决定不生产了,通过c.close()关闭consumer,整个过程结束。

整个流程无锁,由一个线程执行,produce和consumer协作完成任务,所以称为“协程”,而非线程的抢占式多任务。

猜你喜欢

转载自blog.csdn.net/qq13933506749/article/details/129860968