本片博文基于Go1.14.3版本
创建一个系统线程并且从一个线程切换到另一个线程会带来内存和性能上的损耗。Go
旨在更好地发挥多核的性能,其从一开始设计就考虑了对并发地优化。
M-P-G模型
为了解决线程间切换所造成的性能损耗。Go
有自己的调度器(scheduler
),可以在操作系统上分配协程(Goroutine
)。
这个调度器有三个核心概念,源码中的描述如下:
1 | The main concepts are: |
下图展示了M-P-G
模型:
每个协程G
运行在一个系统线程M
中,同时该线程M
被分配一个逻辑CPUP
上。让我们看一个简单的例子,这个例子介绍了Go M-P-G
模型:
1 | func main() { |
Go
首先会创建多个P
,这取决于执行机器上逻辑U的数量,然后会将这些P
保存在P空闲队列
中。
然后,新的协程或者处于待执行状态的协程将会唤醒一个逻辑UP
去执行,P
会创建一个具有关联OS线程的M
:
就像P
一样,不处于工作状态的M
(即没有等待执行的协程),可能是从系统调用返回的,也可能被GC强行停止的,它们也会被会暂存到一个空闲列表中。
在程序启动装载时,Go
已经创建了一些OS线程同时关联到M
上。上面的例子中,第一个打印Hello
的协程将会使用主线程的M
,
第二个打印World
的协程将会从空闲队列中获取一个M
和P
。
目前为止,我们已经对Go
协程和线程管理有了初步的了解。下面让我们看看哪种情况Go
使用的M
多于P
,
以及如何在系统调用的情况下管理协程。
系统调用 System calls
Go
通过在运行中包装系统调用来优化它们(无论是否阻塞)。这个包装器(wrapper
)将自动从线程M
分离P
,
并运行另一个线程在其上运行。让我们通过一个读取文件的例子来解释其原理:
1 | func main() { |
下图是打开文件是的工作流:
P0
目前处于空闲队列,这意味这它可以分配到M
。一旦系统调用结束返回,Go
将按照以下规则执行直到其中一条规则满足:
- 尝试分配之前的
P
:”P0”,然后恢复执行; - 从
P
的空闲队列中获取一个新的P
,然后恢复执行; - 将协程保存在阻塞队列中,同时将
M
返回到空闲队列中;
同时,在非阻塞I/O
(例如http调用)的情况下,Go还可以处理资源尚未准备就绪的情况。在这中情况下,第一个系统如果遵循上面的工作流将不会成功,
这是因为资源还未准备好,可以通过强制转到使用网络轮训器(network poller
)并暂存协程。下面是一个例子:
1 | func main() { |
一旦完成第一个系统调用并明确指出资源尚未准备好,协程将驻留(暂存),直到网络轮训器通知它资源已就绪。这种情况下线程M
将不被阻塞。
当Go调度器调度协程继续工作时,这个被暂存的协程将会恢复执行状态。然后,调度器将询问网络轮询器在成功获得它所等待的信息后, 是否有一个goroutine正在等待运行:
如果有多个goroutine准备好了,那么额外的goroutine将进入全局可运行队列,稍后进行调度。
操作系统线程的限制
当使用系统调用时,Go
不会限制阻塞OS的线程数,官方给出解释:
环境变量
GOMAXPROCS
限制用户级别(user-level)可以执行的线程数量。这意味这Go
在系统调用中阻塞的线程数量没有限制, 这并没有和变量GOMAXPROCS
冲突。
下面我们通过一个例子证明:
1 | func main() { |
通过追踪工具go tool trace
我们可以查看线程的数量:
由于Go
优化了线程调度,当分配到该线程的协程阻塞后,该线程可以被重用,这也解释了为什么上述程序中,协程的数量不等于线程。