goroutine为什么比线程开销小,实现原理

题目来源:SmartX,字节跳动

**答案:**Zbbxd

1.内存占用

从栈空间上, goroutine的栈空间更加动态灵活。每个OS的线程都有⼀个固定⼤⼩的栈内存,通常是2MB,栈内存⽤于保存在其他函数调⽤期间哪些正在执⾏或者
临时暂停的函数的局部变量。这个固定的栈⼤⼩,如果对于goroutine来说,可能是⼀种巨⼤的浪费。作为对⽐,goroutine在⽣命周期开始只有⼀个很⼩的栈,典型情况是2KB, 在go程序中,⼀次创建⼗万左右的goroutine也不罕⻅(2KB*100,000=200MB)。⽽且goroutine的栈不是固定⼤⼩,它可以按需增⼤和缩⼩,最⼤限制可以到1GB。
创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。

2.调度

从调度上看, goroutine的调度开销远远⼩于线程调度开销。
OS的线程由OS内核调度,每隔⼏毫秒,⼀个硬件时钟中断发到CPU, CPU调⽤⼀个调度器内核函数。这个函数暂停当前正在运⾏的线程,把他的寄存器信息保存到内存中,查看线程列表并决定接下来运⾏哪⼀个线程,再从内存中恢复线程的注册表信息,最后继续执⾏选中的线程。这种线程切换需要⼀个完整的上下⽂切换:即保存⼀个线程的状态到内存,再恢复另外⼀个线程的状态,最后更新调度器的数据结构。某种意义上,这种操作还是很慢的。

Go运⾏的时候包涵⼀个⾃⼰的调度器,这个调度器使⽤⼀个称为⼀个M:N调度技术, m个goroutine到n个os线程(可以⽤GOMAXPROCS来控制n的数量), Go的调度器不是由硬件时钟来定期触发的,⽽是由特定的go语⾔结构来触发的,他不需要切换到内核语境,所以调度⼀个goroutine⽐调度⼀个线程的成本低很多。

实现原理:

goroutine的本质是协程,是实现并⾏计算的核⼼。
goroutine使⽤⽅式⾮常的简单,只需使⽤go关键字即可启动⼀个协程,并且它是处于异步⽅式运⾏,你不需要等
它运⾏完成以后再执⾏以后的代码。

  1. go func()//通过go关键字启动⼀个协程来运⾏函数

协程:

协程拥有⾃⼰的寄存器上下⽂和栈。协程调度切换时,将寄存器上下⽂和栈保存到其他地⽅,在切回来的时候,恢复先前保存的寄存器上下⽂和栈。 因此,协程能保留上⼀次调⽤时的状态(即所有局部状态的⼀个特定组合),每次过程重⼊时,就相当于进⼊上⼀次调⽤的状态,换种说法:进⼊上⼀次离开时所处逻辑流的位置。 线程和进程的操作是由程序触发系统接⼝,最后的执⾏者是系统;协程的操作执⾏者则是⽤户⾃身程序, goroutine也是协程。
groutine能拥有强⼤的并发实现是通过GPM调度模型实现。

82ea5abc-6a9a-4fef-8500-c3877dfeae22

Go的调度器内部有四个重要的结构:

M, P, S, Sched,如上图所示(Sched未给出) .

M:M代表内核级线程,⼀个M就是⼀个线程, goroutine就是跑在M之上的; M是⼀个很⼤的结构,⾥⾯维护⼩对象内存cache(mcache)、当前执⾏的goroutine、随机数发⽣器等等⾮常多的信息

G:代表⼀个goroutine,它有⾃⼰的栈, instruction pointer和其他信息(正在等待的channel等等),⽤于调
度。

P:P全称是Processor,处理器,它的主要⽤途就是⽤来执⾏goroutine的,所以它也维护了⼀个goroutine队
列,⾥⾯存储了所有需要它来执⾏的goroutine

Sched:代表调度器,它维护有存储M和G的队列以及调度器的⼀些状态信息等。

调度实现:

6a57cd29-4155-4d10-b84f-06a7a751bacd

从上图中可以看到,有2个物理线程M,每⼀个M都拥有⼀个处理器P,每⼀个也都有⼀个正在运⾏的goroutine。 P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运⾏。图中灰⾊的那些goroutine并没有运⾏,⽽是出于ready的就绪态,正在等待被调度。 P维护着这个队列(称之为runqueue), Go语⾔⾥,启动⼀个goroutine很容易: go function 就⾏,所以每有⼀个go语句被执⾏,runqueue队列就在其末尾加⼊⼀个goroutine,在下⼀个调度点,就从runqueue中取出(如何决定取哪个goroutine?)⼀个goroutine执⾏。当⼀个OS线程M0陷⼊阻塞时, P转⽽在运⾏M1,图中的M1可能是正被创建,或者从线程缓存中取出。

9451e285-6064-4933-a6dc-552233ece795

当MO返回时,它必须尝试取得⼀个P来运⾏goroutine,⼀般情况下,它会从其他的OS线程那⾥拿⼀个P过来, 如果没有拿到的话,它就把goroutine放在⼀个global runqueue⾥,然后⾃⼰睡眠(放⼊线程缓存⾥)。所有的P也会周期性的检查global runqueue并运⾏其中的goroutine,否则global runqueue上的goroutine永远⽆法执⾏。另⼀种情况是P所分配的任务G很快就执⾏完了(分配不均),这就导致了这个处理器P很忙,但是其他的P还有任务,此时如果global runqueue没有任务G了,那么P不得不从其他的P⾥拿⼀些G来执⾏。

538b86c4-842b-40e2-a00a-a3454f488ecb
通常来说,如果P从其他的P那⾥要拿任务的话,⼀般就拿run queue的⼀半,这就确保了每个OS线程都能充分的使⽤。