golang的runtime

650 阅读8分钟

go语言编译后的可执行文件由两部分组成,一部分是用户程序代码,一部分是runtime。runtime的作用是为了实现额外功能,在程序运行时自动加载/运行一些模块。

runtime由4部分组成:

  • Scheduler: 调度器管理所有的GMP,在后台执行调度循环。Goroutine 是由 Go 运行时调度器(Goroutine Scheduler)管理的,它会将多个 Goroutine 分配到少数的系统线程上执行,从而实现高效的并发调度和协作。
  • sysmon:是源码中的一个函数,内部定时循环执行一些操作,在执行main.main()之前启动一个系统监控线程执行这个函数,是Go程序中唯一一个不需要P就可以执行的线程。负责触发垃圾回收、调度抢占、网络轮询。
  • Memory Management: 当代码需要内存时,负责内存分配工作。
  • Garbage Collector: 当内存不再需要时,负责回收内存。
  • Netpoll: 网络轮询机制是在操作系统的IO多路复用机制和Go语言运行时两个不同的体系之间构建了一个桥梁。因为网络轮询机制可以把注册在epoll对象上的事件的变化转换为Goroutine状态的变化。当在Goroutine中产生对文件描述符的读写操作时,当文件描述符不可读或者不可写时,当前Goroutine会让出所在线程并进入阻塞状态。在系统监控线程中,会周期性的执行runtime.netpoll(),该函数基于操作系统的IO多路复用实现,当发现存在可操作的文件描述符后,会把因为这些文件描述符而阻塞的Goroutine唤醒,加入可执行队列等待调度。

1、Go的协程调度是由运行时调度器(Scheduler)完成的。调度器是Go运行时(runtime)的一部分,负责管理协程的创建、调度和销毁。调度器使用一个全局的运行队列(runqueue)来保存所有可运行的协程,通过在不同的P(Processor)之间调度协程来实现负载均衡。

在Go的运行时模型中,P代表处理器,M代表线程,G代表协程。一个P可以对应一个M和多个G,P用于执行G,M用于在操作系统线程上执行G,而G则是最小的调度单位。调度器会根据当前的系统负载情况和调度算法选择合适的P来执行可运行的协程。具体来说,调度器会在全局运行队列和本地运行队列之间进行协程调度,并动态地将协程绑定到不同的P上。

Go程序的系统协程

  • G0:在程序启动时,每个线程都会创建一个 G0 协程,并将其放到线程的 G 全局队列中。G0 协程是由 newproc 函数创建的,与用户协程类似,但它们具有一些特殊的属性和行为,例如不会被垃圾回收和阻塞调度。G0 协程会在程序退出时销毁。
  • M0:在程序启动时,运行时系统会创建一组 M 系统线程,并将它们放入空闲线程池中。当需要创建新的协程时,运行时系统会从线程池中选择一个空闲的 M 线程,并将其绑定到当前线程,以执行协程的函数。M0 系统线程是在启动时自动创建的,它们由 procresize 函数创建。
  • sysmon:启动了一个不会中止的循环,在循环的内部会轮询网络、抢占长期运行或者处于系统调用的 Goroutine 以及触发垃圾回收,通过这些行为,它能够让系统的运行状态变得更健康。
  • sysmon netpoller:网络轮询器是在程序启动时创建的,由 newm 函数在初始化 M0 线程时创建。在 M0 线程执行协程时,当协程需要进行 I/O 操作并将自己阻塞时,M0 线程会将协程的状态标记为阻塞状态,并将其放入等待队列中。然后,M0 线程会切换到网络轮询器上下文中,并等待 I/O 事件发生。当事件发生时,网络轮询器会将阻塞的协程重新放入调度队列中,以继续执行。
  • sweeper:清扫器是由垃圾回收器控制的,在程序运行时根据需要定期启动。清扫器在后台线程中运行,它会定期扫描堆内存中的垃圾对象,并将其回收。清扫器的启动和停止是由 gcStartgcStop 函数控制的,它们会根据程序的内存使用情况和负载情况动态调整扫描的频率和时间。
  • gsignal:信号处理
  • sysmon是由操作系统调度的,它运行在一个单独的系统线程中,由操作系统进行调度。因为sysmon需要进行系统级别的管理和调度,例如监控系统负载、处理信号、垃圾回收等,这些操作需要直接访问操作系统底层的资源和信息,所以sysmon必须以独立的系统线程的方式运行,并由操作系统进行调度。而Go语言的调度器则负责调度和管理用户协程和操作系统线程,与sysmon不同,它们是运行在用户空间中的,不直接访问操作系统底层的资源和信息,因此由Go语言的调度器进行调度。

特殊的M0和G0

M0

M0是启动程序后的编号为0的主线程,这个M对应的实例会在全局变量runtime.m0中,不需要在heap上分配,M0负责执行初始化操作和启动第一个G, 在之后M0就和其他的M一样了。

G0

G0是每次启动一个M都会第一个创建的gourtine,G0仅用于负责调度的G,G0不指向任何可执行的函数, 每个M都会有一个自己的G0。g0不会被垃圾回收,g0的生命周期与程序一致。

我们来跟踪一段代码

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}

接下来我们来针对上面的代码对调度器里面的结构做一个分析。

也会经历如上图所示的过程:

  1. runtime创建最初的线程m0和goroutine g0,并把2者关联。
  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
  3. 示例代码中的main函数是main.mainruntime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。
  4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。
  5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境
  6. M运行G
  7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。

值得注意的是,调度器并不是由每个P负责完成的。每个P都只是调度器管理的一部分资源,并负责执行可运行的协程。而调度器则负责协调P之间的协程调度,并在需要时进行P的创建、销毁和休眠。这种设计可以充分利用系统资源,实现高效的协程调度。

2、runtime.netpoll是Go语言运行时包(runtime package)中的一个组件,它实现了网络轮询机制。当一个Go程序使用标准库中的net包(比如TCP/UDP套接字)进行网络编程时,runtime.netpoll会负责监视底层的网络I/O事件(比如可读、可写、错误等),并将这些事件通知给Go程序中相应的协程。

具体来说,runtime.netpoll的主要工作是将I/O事件转换为协程状态的变化。当底层的网络I/O事件发生时,runtime.netpoll会通过操作系统提供的网络I/O多路复用技术(比如epoll、kqueue或者select等)监视这些事件,然后将事件转换为协程的状态变化(比如可读、可写、错误等),并将这些状态变化通知给调度器(scheduler)。调度器根据协程的状态变化来决定下一步应该执行哪个协程,从而实现网络I/O和协程之间的高效交互。

runtime.netpoll的实现采用了非常高效的机制,使用了操作系统提供的异步I/O多路复用技术,可以有效地减少网络I/O操作的等待时间,从而提高网络应用程序的性能。runtime.netpoll是Go语言中实现高性能网络编程的一个非常重要的组件,它在底层实现了网络轮询机制,并与调度器紧密结合,为Go语言提供了高效、可靠的网络编程支持。