在学习本文之前,可以先想几个问题
1.为什么有了线程,还要有goroutines?
2. go调度器实现并发原理
3. go调度器为什么要引入P层
4. goroutines调度过程
goroutines相对于线程的优势
在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
可能对于cpu密集型,goroutine 的处理不及线程相关的调度算法合理
1. 动态栈
- 修改固定的大小可以提升空间的利用率,允许创建更多的线程。
一个OS线程都有一个固定大小的内存块(一般会是2MB)来做栈,这个栈会用来存储当前正在被调用或挂起(指在调用其它函数时)的函数的内部变量。2MB的栈对于一个小小的goroutine来说是很大的内存浪费,对于很大的goroutine来说又不够。
- 允许更深的递归调用。
固定大小的栈对于更复杂或者更深层次的递归函数调用来说显然是不够的。
- 具体实现
一个goroutine会以一个很小的栈开始其生命周期,一般只需要2KB。一个goroutine的栈和OS线程不太一样的是,一个goroutine的栈大小并不是固定的;栈的大小会根据需要动态地伸缩。最大为1GB
2.调度的性能更好
- 切换开销更小
使用操作系统的 threads 的最大能力一般在万级别,而Goroutine却能有上百万个。很大原因是在上下文切换的延迟不同。
(1)线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给它的时间片,就会被其它可执行的线程抢占。在线程切换的过程中需要保存/恢复所有的寄存器信息。这几步操作很慢,因为其局部性很差需要几次内存访问,并且会增加运行的cpu周期。
(2) 而goroutine的调度是协同式的,它不会直接地与操作系统内核打交道。多个 goroutine 在发生切换的时候,由于是在同一个 thread 下面,切换的时候只会保存/恢复三个寄存器当中的内容:Program Counter, Stack Pointer (栈指针)。并且同一时刻同一个 thread 只会执行一个 goroutine,未被执行但是已经准备好的 goroutine 都是放在一个 queue 中的,他们是被串行处理的。
(3)所以,即使一个程序创建了成千上万的 goroutine 也不会对上下文的切换造成什么影响。最重要的是,golang scheduler 在切换不同 goroutine 的操作上基本上达到了 O(1) 的时间复杂度。这就使得上下文切换的时间已经和 goroutine 的规模完全不相关了。
(4)上下文切换对比
进程上下文切换:切换页目录,切换内核栈和硬件上下文
线程上下文切换:所有寄存器信息,16个通用寄存器,Program Counter(程序计数器), Stack Pointer (栈指针);和进程比就是不用切换页目录。
goroutine上下文切换:Program Counter(程序计数器), Stack Pointer (栈指针)
Go 的行为有何不同:在一个操作系统线程上运行多个 Goroutines
- 更好的支持高并发
(1)支持真正的高并发需要另外一种优化思路:当你知道这个线程能做有用的工作的时候,才去调度这个线程!如果你正在运行多线程,其实无论何时,只有少部分的线程在做有用的工作。最好的并发不是利用共享内存来通信,而是用通信来共享内存。
(2)Go 语言引入了 channel 的机制来协助这种调度机制。如果一个 goroutine 正在一个空的 channel 上等待,那么调度器就能看到这些,并不再运行这个 goroutine 。同时 Go 语言更进了一步。它把很多个大部分时间空闲的 goroutines 合并到了一个自己的操作系统线程上。这样可以通过一个线程来调度活动的 Goroutine(这个数量小得多),而是数百万大部分状态处于睡眠的 goroutines 被分离出来。这种机制也有助于降低延迟。
3.Goroutine没有显示暴露ID号
在大多数支持多线程的操作系统和程序语言中,当前的线程都有一个独特的身份(id)。
goroutine不可以被程序员很容易获取到身份(id)。这一点是设计上故意而为之,在一定程度上防止thread-local storage被滥用。