goroutine 调度原理,生命周期
- 传统cpu调度背景 #操作系统原理#
- 线程切换
- 行为
- 时机
- 代价
- go是怎样的思路
- 将调度维持在用户态
- 推出用户态runtime代码实现的轻量级线程
- go调度策略
- 常规: 本地队列- 其他队列、全局队列
- 协作式调度: 基于用户态事件
-- 异步io: 网络: 基于netpoller
-- 同步io: 产生M - 抢占式
- goroutine的生命周期
只想关注golang goroutine背景、调度方式、生命周期的请关注3,4,5部分。
1. 传统cpu调度模型: 应用不能既当运动员,又当裁判员
操作系统分为用户态和内核态(或者叫用户空间和内核空间), 那内核态究竟是什么呢?
计算机是多进程操作系统,多个用户进程同时都在利用有限的物理资源:cpu、内存、io, 不能让用户进程既当运动员,又当裁判员。
eg : CPU 有限,需要进行线程切换以实现并发执行。
于是操作系统抽象出了内核对象,用于实现进程同步、进程通信、内存管理等系统资源的分配和管理。操作系统维护着内核对象表,每个内核对象都在这张表上记录了状态和属性。
内核对象实质由内核态代码创建,用户可以系统调用、api函数来产生和操作内核对象。
eg: C# Thread类表示一个线程(是托管代码),而线程是内核对象,故C#使用Thread创建线程,实际是由操作系统创建了一个内核对象来实现线程。
画外音:
os作为裁判员,内核态是一种特殊的调度程序,统筹计算机的硬件资源,例如协调CPU资源、分配内存资源、并且提供稳定的环境供应用程序运行`。
常规的线程模型会触发线程切换。
2. 线程切换
- 线程是cpu调度的基本单位,进程是资源占有的基本单位。
- 线程中的代码是在用户态运行,而线程的调度是在内核态。
具体行为: 保存当前线程上下文、 加载新线程的上下文,涉及PC、sp指针。 倒也不是说常规线程模型触发了线程切换,是一个极端不好的问题,只是一直以来cpu都是这种调度模型。
时机:分为用户代码导致的 和 操作系统主动形成的 线程切换。
① 自发性上下文切换 | 线程受用户代码指令导致的切出 |
---|---|
Thread.sleep() | 线程主动休眠 |
object.wait() | 线程等待锁 |
Thread.yield() | 当前线程主动让出CPU,如果有其他就绪线程就执行其他线程,如果没有则继续当前线程 |
oThread.join() | 阻塞发起调用的线程,直到oThread执行完毕 |
② 非自发性上下文切换: 来自内核线程调度器管控 |
---|
线程的时间片用完 ,cpu时间片雨露均沾 |
高优先级线程抢占 |
虚拟机的垃圾回收动作 |
线程上下文切换的代价是高昂的:上下文切换的延迟取决于不同的因素,大概是50到100ns左右,考虑到硬件平均在每个核心上每ns执行12条指令,那么一次上下文切换可能会花费600到1200条指令的延迟时间。
① 直接开销 |
---|
保存当前/恢复新线程上下文所需的开销 |
线程调度器调度线程的开销 |
② 间接开销 |
---|
重新加载高速缓存 |
上下文切换可能导致 一级缓存被冲刷,写入下一级缓存或内存 |
3. go的用户态轻量级线程
如上面所述,常规线程切换会导致用户态程序和内核态调度程序的切换。
大佬们思考了另外一个思路:将调度尽量维持在用户态, 由早期的GM模型,演进到GPM调度模型。
P是一个“逻辑Proccessor”,每个G(Goroutine)要想真正运行起来,首先需要被分配一个P,也就是进入到P的本地运行队列(local runq)中。对于G来说,P就是运行它的“CPU”,可以说:在G的眼里只有P。但从Go调度器的视角来看,真正的“CPU”是M,只有将P和M绑定,才能让P的runq中的G真正运行起来。
golang 在 runtime 层面拦截了可能导致线程阻塞的情况,并针对性优化,他们可分为两类:
- 网络 IO、channel 操作、锁:只阻塞 G,M、P 可用,即线程不会让出时间片
- 磁盘IO:阻塞 M,P 需要切换一个新的M,线程会让出时间片
用户态的goroutine调度有下面的优势:
(1) 上下文切换代价小: P 是G、M之间的桥梁,调度器对于goroutine的调度,很明显也会有切换,这个切换是很轻量的:
只涉及
- PC (程序计数器,标记当前执行的代码的位置)
- SP (当前执行的函数堆栈栈顶指针)
- BP 三个寄存器的值的修改;
而对比线程的上下文切换则需要陷入内核模式、以及16个寄存器的刷新。
(2) 内存占用小: 线程栈空间通常是2M, Goroutine栈空间最小是2k, golang可以轻松支持1w+的goroutine运行,而线程数量到达1k(此时基本就达到单机瓶颈了), 内存占用就到2G。
4. GO 协程调度时机
通常情况下:
go关键字产生的一个常规执行逻辑的goroutine,由P调度进队列,等到被M执行完之后,调度器P继续从本地队列调出G给到M执行,若没有则从其他队列/全局队列偷取G。
存在P的本地队列、全局队列、parked goroutines(阻塞的协程)
Go scheduler is not a preemptive scheduler but a cooperating scheduler. Being a cooperating scheduler means the scheduler needs well-defined user space events that happen at safe points in the code to make scheduling decisions. The followings are the opportunities for scheduling:
① The use of the keyword go
This is how we create a new goroutine, scheduler gain an opportunity when a new goroutine was created.
② 同步 and 信道操作
If an mutex, or channel operation call will cause the Goroutine to block, the scheduler can context-switch a new Goroutine to run. Once the Goroutine can run again, it will be re-queued automatically.
③ System calls
Including async and sync system calls, go has different way to deal with them. With async type like network request, a network poller would be used, goroutine that might block is moved to net poller, let the proccesor can execute the next one.
With sync type like file I/O, the current pair of G and M will be seperated from G, P, M model. Meawhile, a new machine would be created in order to keep the original G, P, M model working, and the block goroutine would be take back while system call finished.
④ Garbage collection
Since the GC runs using its own set of Goroutines, those Goroutines need time on an M to run, scheduler needs a opportunitt to handle that
系统调用(system call)又分为两种,同步和异步系统调用。
同步和异步系统调用是指在系统调用过程中,用户程序和操作系统之间的交互方式。
同步系统调用(Synchronous System Call)是指用户程序在进行系统调用时,必须等待系统完成操作并返回结果后才能继续执行。在进行同步系统调用时,用户程序会阻塞,直到系统调用完成。同步系统调用的优点是操作简单,易于实现,但缺点是会造成用户程序的阻塞,影响程序的响应性能。
异步系统调用(Asynchronous System Call)是指用户程序在进行系统调用时,可以在系统调用的同时继续执行其他操作,无需等待系统调用的完成。在进行异步系统调用时,用户程序不会阻塞,而是会通过回调函数等机制在系统调用完成后再进行处理。异步系统调用的优点是可以提高程序的并发性和响应性能,但缺点是实现较为复杂。
在实际的系统编程中,通常需要根据具体的需求和场景选择使用同步或异步系统调用。例如,在需要进行文件IO等较为简单的操作时,可以使用同步系统调用;在需要进行网络通信等较为复杂的操作时,可以使用异步系统调用,以提高程序的并发性和响应性能。
Go在1.14版本中接受了奥斯汀-克莱门茨(Austin Clements)的提案 。
增加了对非协作的抢占式调度的支持,这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的Goroutine。
5. goroutine生命周期
Go 必须对每个运行着的线程上的 Goroutine 进行调度和管理。
这个调度的功能被委托给了一个叫做 g0 的特殊的 goroutine, g0 是每个 OS 线程创建的第一个goroutine。
g0为新创建的goroutine
- 设置PC/SP寄存器
- 更新goroutine内部的 ID和status
Go 需要一种方法来了解 goroutine的结束。
这个控制是在 goroutine 的创建过程中,在创建 goroutine 时,Go在开启实际go执行片段之前,通过PC寄存器设置了SP寄存器的首个函数栈帧(名为goexit
的函数),这个技巧强制goroutine在结束工作后调用函数goexit
。
newg.sched.pc = funcPC(goexit) + sys.PCQuantum
newg.sched.g = guintptr(unsafe.Pointer(newg))
gostartcallfn(&newg.sched, fn)
//------------- ......
// adjust Gobuf as if it executed a call to fn with context ctxt
// and then did an immediate gosave.
func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
sp := buf.sp
...
sp -= sys.PtrSize
*(*uintptr)(unsafe.Pointer(sp)) = buf.pc
buf.sp = sp
buf.pc = uintptr(fn)
buf.ctxt = ctxt
}
https://www.sobyte.net/post/2022-02/where-is-goexit-from/
通常,调度指的是由 g0 按照特定策略找到下一个可执行 g 的过程. 而本小节谈及的调度类型是广义上的“调度”,指的是调度器 p 实现从执行一个 g 切换到另一个 g 的过程.
这种广义“调度”可分为几种类型:
(1)主动调度
一种用户主动执行让渡的方式,主要方式是,用户在执行代码中调用了 runtime.Gosched 方法,此时当前 g 会当让出执行权,主动进行队列等待下次被调度执行.
代码位于 runtime/proc.go
func Gosched() {
checkTimeouts()
mcall(gosched_m)
}
(2)被动调度
因当前不满足某种执行条件,g 可能会陷入阻塞态无法被调度,直到关注的条件达成后,g才从阻塞中被唤醒,重新进入可执行队列等待被调度.
常见的被动调度触发方式为因 channel 操作或互斥锁操作陷入阻塞等操作,底层会走进 gopark 方法.
代码位于 runtime/proc.go
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
// ...
mcall(park_m)
}
goready 方法通常与 gopark 方法成对出现,能够将 g 从阻塞态中恢复,重新进入等待执行的状态.
代码位于 runtime/proc.go
func goready(gp *g, traceskip int) {
systemstack(func() {
ready(gp, traceskip, true)
})
}
(3)正常调度:
g 中的执行任务已完成,g0 会将当前 g 置为死亡状态,发起新一轮调度.
(4)抢占调度:
倘若 g 执行系统调用超过指定的时长,且全局的 p 资源比较紧缺,此时将 p 和 g 解绑,抢占出来用于其他 g 的调度. 等 g 完成系统调用后,会重新进入可执行队列中等待被调度.
值得一提的是,前 3 种调度方式都由 m 下的 g0 完成,唯独抢占调度不同.
因为发起系统调用时需要打破用户态的边界进入内核态,此时 m 也会因系统调用而陷入僵直,无法主动完成抢占调度的行为.
因此,在 Golang 进程会有一个全局监控协程 monitor g 的存在,这个 g 会越过 p 直接与一个 m 进行绑定,不断轮询对所有 p 的执行状况进行监控. 倘若发现满足抢占调度的条件,则会从第三方的角度出手干预,主动发起该动作.
5. goroutine的常规实践
-
在一个函数前放置go即可开启一个go的 协程,如其他函数一样,可以有形参,不过函数返回值会被忽略。
-
在golang中, 大家习惯使用一个封装了业务逻辑的闭包来启动一个goroutine, 该闭包负责管理并发的数据和状态,例如闭包从信道中读取数据并传递给业务逻辑, 业务逻辑完全不知道它是在一个goroutine中,
然后函数的结果被写回另外一个信道,这种职责分离使代码模块化、可测试,并使得api调用简单,无需关注并发问题。
func process(val int) int {
}
func runningConcurrently(in <-chan int, out chan <- int) {
go func() { // 业务逻辑协程
for val := range in {
result := process(val)
out<- result // 利用信道来在协程间通信
}
}
}
ref
本文来自博客园,作者:{有态度的马甲},转载请注明原文链接:https://www.cnblogs.com/JulianHuang/p/16008107.html
欢迎关注我的原创技术、职场公众号, 加好友谈天说地,一起进化