【Go】Golang runtime调度③

文章目录

https://www.cnblogs.com/qcrao-2018/p/11442998.html

为什么要使用 Go 语言?Go 语言的优势在哪里?

一、基础概念

进程、线程、协程

进程和线程的区别

线程与协程的区别
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
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值