GMP模式
当Go程序启动时,会为机器上的每一个虚拟核
分配一个逻辑处理器(P)
。同时,如果你的CPU处理器支持超线程
技术,每一个超线程
对于Go程序都是一个虚拟核
。
上面是我的MacBook Pro的配置,可以看到有一个双核的CPU处理器。同时Intel Core i5
支持超线程
技术,每个CPU物理核
允许两个线程同时不冲突地使用该CPU物理核
。那么对于Go程序,就有4个虚拟核
可以并行
地运行系统线程
。
package main
import (
"fmt"
"runtime"
)
func main() {
// NumCPU returns the number of logical
// CPUs usable by the current process.
fmt.Println(runtime.NumCPU())
}
NumCPU()
会输出逻辑处理器(P)
的数目。
每一个P
会被分配一个系统线程(M)
,线程的调度是由系统调度器(OS Schedule)
来负责。
G
,协程(Goruntine)
,你可以理解为用户态
的线程,和线程不同的是,协程的调度由用户的应用程序
来负责,多个协程间的上下文切换
是发生在运行这些协程的线程上。
Go还维护这两个不同的运行队列
:全局队列(GRQ)
和本地队列(LRQ)
。每一个P
都会有一个LRQ
,队列里的协程G
,会进行上下文切换,在被绑定到P的线程M
上运行。
GRQ
保存着还没分配给P
的协程,GRQ
的协程会在后面被移动到某个P
的LRQ
。
可以看到,Go调度器的设计,复用了线程,减少了传统多线程上下文切换,带来的时间和资源的损耗。Go协程间的调度发生在用户态
,不需要内核的参与,大大减少了时间延迟。
协程的状态
和线程一样,协程有三种状态,Go调度器的调度依赖协程的状态,所以有必要先大概了解下。
- 等待(Waiting)
协程暂停,等待条件满足后继续执行。一般是等待系统调用完成,或者同步调用(锁释放等)。 - 可运行(Runnable)
协程可运行,协程此时已经准备好,随时可以被分配到P
的线程M
运行。 - 执行中(Executing)
协程正在P
上的线程M
上运行。
协程的上下文切换
什么时候当前执行中的协程可能会让出执行权,发生上下文切换,让可运行的协程获得执行权。有四种场景。
- 使用关键字
go
关键字go创建新的协程,一旦新协程创建成功,go调度器会做出调度的决定。一般情况下,为了局部性优化,会将新创建的协程加入到同一个
P
的LRQ
。
- 垃圾回收
go使用单独的协程来进行垃圾回收,垃圾回收可能会造成协程的切换。例如调度器会将使用到正在被回收堆的协程,让出执行权,切换到不使用正在被回收堆的协程。 - 系统调用
如果一个协程调用了系统调用,导致进程被阻塞(协程是碰着阻塞式IO会导致整个线程被挂起么?)
这时候,调度器会将被阻塞的线程M1
和P
解绑,但原协程G1
还是会绑定在原线程M1
。调度器会创建一个新的线程M2
(或者使用之前创建但没销毁的线程)绑定到P
,P
会从LRQ队列
里挑选一个协程来运行。
当协程
G1
的系统调用完成后,G1
可以被放回到原P
的LRQ
,等待下一次协程切换来运行。
但其实这里面会有一个很大的问题,如果每次系统调用都会创建一个新的线程,那么整个系统最终会创建越来越多的线程。
一个很常见的go服务器端程序。每次与客户端创建一个连接后,都开启一个协程来处理连接的读写。
go对于非阻塞的I/O调用,使用了
网络轮询器
,不需要每次都创建一个新的线程。
网络轮询器
是基于epoll
实现,整个实现,个人理解,是将所有监听的套接字注册到epoll,利用I/O多路复用,等待套接字就绪后,协程可以读写套接字了,再将协程返回到P
的LRQ队列
。
下面有两篇文章,可以深入了解下
网络轮询器
的实现。
The Go netpoller
网络轮询器 - 同步调用
锁或者通道等操作,会造成协程阻塞,此时调度器会进行协程切换。
Go 调度器调度场景过程全解析
Go 调度器调度场景过程全解析 这篇文章里对Go的整个调度器调度场景,有非常详细的图和文字说明,很不错 。