goroutine 调度原理,生命周期

  1. 传统cpu调度背景 #操作系统原理#
  2. 线程切换
  • 行为
  • 时机
  • 代价
  1. go是怎样的思路
  • 将调度维持在用户态
  • 推出用户态runtime代码实现的轻量级线程
  1. go调度策略
  • 常规: 本地队列- 其他队列、全局队列
  • 协作式调度: 基于用户态事件
    -- 异步io: 网络: 基于netpoller
    -- 同步io: 产生M
  • 抢占式
  1. 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

image.png


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

posted @ 2022-03-15 13:58  博客猿马甲哥  阅读(1348)  评论(1编辑  收藏  举报