文章目录
- 一、基础概念
- 二、Goroutine生命周期与调度模型
- 三、调度触发机制
- 四、关键组件实现
- 五、启动与底层机制
- 面试问题
-
-
- Go 程序启动过程
- 自旋线程M的最大限制
- 调度器的设计策略
- 触发调度(阻塞场景)
- 5.如何解决Go服务容器化的P数量的问题
-
- 四、go为什么这么快?(再探GMP模型)
https://www.cnblogs.com/qcrao-2018/p/11442998.html
一、基础概念
进程、线程、协程
进程和线程的区别
线程与协程的区别
Goroutine基础
Goroutine概念
goroutine和thread的区别
内存占用
创建/销毁开销
调度切换效率
复杂性对比
M:N线程/协程模型
进程、线程、协程
进程和线程
多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。
虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1M 以上的内存空间,在切换线程时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁资源,每一次线程上下文的切换都需要消耗 ~1us 左右的时间1,但是 Go 调度器对 Goroutine 的上下文切换约为 ~0.2us,减少了 80% 的额外开销2。
Goroutine概念
Goroutine 是一个与其他 goroutines 并行运行在同一地址空间的 Go 函数或方法。一个运行的程序由一个或更多个
goroutine 组成。它与线程、协程、进程等不同。它是一个 goroutine” —— Rob Pike
Goroutines 在同一个用户地址空间里并行独立执行 functions,channels 则用于 goroutines 间的通信和同步访问控制。
线程与 Goroutine
Go 语言的调度器通过使用与 CPU 数量相等的线程减少线程频繁切换的内存开销,同时在每一个线程上执行额外开销更低的 Goroutine 来降低操作系统和硬件的负载。
首先,每个系统级线程都会有一个固定大小的栈(一般默认可能是 2MB),这个栈主要用来保存函数递归调用时参数和局部变量。
固定了栈的大小导致了两个问题:
一是对于很多只需要很小的栈空间的线程来说是一个巨大的浪费,
二是对于少数需要巨大栈空间的线程来说又面临栈溢出的风险。
针对这两个问题的解决方案是:
1.要么降低固定的栈大小,提升空间的利用率;
2. 要么增大栈的大小以允许更深的函数递归调用,
但这两者是没法同时兼得的。相反,一个 Goroutine 会以一个很小的栈启动(可能是 2KB 或 4KB),当遇到深度递归导致当前栈空间不足时,Goroutine 会根据需要动态地伸缩栈的大小(主流实现中栈的最大值可达到1GB)。因为启动的代价很小,所以我们可以轻易地启动成千上万个 Goroutine。
Go的运行时还包含了其自己的调度器,这个调度器使用了一些技术手段,可以在 n 个操作系统线程上多工调度 m 个 Goroutine。Go 调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的 Go 程序中的 Goroutine。Goroutine 采用的是半抢占式的协作调度,只有在当前 Goroutine 发生阻塞时才会导致调度;同时发生在用户态,调度器会根据具体函数只保存必要的寄存器,切换的代价要比系统线程低得多。运行时有一个 runtime.GOMAXPROCS 变量,用于控制当前运行正常非阻塞 Goroutine 的系统线程数目。
goroutine 和 thread 的区别?
二、Goroutine生命周期与调度模型
Goroutine生命周期
创建、执行、阻塞、唤醒、销毁
调度器演进
GM调度器(Go1.2 - 1.13)
Go 1.2前的调度器实现,限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。
- 每个 goroutine 对应于 runtime 中的一个抽象结构:G,
- thread 作为“物理 CPU”的存在而被抽象为一个结构:M(machine)。
当 goroutine 调用了一个阻塞的系统调用,运行这个 goroutine 的线程就会被阻塞,这时至少应该再创建/唤醒一个线程来运行别的没有阻塞的 goroutine。线程这里可以创建不止一个,可以按需不断地创建,而活跃的线程(处于非阻塞状态的线程)的最大个数存储在变量 GOMAXPROCS中。
GM 调度模型的问题与局限性
-
单一全局互斥锁(Sched.Lock)和集中状态存储
导致所有 goroutine 相关操作,比如:创建、结束、重新调度等都要上锁。 -
Goroutine 传递问题
M 经常在 M 之间传递”可运行”的 goroutine,这导致调度延迟增大以及额外的性能损耗(刚创建的 G 放到了全局队列,而不是本地 M 执行,不必要的开销和延迟)。 -
Per-M 持有内存缓存 (M.mcache)
每个 M 持有 mcache 和 stackalloc,然而只有在 M 运行 Go 代码时才需要使用的内存(每个 mcache 可以高达2mb),当 M 在处于 syscall 时并不需要。运行 Go 代码和阻塞在 syscall 的 M 的比例高达1:100,造成了很大的浪费。同时内存亲缘性也较差,G 当前在 M 运行后对 M 的内存进行了预热,因为现在 G 调度到同一个 M 的概率不高,数据局部性不好。 -
严重的线程阻塞/解锁
在系统调用的情况下,工作线程经常被阻塞和取消阻塞,这增加了很多开销。比如 M 找不到G,此时 M 就会进入频繁阻塞/唤醒来进行检查的逻辑,以便及时发现新的 G 来执行。
GMP调度模型
G(Goroutine)、M(Machine)、P(Processor)定义
- G — 表示 Goroutine,它是一个待执行的任务;
- M — 表示操作系统的线程,它由操作系统的调度器调度和管理,操作系统负责把它放置到一个 core 上去执行。;
- P — 表示处理器
- 它可以被看做运行在线程上的本地调度器,它的主要用途就是用来执行goroutine,它维护了一个goroutine队列,即runqueue。Processor的让我们从N:1调度到M:N调度的重要部分 (通过runtime.GOMAXPROCS控制P的数量);
- 当P发现没有任务的时候,除了会找本地和全局,也会去netpoll中找。
组件 | 说明 | 关键特性 |
---|---|---|
G | Goroutine 运行上下文 | 含栈/PC/调度状态 |
P | 逻辑处理器(调度控制中心) | 维护本地运行队列(LRQ) |
M | OS 线程实体 | 绑定 P 执行 G |
Sched | 全局调度器 | 管理全局运行队列(GRQ) |
struct P {
Lock;
uint32 status;
P* link;
uint32 tick;
M* m;
MCache* mcache;
G** runq;
int32 runqhead;
int32 runqtail;
int32 runqsize;
G* gfree;
int32 gfreecnt;
};
C
G结构体、状态流转
// runtime/runtime2.go
type g struct {
// stack 描述了 goroutine 的栈内存范围 [stack.lo, stack.hi)
stack stack // offset known to runtime/cgo
// stackguard0 用于检测是否需要扩展栈
// 通常设置为 stack.lo + StackGuard
// 但可以通过抢占或信号修改为 StackPreempt
stackguard0 uintptr // offset known to liblink
// stackguard1 用于 C 栈增长检查
stackguard1 uintptr // offset known to liblink
// 当前 goroutine 的 m (machine)
// 如果为 nil 表示当前 goroutine 不在执行或由系统栈执行
m *m // current m; offset known to arm liblink
// 调度相关
sched gobuf // goroutine 的调度信息
syscallsp uintptr // if status==Gsyscall, syscallsp = sched.sp to use during gc
syscallpc uintptr // if status==Gsyscall, syscallpc = sched.pc to use during gc
stktopsp uintptr // expected sp at top of stack, to check in traceback
// 参数传递区域
param unsafe.Pointer // passed parameter on wakeup
// goroutine 状态
atomicstatus uint32
// goroutine ID
goid int64
// 指向当前 goroutine 等待的 channel
waitsince int64 // approx time when the g become blocked
waitreason waitReason // if status==Gwaiting
// 抢占相关
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
preemptStop bool // transition to _Gpreempted on preemption; otherwise, just deschedule
preemptShrink bool // shrink stack at synchronous safe point
// 与 defer 和 panic 相关
_panic *_panic // innermost panic - offset known to liblink
_defer *_defer // innermost defer
// 与 GC 相关
gcAssistBytes int64 // bytes allocated in this cycle
}
G:goroutine ,G是goroutine实现的核心结构,它包含了状态、堆栈,上下文、指令指针,以及其他对调度goroutine很重要的信息,例如其阻塞的channel。每次 go func() 都代表一个 G,无限制。
**Goroutine 在 Go 语言运行时使用私有结构体struct runtime.g 表示。**这个私有结构体非常复杂,总共包含 40 多个用于表示各种状态的成员变量,这里也不会介绍所有的字段,仅会挑选其中的一部分,首先是与栈相关的两个字段:
G状态流转
协程的状态其实和线程状态类似,状态转换和发生状态转换的时机如图所示. 还是需要注意: 协程只是一个执行流, 并不是运行实体.
多数情况下 Goroutine 在执行的过程中都会经历协作式或者抢占式调度,它会让出线程的使用权等待调度器的唤醒。
如何获取G
// runtime/proc.go
func schedule() {
// 从以下位置获取可运行的G:
// 1. 本地P的runnext(最高优先级)
// 2. 本地P的运行队列
// 3. 全局队列
// 4. 网络轮询器
// 5. 从其他P偷取
execute(gp) // 切换到目标goroutine
}
1.本地(调度逻辑: 此时执行每61次,就会尝试优先从全局runq获取,有的话会拿一个,避免饿死)
2.全局:从全局队列中拿一个【加锁】,顺便从全局队列拿出128个给这个P(如果全局队列中没有128个就都给它,省的以后总来要),如果全区队列为空就继续往下走
3.从netpoll中拿,如果有返回第一个,剩下的通过injectglist放到全局队列中【加锁】,如果没有往下走
4.偷其他P一半:通过runqsteal函数随机从其他的P的本地队列中偷一半给当前P,然后将偷到的最后一个返回,如果还没有,那就算了,将P设置为空闲状态并且将M从P中拿下来,但是不会kill M
Goroutine Lifecycle(生命周期)
-
start:启动 m0 主线程,初始化 g0 负责 schedule,初始化 P, sysmon 线程, GC 协程;
-
然后 P 负责创建 os thread 关联 M;
-
G 切换时,暂存当前的 PC 及 GO 堆栈即可;
-
回收:G 用完会放到空闲列表,由 P 来负责回收
// runtime/proc.go
func schedinit() {
// 初始化M0(主线程)
m0 = allocm(nil)
m0