调度机制 #
goroutine与线程的区别 #
- 内存消耗
创建一个goroutine的栈内存消耗为2KB,世纪运行过程中,如果栈空间不够用,会自动进行扩容。创建一个线程则需要消耗1MB栈内存,而且还需要一个被称为“a gurad page“的区域用于和其他thread的栈空间进行隔离。
对于一个用Go构建的HTTP server而言,对到来的每个请求,分别创建一个goroutine用来处理是一个非常轻松的事情。而对于一个使用线程作为并发原语的语言(例如java)构建的服务来说,每个请求对应一个线程则太浪费资源了,如果不加限制,可能会出OOM错误(Out Of Mermory Error)。
- 创建和销毁
线程创建和销毁都会产生巨大的消耗,因为要和操作系统打交道,是内核级的。通常解决的办法就是使用线程池,尽量复用,减小重复创建和销毁的开销。而goroutine由Go runtime负责管理,创建和销毁的消耗非常小,是用户级的。
- 切换
当线程切换时,需要保存各种寄存器,以便将来恢复。
而goroutine切换时只需要保存三个寄存器:Program Counter、Stack Pointer和BP。
一般而言,线程切换回消耗1000~1500ns,而goroutine的切换约为200ns,goroutine的切换成本比threads小的多。
Go sheduler #
Go程序的执行有两个层面:Go Program 和Runtime,即用户程序和运行时。它们之间通过函数调用来实现内存管理、channel通信、goroutine创建等功能。用户程序进行的系统调用都会被Runtime拦截,以此来帮助它进行调度以及垃圾回收相关的工作。
Go sheduler的目标:将goroutine调度到内核线程上。
Go sheduler的核心思想:
- 重用线程
- 限制同时运行(不包括阻塞)的线程数为N,N等于CPU的核心数目。
- 线程私有runqueues,并且可以从其他线程偷取goroutine来运行,线程阻塞后,可以将runqueues传递给其他线程。
Go scheduler会启动一个后台线程sysmon,用来检测长时间(超过10ms)运行到goroutine,将其“停靠”到global runqueues。这是一个全局的runqueues,优先级比较低,以示惩罚。
G goroutine协程
P processor处理器
M thread线程
Processor 它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列。
在Go中,线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上。
- 全局队列:存放等待运行的G
- P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
- P列表:所有的P都在程序启动时创建,并保存在数组中,最多有
GOMAXPROCS
(可配置) 个。 - M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。
P 处理器的作用 #
负责调度G 当一个线程阻塞的时候,将和它绑定的P上的goroutine转移到其他线程。
P和M的个数问题 #
1、P的数量:
- 由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
2、M的数量:
- go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
- runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
- 一个 M 阻塞了,会创建新的 M。
M与P的数量没有绝对关系,一个M阻塞,P就会去创建或者切换另一个M,所以即使P的数量是1,也有可能会创建很多个M出来。
P和M何时会被创建 #
1、P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。
2、M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。
调度器的设计策略 #
复用线程:避免频繁的创建、销毁线程,而是对线程的复用。
1)work stealing 机制
当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。
2)hand off 机制
当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。
利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。
抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。
全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。
go func () 调度流程 #
从上图我们可以分析出几个结论:
1、我们通过 go func () 来创建一个 goroutine;
2、有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;
3、G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;
4、一个 M 调度 G 执行的过程是一个循环机制;
5、当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;
6、当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。
垃圾回收 #
认识 #
有了垃圾回收,为什么还会发生内存泄露? #
在Go中,由于goroutine的存在,所谓的内存泄露除了附着在长期对象上之外,还存在多种不同的形式。
-
预期能被快速释放的内存因被根对象引用而没有得到迅速释放
当有一个全局对象时,可能不经意间将某个变量附着其上,且忽略了将其进行释放,则该内存永远不会得到释放。
-
goroutine泄露
goroutine作为逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在Go中时不会被释放的。因此,如果一个程序持续不断地产生新的goroutine、且不结束已经创建的goroutine并复用这部分内存,就会造成内存泄露。
这种形式的goroutine泄露还可能由channel泄露导致。而channel的泄漏本质上与goroutine泄漏存在直接联系。channel作为一种同步原句,会连接两个不同的goroutine,如果一个goroutine尝试向一个没有接收方的无缓冲channel发送消息,则该goroutine会被永久的休眠,整个goroutine及其执行栈都得不到释放。