当前位置: 首页 > news >正文

Go语言设计与实现 学习笔记 第六章 并发编程(3)

系统调用

系统调用对于Go语言调度器的调度也有比较大的影响,为了处理这些特殊的系统调用,我们甚至专门在Goroutine中加入了_Gsyscall这一状态,Go语言通过SyscallRawsyscall等使用汇编语言编写的方法封装了操作系统提供的所有系统调用,其中Syscall在Linux 386上的实现如下:

// 定义名为.Syscall的函数,该函数不允许栈分割,栈帧大小为0,有28字节的参数通过栈传递
TEXT .Syscall(SB),NOSPLIT,$0-28// 调用runtime.entersyscall,通知Go运行时该线程即将进入系统调用,可能会被阻塞CALL  runtime.entersyscall(SB)// 从参数帧中加载系统调用号、最多三个参数到相应寄存器MOVL  trap+0(FP), AX // syscall entryMOVL  a1+4(FP), BXMOVL  a2+8(FP), CXMOVL  a3+12(FP), DX// 清零寄存器SI、DI,防止未初始化的寄存器值被错误地用作系统调用参数MOVL  $0, SIMOVL  $0, DI// 执行系统调用的伪代码,实际系统调用的执行会依赖于平台,如在x86平台上,可能是SYSCALL指令INVOKE_SYSCALL// 系统调用的返回值存在寄存器AX中,比较两者大小CMPL  AX, $0fffff001// 如果系统调用返回值更小,说明成功返回,跳转到okJLS   ok// 如果系统调用失败,将返回值r1设为-1MOVL  $-1, r1+16(FP)// 将返回值r2设为0MOVL  $0, r2+20(FP)// 将寄存器AX中存放的系统调用返回值的负数存到err作为错误码NEGL  AXMOVL  AX, err+24(FP)// 通知Go运行时系统调用完成,处理可能的调用CALL  runtime.exitsyscall(SB)RET
ok:// 将系统调用结果存到r1和r2,错误字段设为0,通知Go运行时系统调用完成MOVL  AX, r1+16(FP)MOVL  DX, r2+20(FP)MOVL  $0, err+24(FP)CALL  runtime.exitsyscall(SB)RET

在真正通过汇编指令INVOKE_SYSCALL执行系统调用前后,都会调用运行时的entersyscallexitsyscall进行一些处理,正是这一层包装能够让我们在陷入系统调用之前触发调度器的一些操作,但是另外的Rawsyscall等方法就会省略调用运行时方法的过程。
在这里插入图片描述
进入系统调用

entersyscall函数会在获取当前PC程序计数器和SP栈指针之后调用reentersyscall,这个函数会完成绝大部分Goroutine进入系统调用之前的准备工作:

func reentersyscall(pc, sp uintptr) {_g_ := getg()// 增加当前M的锁计数_g_.m.locks++// 预设栈边界,达到时会触发栈增长_g_.stackguard0 = stackPreempt// 设置为在栈分裂时触发异常_g_.throwsplit = true// 保存进入系统调用前的执行状态save(pc, sp)// 保存系统调用前的sp和pc,用于恢复运行以及垃圾回收的扫描地址_g_.syscallsp = sp_g_.syscallpc = pc// 使用cas操作将Goroutine状态从_Grunning改为_Gsyscallcasgstatus(_g_, _Grunning, _Gsyscall)// 将P的syscalltick同步到M的syscalltick,用于跟踪系统调用次数_g_.m.syscalltick = _g_.m.p.ptr().syscalltick// 应该追踪系统调用的阻塞情况_g_.sysblocktraced = true// 进入系统调用时,清空M关联的缓存_g_.m.mcache = nil// 获取M关联的Ppp := _g_.m.p.ptr()// 取消P关联的Mpp.m = 0// 备份旧的P_g_.m.oldp.set(pp)// 取消M关联的P_g_.m.p = 0// 将P的状态原子地设为_Psyscallatomic.Store(&pp.status, _Psyscall)// 如果在垃圾收集等待中if sched.gcwaiting != 0 {// systemstack函数在系统栈上执行一段代码,此处是处理垃圾手机等待的代码systemstack(entersyscall_gcwait)// 再次保存状态,确保所有更改都被记录save(pc, sp)}// 减少M的锁计数_g_.m.locks--
}

1.禁止当前线程M上发生的抢占,防止出现内存不一致的问题;

2.保证当前行函数不会调用任何会导致当前栈分裂或者增长的函数;

3.保存当前的程序计数器PC和栈指针SP中的内容;

4.将Goroutine的状态更新至_Gsyscall

5.将Goroutine对应的处理器P和线程M暂时分离并更新处理器P的状态到_Psyscall

6.释放当前线程M上的锁;

需要注意的是reentersyscall方法会导致处理器P和线程M的分离,当前线程M会陷入系统调用等待返回,处理器P上其他的Goroutine在这时就可能被其他处理器“取走”并执行,避免饥饿问题的发生,这也是Go语言程序创建的线程数可能会多于GOMAXPROCS的原因。

退出系统调用

当系统调用结束之后,就会调用退出系统调用的函数exitsyscall为当前Goroutine重新分配一个新的CPU,这个函数有两个不同的执行路径,其中第一条是快速执行路径,也就是调用exitsyscallfast函数,另一条路径就是较慢的路径,它会切换至调度器的Goroutine并调用exitsyscall0函数:

func exitsyscall() {_g_ := getg()// 增加M的锁计数_g_.m.locks++// 重置Goroutine等待时间,因为它即将从系统调用返回_g_.waitsince = 0// 获取Goroutine进入系统调用时保存的旧P指针oldp := _g_.m.oldp.ptr()// 清空旧P指针_g_.m.oldp = 0// 如果快速从系统调用返回成功if exitsyscallfast(oldp) {// 增加P的系统调用计数_g_.m.p.ptr().syscalltick++// 使用cas将Goroutine的状态从_Gsyscall改为_Grunningcasgstatus(_g_, _Gsyscall, _Grunning)// 清除系统调用前保存的栈指针_g_.syscallsp = 0// 减少M锁计数_g_.m.locks--// 根据抢占标志设置栈保护区if _g_.preempt {_g_.stackguard0 = stackPreempt} else {_g_.stackguard0 = _g_.stack.lo + _StackGuard}// 栈分裂时,不再抛出异常_g_.throwsplit = falsereturn}// 不能快速从系统调用返回时,会走到此处// 执行清理操作_g_.sysexitticks = 0_g_.m.locks--// 调用exitsyscall0,以更通用的方式从系统调用返回mcall(exitsyscall0)// 清除系统调用前保存的栈指针_g_.syscallsp = 0// 增加P的系统调用计数_g_.m.p.ptr().syscalltick++// 栈分裂时,不再抛出异常_g_.throwsplit = false
}

这两种不同的路径会分别通过不同的方法查找一个用于执行当前Goroutine的处理器P,快速路径exitsyscallfast中包含两个不同的分支:
1.如果Goroutine的原处理器处于_Psyscall状态,就会直接调用wirep将Goroutine与处理器进行关联;

2.如果调度器中存在闲置的处理器,就会调用acquirep函数使用闲置的处理器处理当前Goroutine;

另一个相对较慢的路径exitsyscall0就会将当前Goroutine切换至_Grunnable状态,并移除线程M和当前Goroutine的关联:
1.当我们通过pidleget获取到闲置的处理器时就会在该处理器上执行Goroutine;

2.其他情况下,我们会将当前Goroutine放到全局的运行队列中,等待调度器的调度;

无论哪种情况,我们在这个函数中都会调用schedule函数触发调度器的调度,我们在上一节中已经介绍过调度器的调度过程,所以在这里就不展开介绍了。

运行时

我们需要注意的是,不是所有的系统调用都会调用entersyscallexitsyscall这两个运行时函数,出于性能的考虑,一些系统调用是不会调用这两个方法的,你可以在这个列表中查询到Go语言对Linux 386架构上不同系统调用的分类,我们在这里只简单展示其中的一部分内容:
在这里插入图片描述
由于直接进行系统调用会阻塞当前的线程,所以只有可以立刻返回的系统调用才可能会被“设置”成RawSyscall不被Go语言的调度器控制,例如:SYS_EPOLL_CREATESYS_EPOLL_WAIT(超时时间为0)、SYS_TIME等。

调度器启动

在我们创建Goroutine用于执行并发任务或者执行IO操作时都会触发调度器的调度,对于一个有经验的Go语言使用者,对于触发调度的方式和时机其实会有一些比较靠谱的推测的,但是调度器的启动却是我们平时比较难以接触的部分,我们在这里就介绍一下Golang调度器的启动过程:

func schedinit() {_g_ := getg()// ...sched.maxcount = 10000// ...sched.lastpoll = uint64(nanotime())// 先将处理器数量procs设为CPU核心数procs := ncpu// 然后获取环境变量GOMAXPROCS的值,如果存在,用其覆盖procs// 这使得用户可以通过设置环境变量来改变最大并行处理器数if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {procs = n}// 更新程序中处理器的数量if procresize(procs) != nil {throw("unknown runnable goroutine during bootstrap")}
}

在调度器初始函数执行的过程中会将maxmcount设置成10000,这是一个Go语言程序能够创建的最大线程数,虽然最多可以创建10000个线程,但是可以同时运行的线程还是由GOMAXPROCS这个环境变量控制。

我们从环境变量GOMAXPROCS获取了程序能够同时运行的最多处理器P后就会调用procresize更新程序中处理器的数量,在这时“整个世界”都会停止(不会有任何用户协程被执行),且调度器也会进入锁定状态,procresize的执行过程如下:

import "unsafe"func procresize(nprocs int32) *p {// 获取原来的最大处理器数量old := gomaxprocs// grow allp if necessary.(处理allp的大小)// ...// initialize new P's(初始化新创建的处理器P)// ..._g_ := getg()// 断开当前运行的P和M的关联,清空M的本地缓存if _g_.m.p != 0 {_g_.m.p.ptr().m = 0}_g_.m.p = 0_g_.m.mcache = nil// 将allp中第一个P的M设为0、状态设为闲置p := allp[0]p.m = 0p.status = _Pidle// 将p与当前M关联acquirep(p)// release resources from unused P's(释放不再需要的P)// ...// trim allp.(调整allp数组大小)// ...// 处理每个P的状态var runnablePs *pfor i := nprocs - 1; i >= 0; i-- {p := allp[i]// 跳过当前Pif _g_.m.p.ptr() == p {continue}// 将P的状态设置为闲置p.status = _Pidle// 如果P的运行队列为空if runqempty(p) {// 将P加入空闲队列pidleput(p)} else {// 分配一个M来运行Pp.m.set(mget())// 更新可运行P的列表p.link.set(runnablePs)runnablePs = p}}// 重置处理器的窃取算法的窃取顺序stealOrder.reset(uint32(nprocs))// 原子地更新最大处理器数量var int32p *int32 = &gomaxprocsatomic.Store((*uint32)(unsafe.Pointer(int32p)), uint32(nprocs))// 返回一个可运行P的列表return runnablePs
}

1.如果全局变量allp切片中的处理器数量少于期望数量就会对切片进行扩容;

2.使用new创建新的处理器结构体并调用init方法初始化刚刚扩容的处理器;

3.通过指针将线程m0和处理器allp[0]绑定到一起;

4.调用destroy方法释放不再使用的处理器结构;

5.通过截断改变全局变量allp的长度保证与期望处理器数量相等;

6.将除allp[0]外的处理器P全部设置成_Pidle并加入到全局空闲队列中;

调用procresize就是调度器启动的最后一步,在这一步过后调度器会完成相应数量处理器的启动,等待用户创建运行新的Goroutine并为Goroutine调度处理器资源。

总结

Goroutine和调度器是Go语言能够高效地处理任务且最大化利用资源的最主要原因,我们在这一节中介绍了Golang用于处理并发任务的M-G-P模型,包括它们各自的数据结构以及状态,除此之外我们还通过一些常见的场景介绍调度器的工作原理以及不同数据结构之间的协作关系,相信能够对各位读者理解调度器有一定的帮助。

理论上Goroutine的可创建数量没有限制,只有实际机器的内存等资源限制。

6.6 网络轮询器

在今天,大部分的服务都是IO密集型的,应用程序会花费大量时间等待IO操作执行完成。网络轮询器就是Go语言运行时用来处理IO操作的关键组件,它使用了操作系统提供的IO多路复用机制增强程序的并发处理能力。本节会深入分析Go语言网络轮询器的设计与实现原理。

6.6.1 设计原理

网络轮询器不仅用于监控网络IO,还能用于监控文件的IO,它利用了操作系统提供的IO多路复用模型来提升IO设备的利用率以及程序的性能。本节会分别介绍常见的几种IO模型以及Go语言运行时的网络轮询器如何使用多模块设计在不同的操作系统上支持多路复用。

IO模型

操作系统中包含阻塞IO、非阻塞IO、信号驱动IO、异步IO、IO多路复用五种IO模型。我们在本节中会介绍上述五种模型中的三种:
1.阻塞IO模型;

2.非阻塞IO模型;

3.IO多路复用模型;

在Unix和类Unix操作系统中,文件描述符(File descriptor,FD)是用于访问文件或者其他IO资源的抽象句柄,例如:管道或网络套接字。而不同的IO模型会使用不同的方式操作文件描述符。

阻塞IO

阻塞IO是最常见的IO模型,对文件和网络的读写操作在默认情况下都是阻塞的。当我们通过read或者write等系统调用对文件进行读写时,应用程序就可能会被阻塞:

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t nbytes);

如下图所示,当我们执行read系统调用时,应用程序会从用户态陷入内核态,内核会检查文件描述符是否可读;当文件描述符中存在数据时,操作系统内核会将准备好的数据拷贝给应用程序并将控制权交回。
在这里插入图片描述
操作系统中多数的IO操作都是如上所示的阻塞请求,一旦执行IO操作,应用程序就会陷入阻塞等待IO操作的结束。

非阻塞IO

当进程把一个文件描述符设置成非阻塞时,执行readwrite等IO操作就会立刻返回。在C语言中,我们可以使用如下所示的代码片段将一个文件描述符设置成非阻塞的:

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

在上述代码中,最关键的就是系统调用fcntl和参数O_NONBLOCKfcntl为我们提供了操作文件描述符的能力,我们可以通过它修改文件描述符的特性。当我们将文件描述符修改成非阻塞后,读写文件就会经历以下流程(实际上,read磁盘文件很少会失败,如硬件错误等,这里改为读写网络套接字更合适):
在这里插入图片描述
第一次从文件描述符中读取数据会触发系统调用并返回EAGAIN错误,EAGAIN意味着该文件描述符还在等待缓冲区中的数据;随后,应用程序会不断轮询调用read直到它的返回值大于0,这时应用程序就可以读取操作系统缓冲区中的数据并进行操作。进程使用非阻塞的IO操作时,可以在等待过程中执行其他的任务,增加CPU资源的利用率。

IO多路复用

IO多路复用被用来处理同一个事件循环中的多个IO事件。IO多路复用需要使用特定的系统调用,最常见的就是select,该函数可以同时监听最多1024个文件描述符的可读或可写状态:

int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restrict errorfds, struct timeval *restrict timeout);

除了标准的select函数之外,操作系统中还提供了一个比较类似的poll函数,它使用链表存储文件描述符,摆脱了1024的数量上限。
在这里插入图片描述
多路复用函数会阻塞地监听一组文件描述符,当其中至少一个文件描述符的状态转变为可读或可写时(应该还有异常时),select会返回可读或者可写事件(如上,还有异常事件)的个数,应用程序就可以在输入的文件描述符中(实际上,select函数会通过参数返回哪些fd可用,例如通过readfds参数返回可读的fd集合,通过writefds参数返回可写的fd集合)查找哪些可读或可写(或异常),然后执行相应的操作。
在这里插入图片描述
IO多路复用模型是效率较高的IO模型,它可以同时阻塞地监听一组文件描述符的状态。很多高性能的服务和应用程序都会使用这一模型来处理IO操作,例如:Redis、Nginx等。

多模块

Go语言在网络轮询器中使用IO多路复用模型处理IO操作,但是它没有选择最常见的系统调用select。虽然select也可以提供IO多路复用的能力,但是使用它有比较多的限制:
1.监听能力有限——最多只能监听1024个文件描述符;

2.内存拷贝开销大——需要维护一个较大的数据结构存储文件描述符,该结构需要拷贝到内核中;

3.时间复杂度O(n)——返回准备就绪的事件个数后,需要遍历所有的文件描述符(这一点有问题,select的时间复杂度是O(n),是因为select函数内部需要遍历所有文件描述符,而不是返回就绪事件后,因为select函数只会返回可用的fd);

为了提高IO多路复用的性能,不同的操作系统也都实现了自己的IO多路复用函数,例如:epollkqueueevport等。Go语言为了提高在不同操作系统上的IO操作性能,使用平台特定的函数实现了多个版本的网络轮询模块:
1.src/runtime/netpoll_epoll.go

2.src/runtime/netpoll_kqueue.go

3.src/runtime/netpoll_solaris.go

4.src/runtime/netpoll_windows.go

5.src/runtime/netpoll_aix.go

6.src/runtime/netpoll_fake.go

这些模块在不同平台上实现了相同的功能,构成了一个常见的树形结构。编译器在编译Go语言程序时,会根据目标平台选择树中特定的分支进行编译:
在这里插入图片描述
如果目标平台是Linux,那么就会根据文件中的// +build linux编译指令选择src/runtime/netpoll_epoll.go并使用epoll函数处理用户的IO操作。

接口

epollkqueuesolaris等多路复用模块都要实现以下五个函数,这五个函数构成一个虚拟的接口:

func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(delta int64) gList
func netpollBreak()
func netpollIsPollDescrptor(fd uintptr) bool

上述函数在网络轮询器中分别扮演了不同的作用:
1.runtime.netpollinit——初始化网络轮询器,通过sync.OncenetpollInited变量保证函数只会调用一次;

2.runtime.netpollopen——监听文件描述符上的边缘触发事件,创建事件并加入监听;

3.runtime.netpoll——轮询网络并返回一组已经准备就绪的Goroutine,传入的参数会决定它的行为:
(1)如果参数小于0,无限期等待文件描述符就绪;

(2)如果参数等于0,非阻塞地轮询网络;

(3)如果参数大于0,阻塞特定时间轮询网络;

4.runtime.netpollBreak——唤醒网络轮询器,例如:计时器向前修改时间时会通过该函数中断网络轮询器;

5.runtime.netpollIsPollDescriptor——判断文件描述符是否被轮询器使用;

我们在这里只需要了解多路复用模块中的几个函数,本节的后半部分会详细分析各个函数的实现原理。

6.6.2 数据结构

操作系统中IO多路复用函数会监控文件描述符的可读或可写,而Go语言网络轮询器会监听runtime.pollDesc结构体的状态,该结构会封装操作系统的文件描述符:

type pollDesc struct {link *pollDesclock mutexfd   uintptr// ...rseq uintptrrg   uintptrrt   timerrd   int64wseq uintptrwg   uintptrwt   timerwd   int64
}

该结构体中包含用于监控可读和可写状态的变量,我们按照功能将它们分成以下四组:
1.rseqwseq——表示文件描述符被重用或者计时器被重置(作者这里写的有点问题,rseq表示读操作的序列号,wseq表示写操作的序列号);

2.rgwg——表示二进制的信号量,可能为pdReadypdWait、等待文件描述符可读或可写的Goroutine、nil(作者这里写得有点问题,这两个字段并非是信号量,跟二进制也没什么关系,rg表示读等待组(read group),其中是等待读操作完成的Goroutine,wg同理,是等待写操作完成的Goroutine)。

3.rdwd——等待文件描述符可读或可写的截止日期(补充一下,读操作必须在rd前完成,写操作必须在wd之前完成,这两个字段分别是read deadline和write deadline的缩写);

4.rtwt——用于等待文件描述符的计时器(补充一下,rt是读操作的计时器,wt是写操作的计时器,用于控制读写操作的超时行为);

除了上述八个变量之外,该结构体中还保存了用于保护数据的互斥锁、文件描述符。runtime.pollDesc结构体会使用link字段串联成一个链表存储在runtime.pollCache中:

type pollCache struct {lock  mutexfirst *pollDesc
}

runtime.pollCache是运行时包中的全局变量,该结构体中包含一个用于保护轮询数据的互斥锁和链表头:
在这里插入图片描述
运行时会在第一次调用runtime.pollCache.alloc方法初始化总大小约为4KB的runtime.pollDesc结构体,runtime.persistentalloc会保证这些数据结构初始化在不会触发垃圾回收的内存中,让这些数据结构只能被内部的epollkqueue模块引用:

func (c *pollCache) alloc() *pollDesc {// 加锁保护c.firstlock(&c.lock)// 如果空闲列表为空if c.first == nil {// 获取pollDesc结构的大小const pdSize = unsafe.Sizeof(pollDesc{})// 一次能获取多少个pollDesc结构,pollBlockSize是预设的值n := pollBlockSize / pdSizeif n == 0 {n = 1}// 分配内存mem := persistentalloc(n*pdSize, 0, &memstats.other_sys)// 遍历每个分配的pollDesc结构,将其放到first链表头for i := uintptr(0); i < n; i++ {pd := (*pollDesc)(add(mem, i*pdSize))pd.link = c.firstc.first = pd}}// 从first链表中取出链表头pd,然后返回它pd := c.firstc.first = pd.linkunlock(&c.lock)return pd
}

每次调用该结构体都会返回链表头还没有被使用的runtime.pollDesc,这种批量初始化的做法能够增加网络轮询器的吞吐量。Go语言运行时会调用runtime.pollCache.free方法释放已经用完的runtime.pollDesc结构,它会直接将结构体插入链表的最前面:

func (c *pollCache) free(pd *pollDesc) {lock(&c.lock)pd.link = c.firstc.first = pdunlock(&c.lock)
}

上述方法没有重置runtime.pollDesc结构体中的字段,该结构体被重复利用时才会由runtime.poll_runtime_pollOpen函数重置。

6.6.3 多路复用

网络轮询器实际上就是对IO多路复用技术的封装,本节将通过以下三个过程分析网络轮询器的实现原理:
1.网络轮询器的初始化;

2.如何向网络轮询器中加入待监控的任务;

3.如何从网络轮询器中获取触发的事件;

上述三个过程包含了网络轮询器相关的方方面面,能够让我们对其实现有完整的理解。需注意的是,我们在分析实现时会遵循两个规则:
1.因为不同IO多路复用模块的实现大同小异,本节会使用Linux操作系统上的epoll实现;

2.因为处理读事件和写事件的逻辑类似,本节会省略写事件相关的代码;

初始化

因为文件IO、网络IO、计时器都依赖网络轮询器,所以Go语言会通过以下两条不同路径初始化网络轮询器:
1.internal/poll.pollDesc.init——通过net.netFD.initos.newFile初始化网络IO和文件IO的轮询信息时;

2.runtime.doaddtimer——向处理器中增加新的计时器时;

网络轮询器的初始化会使用runtime.poll_runtime_pollServerInitruntime.netpollGenericInit两个函数:

func poll_runtime_pollServerInit() {netpollGenericInit()
}func netpollGenericInit() {// 如果网络轮询器未被初始化if atomic.Load(&netpollInited) == 0 {// 加锁lock(&netpollInitLock)// 加锁后再次检查是否被初始化if netpollInited == 0 {// 执行真正的初始化工作netpollinit()// 原子地设为初始化状态atomic.Store(&netpollInited, 1)}unlock(&netpollInitLock)}
}

runtime.netpollGenericInit会调用平台上特定实现的runtime.netpollinit函数,即Linux上的epoll,它主要做了以下几件事情:
1.调用epollcreate1创建一个新的epoll文件描述符,这个文件描述符会在整个程序的生命周期中使用;

2.通过runtime.nonblockingPipe创建一个用于通信的管道;

3.使用epollctl将用于读取数据的文件描述符打包成epollevent事件加入监听;

var (// epoll文件描述符被赋值为-1,表示未初始化状态epfd int32 = -1// 管道的读端和写端netpollBreakRd, netpollBreakWr uintptr
)func netpollinit() {// 创建一个新的epoll实例,该epoll文件描述符不会被子进程继承epfd = epollcreate1(_EPOLL_CLOEXEC)// 创建一个无阻塞管道,获取其读端和写端r, w, _ := nonblockingPipe()// 创建一个epollevent结构体,用于表示epoll事件ev := epollevent {events: _EPOLLIN,}// 将管道读端作为epoll的ev.data,它是用户数据*(**uintptr)(unsafe.Pointer(&ev.data)) = &netpollBreakRd// 将管道读端加入epoll监听列表epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)// 保存管道读端和写端netpollBreakRd = uintptr(r)netpollBreakWr = uintptr(w)
}

初始化的管道为我们提供了中断多路复用等待文件描述符中事件的方法,runtime.netpollBreak函数会向管道中写入数据唤醒epoll

func netpollBreak() {for {var b byten := write(netpollBreakWr, unsafe.Pointer(&b), 1)if n == 1 {break}// 写操作被中断时重试if n == -_EINTR {continue}// 资源不可用时(如管道空间不足),直接返回if n == -_EAGAIN {return}}
}

因为目前的计时器由网络轮询器管理和触发,以上代码能够让网络轮询器立刻返回并让运行时检查是否有需要触发的计时器。

轮询事件

调用internal/poll.pollDesc.init初始化文件描述符时不止会初始化网络轮询器,还会通过runtime.poll_runtime_pollOpen函数重置轮询信息runtime.pollDesc(需要重置是因为free和首次分配时,没有初始化)并调用runtime.netpollopen初始化轮询事件:

// 初始化描述符fd的pollDesc
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {// 从缓存中获取一个新的pollDesc结构pd := pollcache.alloc()lock(&pd.lock)// 如果写等待组非0 && 该pollDesc不处于pReady状态(该状态下可以完成读写操作)// 即有Goroutine正在等待该pollDesc完成写操作if pd.wg != 0 && pd.wg != pReady {// 抛出异常,因为当前pollDesc是空闲的,但有其他在等待写完成的操作throw("runtime: blocked write on free polldesc")}// ...// 初始化pollDesc结构pd.fd = fd// 描述符不处于正在关闭状态pd.closing = false// 没有发生过错误pd.everr = false// ...// 增加写序列号pd.wseq++pd.wg = 0pd.wd = 0unlock(&pd.lock)var errno int32// 开始轮询fderrno = netpollopen(fd, pd)return pd, int(errno)
}

runtime.netpollopen的实现非常简单,它会调用epollctl向全局的轮询文件描述符epfd中加入新的轮询事件监听文件描述符的可读和可写状态:

func netpollopen(fd uintptr, pd *pollDesc) int32 {var ev epolleventev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET*(**pollDesc)(unsafe.Pointer(&ev.data)) = pdreturn -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}

从全局的epfd中删除待监听的文件描述符可以使用runtime.netpollclose函数,因为该函数的实现与runtime.netpollopen比较相似,所以这里就不展开分析了。

事件循环

本节将继续介绍网络轮询器的核心逻辑,也就是事件循环。我们将从以下两个部分介绍事件循环的实现原理:
1.Goroutine让出线程并等待读写事件;

2.多路复用等待读写事件的发生并返回;

上述过程连接了操作系统中的IO多路复用机制和Go语言的运行时,在两个不同体系之间构建了桥梁,我们将分别介绍上述两个过程。

等待事件

当我们在文件描述符上执行读写操作时,如果文件描述符不可读或者不可写,当前Goroutine就会执行runtime.poll_runtime_pollWait检查runtime.pollDesc的状态,runtime.poll_runtime_pollWait中会调用runtime.netpollblock等待文件描述符的可读或可写:

// 等待pollDesc在mode(读、写)下变得可用
func poll_runtime_pollWait(pd *pollDesc, mode int) int {// ...// 循环等待描述符变得可用,此处的netpollblock函数会阻塞等待for !netpollblock(pd, int32(mode), false) {...}return 0
}// 尝试阻塞调用者,直到文件描述符可用
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {// 根据mode获取对应等待组gpp := &pd.rgif mode == 'w' {gpp = &pd.wg}// ...// 如果需要等待io || netpollcheckerr函数没有检测到错误if waitio || netpollcheckerr(pd, mode) == 0 {// 休眠等待fd可用gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5)}// ...
}

runtime.netpollblock是Goroutine等待IO事件的关键函数,它会使用运行时提供的runtime.gopark让出当前线程,将Goroutine转换到休眠状态并等待运行时的唤醒。

轮询等待

Go语言的运行时会在调度或者系统监控中调用runtime.netpoll轮询网络,该函数的执行过程可分成以下几个部分:
1.根据传入的delay计算epoll系统调用需要等待的时间;

2.调用epollwait等待可读或者可写事件的发生;

3.在循环中依次处理epollevent事件;

因为传入delay的单位是纳秒,下面这段代码会将纳秒转换成毫秒:

func netpoll(delay int64) gList {// 决定轮询等待时间var waitms int32// 如果delay小于0,将waitms设为-1,表示无限期等待if delay < 0 {waitms = -1// waitms为0表示不等待,立即返回} else if delay == 0 {waitms = 0// 如果delay小于1ms,则将waitms设为1ms// 由于操作系统的时间精度限制,通常最小延时单位不低于1ms} else if delay < 1e6 {waitms = 1// 如果delay在1ms到1000000s之间,直接将其转换为ms} else if delay < 1e15 {waitms = int32(delay / 1e6)// 最多等待1000000秒} else {waitms = 1e9}

计算了需要等待的时间之后,runtime.netpoll会执行epollwait等待文件描述符转换成可读或可写,如果该函数返回了负值,就可能返回空的Goroutine列表(等待超时)或者重新调用epollwait陷入等待(没有指定超时时间):

    // 声明一个128个元素的epollevent结构,用于存放epoll返回的可用描述符var events [128]epollevent
retry:// 等待有描述符可用,n是返回的可用描述符数量n := epollwait(epfd, &events[0], int32(len(events)), waitms)// 如果返回错误if n < 0 {// 如果指定了超时时间(waitms是正数),说明是超时导致的返回if waitms > 0 {// 返回空Goroutine列表return gList{}}// 到此处说明waitms小于0,即永久等待,需要重新等待goto retry}

epollwait函数返回的值大于0时,意味着被监控的文件描述符出现了待处理的事件,我们在如下所示的循环中依次处理这些事件:

    // 遍历每个返回的可用描述符var toRun gListfor i := int32(0); i < n; i++ {ev := &events[i]// 如果是用于打破epoll阻塞的内部控制事件if *(**uintptr)(unsafe.Pointer(&ev.data)) == &netpollBreakRd {// ...continue}var mode int32// 如果是读相关的事件if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {mode += 'r'}// ...// 如果有读写事件发生if mode != 0 {// 获取该事件关联的pollDesc结构pd := *(**pollDesc)(unsafe.Pointer(&ev.data))pd.everr = false// netpollready将pd和mode加入toRun列表netpollready(&toRun, pd, mode)}}return toRun
}

处理的事件总共包含两种,一种是调用runtime.netpollBreak函数触发的事件,该函数的作用是中断网络轮询器;另一种是其他文件描述符的正常读写事件,对于这些事件,我们会交给runtime.netpollready处理:

func netpollready(toRun *gList, pd *pollDesc, mode int32) {var rg, wg *g// ...// 如果是写操作事件if mode == 'w' || mode == 'r'+'w' {// 解除等待写操作的Goroutine的阻塞wg = netpollunblock(pd, 'w', true)}// ...// 如果有等待写操作的Goroutineif wg != nil {// 将其加入toRun列表toRun.push(wg)}
}

runtime.netpollunblock会在读写事件发生时,将runtime.pollDesc中的读或写等待组转换成pdReady并返回其中存储的Goroutine;如果返回的Goroutine不为空,那么Goroutine就会被加入toRun列表,运行时会将列表中的全部Goroutine加入运行队列并等待调度器的调度。

截止日期

网络轮询器和计时器的关系非常紧密,这不仅仅是因为网络轮询器负责计时器的唤醒,还因为文件和网络IO的截止日期也由网络轮询器负责处理。截止日期在IO操作中,尤其是网络调用中很关键,网络请求存在很高的不确定因素,我们需要设置一个截止日期保证程序的正常运行,这时就需要用到网络轮询器中的runtime.poll_runtime_pollSetDeadline函数:

// 设置轮询描述符pollDesc的超时时间
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {// 先获取旧的读超时和写超时的时间rd0, wd0 := pd.rd, pd.wdif d > 0 {// 设置超时时间为从当前时间起的绝对时间加上dd += nanotime()}pd.rd = d// ...// 如果读超时函数未设置(可能是第一次设置超时时间,或者存在其他的超时函数变化的逻辑路径)if pd.rt.f == nil {// 如果设置了读超时时间if pd.rd > 0 {// 设置读超时函数pd.rt.f = netpollReadDeadlinepd.rt.arg = pdpd.rt.seq = pd.rseq// 重新设置定时器resettimer(&pd.rt, pd.rd)}// 如果读超时时间有改变} else if pd.rd != rd0 {// 超时值的更改往往涉及序列号rseq更新,避免旧数据的影响,保证每次超时处理都是基于最新值的pd.rseq++// 如果设置了超时时间if pd.rd > 0 {// 修改定时器modtimer(&pd.rt, pd.rd, 0, rtf, pd, pd.rseq)// 否则需要删掉定时器} else {deltimer(&pd.rt)pd.rt.f = nil}}

该函数会先使用截止日期计算出过期的时间点,然后根据runtime.pollDesc的状态做出以下不同的处理:
1.如果结构体中的计时器没有设置执行的函数时,该函数会设置计时器到期后执行的函数、传入的参数,然后调用runtime.resettimer重置计时器;

2.如果结构体的读截止日期已经被改变,我们会根据新的截止日期做出不同的处理:
(1)如果新的截止日期大于0,调用runtime.modtimer修改计时器;

(2)如果新的截止日期小于0,调用runtime.deltimer删除计时器;

runtime.poll_runtime_pollSetDeadline函数的最后,会重新检查轮询信息中存储的截止日期:

    var rg *g// 如果读deadline小于0if pd.rd < 0 {// 这里为什么要嵌套?作者只截取了一部分代码,其实此处同时处理了读和写的截止日期// 这里是处理读deadline的部分,这样截断让嵌套看起来毫无意义if pd.rd < 0 {// 解除等待读操作的Goroutine阻塞rg = netpollunblock(pd, 'r', false)}// ...}// 如果有Goroutine在等待读操作if rg != nil {// 唤醒等待的Goroutinenetpollgoready(rg, 3)}// ...
}

如果截止日期小于0,上述代码会调用runtime.netpollgoready直接唤醒对应的Goroutine。

runtime.poll_runtime_pollSetDeadline函数中直接调用runtime.netpollgoready是相对比较特殊的情况。正常情况下,运行时都会在计时器到期时调用runtime.netpollDeadlineruntime.netpollReadDeadlineruntime.netpollWriteDeadline三个函数:
在这里插入图片描述
上述三个函数都会通过runtime.netpolldeadlineimpl调用runtime.netpollgoready直接唤醒相应的Goroutine:

func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) {// 获取当前操作(读或写)的序列号currentSeq := pd.rseqif !read {currentSeq = pd.wseq}// 如果序列号不匹配(说明我们关心的事件已取消或过时)if seq != currentSeq {return}var rg *g// 如果是读超时if read {// 将读deadline设为-1,表示已过时或取消pd.rd = -1// 以无写屏障的原子操作清除读超时处理函数// 无写屏障防止内存屏障引入额外开销,提高性能atomic.StoreNoWB(unsafe.Pointer(&pd.rt.f), nil)// 获取该pollDesc上因读操作而阻塞的Goroutinerg = netpollunblock(pd, 'r', false)}// ...// 如果有因读操作而阻塞的Goroutineif rg != nil {// 解除其阻塞netpollgoready(rg, 0)}// ...
}

Goroutine在被唤醒之后就会意识到当前IO操作已经超时,可以根据需要选择重试请求或中止调用。

6.6.4 小结

网络轮询器并不是由运行时中的某一个线程独立运行的,运行时中的调度和系统调用会通过runtime.netpoll与网络轮询器交换消息,获取待执行的Goroutine列表,并将待执行的Goroutine加入运行队列等待处理。

所有的文件IO、网络IO、计时器都是由网络轮询器管理的,它是Go语言运行时重要的组成部分。

6.7 系统监控

很多系统中都有守护进程,他们能够在后台监控系统的运行状态,在出现意外情况时及时响应。系统监控是Go语言运行时的重要组成部分,它会每隔一段时间检查Go语言运行时,确保程序没有进入异常状态。本节会介绍Go语言系统监控的设计与实现原理,包括它的启动、执行过程、主要职责。

6.7.1 设计原理

在支持多任务的操作系统中,守护进程(Daemon)是在后台运行的计算机程序。守护进程不会由用户直接操作,它一般会在操作系统启动时自动运行。Kubernetes的DaemonSet和Go语言的系统监控都使用类似设计提供一些通用的功能:
在这里插入图片描述
守护进程是很有效的设计,它在整个系统的生命周期中都会存在,会随着系统的启动而启动,系统的结束而结束。在操作系统和Kubernetes中,我们经常会将数据库服务、日志服务、监控服务等进程作为守护进程运行。

Go语言的系统监控也起到了很重要的作用,它在内部启动了一个不会中止的循环,在循环的内部会轮询网络、抢占长期运行或处于系统调用的Goroutine、触发垃圾回收,通过这些行为,它能够让系统的运行状态变得更健康。

6.7.2 监控循环

当Go语言启动时,运行时会在第一个Goroutine中调用runtime.main启动主程序,该函数会在系统栈中创建新的线程:

func main() {// ...// 如果架构不是wasm(wasm代表WebAssembly,它通常在浏览器环境中运行,可能不支持某些系统级操作)if GOARCH != "wasm" {// systemstack函数在系统栈上执行某函数// 系统栈用于执行系统级别,不依赖特定Goroutine上下文的操作// newm函数用于创建新的系统线程结构M,在M上运行系统监视器函数sysmonsystemstack(func() {// 第二个参数是处理器P结构,此处传nilnewm(sysmon, nil)})}// ...
}

runtime.newm会创建一个存储待执行函数和处理器的新结构体runtime.m(即操作系统线程结构体M)。运行时执行系统监控不需要处理器(即P),系统监控的Goroutine会直接在创建的线程上运行:

func newm(fn func(), _p_ *p) {// 分配一个M结构体mp := allocm(_p_, fn)// 保存M应该处理的Pmp.nextp.set(_p_)// 设置信号掩码mp.sigmask = initSigmask// ...// 通过M结构创建具体的操作系统线程newm1(mp)
}

runtime.newm1会调用特定平台的runtime.newosproc通过系统调用clone创建一个新的线程并在新的线程中执行runtime.mstart

func newosproc(mp *m) {// 获取m的系统栈栈顶// mp.g0是与m关联的g0 Goroutine,它是每个M固有的系统栈,stack.hi是栈顶指针stk := unsafe.Pointer(mp.g0.stack.hi)var oset sigset// 阻塞所有信号(sigset_all),并将旧信号保存在oset中sigprocmask(_SIG_SETMASK, &sigset_all, &oset)// 使用系统调用close创建新线程,使其运行mstart函数ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))// 恢复信号掩码sigprocmask(_SIG_SETMASK, &oset, nil)// ...
}

在新创建的线程中,我们会执行存储在runtime.m结构体中的runtime.sysmon函数启动系统监控:

func sysmon() {// 增加系统线程计数sched.nmsys++// 检查死锁checkdead()lasttrace := int64(0)// idle是累计多少个循环没有唤起过Goroutine了idle := 0delay := uint32(0)for {if idle == 0 {delay = 20} else if idle > 50 {delay *= 2}if delay > 10*1000 {delay = 10*1000}usleep(delay)// ...}
}

当运行时刚刚调用上述函数时,会先通过runtime.checkdead检查是否存在死锁,然后进入核心的监控循环;系统监控在每次循环开始时都会通过usleep挂起当前线程,该函数的参数是微秒,运行时会遵循以下规则决定休眠时间:
1.初始的休眠时间是20us;

2.最长的休眠时间是10ms;

3.当系统监控在50个循环中都没有唤醒Goroutine时,休眠时间会倍增;

当程序趋于稳定之后,系统监控的出发时间就会稳定在10ms。它除了会检查死锁外,还会在循环中完成以下工作:
1.运行计时器——获取下一个需要被触发的计时器;

2.轮询网络——获取需要处理的到期文件描述符;

3.抢占处理器——抢占运行时间较长的或处于系统调用的Goroutine;

4.垃圾回收——在满足条件时触发垃圾收集来回收内存;

我们在这一节会介绍系统监控是如何处理五种不同工作的。

检查死锁

系统监控通过runtime.checkdead检查运行时是否发生了死锁,我们可将检查死锁的过程分成以下三个步骤:
1.检查是否存在正在运行的线程;

2.检查是否存在正在运行的Goroutine;

3.检查处理器上是否存在计时器;

该函数首先会检查Go语言运行时中正在运行的线程数量,我们通过调度器中的多个字段计算该值的结果:

func checkdead() {var run0 int32run := mcount() - sched.nmidle - sched.nmidlelocked - sched.nmsys// 如果还有线程在运行if run > run0 {return}if run < 0 {print("runtime: checkdead: nmidle=", sched.nmidle, " nmidlelocked=", sched.nmidlelocked, " mcount=", mcount(), " nmsys=", sched.nmsys, "\n")throw("checkdead: inconsistent counts")}// ...
}

1.runtime.mcount根据下一个待创建的线程id和释放的线程数得到系统中存在的线程数;

2.nmidle是处于空闲状态的线程数量;

3.nmidlelocked是处于锁定状态的线程数量;

4.nmsys是处于系统调用的线程数量;

利用上述几个线程相关数据,我们可以得到正在运行的线程数,如果大于0,说明当前程序不存在死锁(作者上面给出的代码不能说明当前程序不存在死锁,只能说明有线程在运行,可能有部分Goroutine发生死锁);如果小于0,说明当前程序的状态不一致;如果线程数等于0,我们需要进一步检查程序的运行状态:

func checkdead() {// ...// 处于等待或被抢占状态的Goroutine计数grunning := 0// 遍历所有Goroutinefor i := 0; i < len(allgs); i++ {gp := allgs[i]// 跳过系统Goroutine(如垃圾回收器的Goroutine)if isSystemGoroutine(gp, false) {continue}// 获取Goroutine的状态s := readgstatus(gp)// 清除_Gscan标志位,因为它是辅助标志,用于垃圾收集switch s &^ _Gscan {// 如果它处于等待或被抢占状态,意味着它仍然是活跃的case _Gwaiting, _Gpreempted:grunning++// 如果它处于可运行、正在运行、系统调用状态case _Grunnable, _Grunning, _Gsyscall:// 抛出异常,因为这些状态的Goroutine不应该出现在这里// 应该在前面已经返回,这些情况下正在运行的线程数不可能为0print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")throw("checkdead: runnable g")}}unlock(&allglock)// 如果正在运行的Goroutine为0if grunning == 0 {// 出现死锁,抛异常throw("no goroutines (main called runtime.Goexit) - deadlock!")}// ...
}

1.当存在Goroutine处于_Grunnable_Grunning_Gsyscall状态时,意味着程序发生了死锁(作者这里写得有点问题,这些状态意味着Go语言本身出错,因为程序走到这里不可能出现这种状态的Goroutine);

2.当所有的Goroutine都处于_Gidle_Gdead_Gcopystack状态时,意味着主程序调用了runtime.goexit(以上代码中,如果grunning为0,说明除了系统Goroutine外没有其他用户Goroutine在执行了,我们可以直接启动go程序,然后调用runtime.goexit出现此异常);

当运行时存在等待的Goroutine且不存在正在运行的Goroutine时,我们会检查处理器中存在的计时器:

func checkdead() {// ...// 遍历所有处理器Pfor _, _p_ := range allp {// 如果处理器上有定时任务,那么所有Goroutine陷入休眠就是合理的if len(_p_.timers) > 0 {return}}throw("all goroutines are asleep - deadlock!")
}

如果处理器中存在等待的计时器,那么所有的Goroutine陷入休眠状态是合理的,不过如果不存在等待的计时器,运行时就会直接报错并退出程序。

运行计时器

在系统监控的循环中,我们通过runtime.nanotimeruntime.timeSleepUntil获取当前时间和计时器下一次需要唤醒的时间;当前调度器需要执行垃圾回收或者所有处理器都处于闲置状态时,如果没有需要触发的计时器,那么系统监控可以暂时陷入休眠:

func sysmon() {// ...for {// ...now := nanotime()next, _ := timeSleepUntil()// 如果debug选项条件符合(一个开关) && (没有垃圾需要回收 || 空闲P数量等于最大P数量)if debug.schedtrace <= 0 && (sched.gcwaiting != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs)) {// 加锁并再次检查lock(&sched.lock)if atomic.Load(&sched.gcwaiting) != 0 || atomic.Load(&sched.npidle) == uint32(gomaxprocs) {// 如果下一个计时器触发时间在未来,处理睡眠逻辑if next > now {// 设置sysmon的等待状态atomic.Store(&sched.sysmonwait, 1)// 解锁调度器unlock(&sched.lock)// 睡眠时间先设为强制gc周期的一半sleep := forcegcperiod / 2// 如果下一个事件比sleep时间短if next-now < sleep {// 将睡眠时间设为到下一个事件的时间sleep = next - now}// ...// sysmon进入睡眠notesleep(&sched.sysmonnote, sleep)// ...// sysmon被唤醒,重新计算当前时间和下一个事件的时间now = nanotime()next, _ = timeSleepUntil()// 调度器加锁lock(&sched.lock)// 修改sysmon等待状态atomic.Store(&sched.sysmonwait, 0)noteclear(&sched.sysmonnote)}idle = 0delay = 20}unlock(&sched.lock)}// ...// 如果下一个事件该执行了if next < now {// 启动一个M执行它startm(nil, false)}}
}

休眠的时间会依据强制GC的周期forcegcperiod和计时器下次触发的时间确定,runtime.notesleep会使用信号量同步系统监控即将进入休眠的状态。当系统监控被唤醒之后,我们会重新计算当前时间和下一个计时器需要触发的时间、调用runtime.noteclear通知系统监控被唤醒、重置休眠的间隔。

如果在这之后,我们发现下一个计时器需要触发的时间小于当前时间,这也就说明所有的线程可能正在忙于运行Goroutine,系统监控会启动新的线程来触发计时器,避免计时器的到期时间有较大偏差。

轮询网络

如果上一次轮询网络已经过去了10ms,那么系统监控还会在循环中轮询网络,检查是否有待执行的文件描述符:

func sysmon() {// ...for {// ...// 原子地获取上次网络轮询的时间lastpoll := int64(atomic.Load64(&sched.lastpoll))// 如果网络轮询机制已经初始化 && 已经进行过一次网络轮询 && 上次网络轮询在10ms前if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {// 使用cas操作原子地更新上次网络轮询的时间atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))// 进行一次非阻塞的网络轮询操作list := netpoll(0)// 如果网络轮询的结果非空(即有阻塞的Goroutine需要执行)if !list.empty() {// 修改调度器状态,一个处理器正在处理网络轮询状态incidlelocked(-1)// 将网络轮询相关的Goroutine插入调度器,准备唤醒执行injectglist(&list)// 处理完后恢复一个空闲的处理器incidlelocked(1)}}// ...}
}

上述函数会非阻塞地调用runtime.netpoll检查待执行的文件描述符并通过runtime.injectglist将所有处于就绪状态的Goroutine加入全局运行队列中:

func injectglist(glist *gList) {if glist.empty() {return}lock(&sched.lock)var n int// 遍历每一个传入的Goroutinefor n = 0; !glist.empty(); n++ {gp := glist.pop()// 原子地将该Goroutine的状态从_Gwaiting改为_Grunnablecasgstatus(gp, _Gwaiting, _Grunnable)// 将该Goroutine放入全局运行队列globrunqput(gp)}unlock(&sched.lock)// 如果有空闲P可用for ; n != 0 && sched.npidle != 0; n-- {// 启动一个M来处理该Goroutinestartm(nil, false)}*glist = gList{}
}

该函数会将所有Goroutine的状态从_Gwaiting切换至_Grunnable并加入全局运行队列等待运行,如果当前程序中存在空闲的处理器,就会通过runtime.startm函数启动线程来执行这些任务。

抢占处理器

系统调用会在循环中调用runtime.retake函数抢占处于运行或者系统调用中的处理器,该函数会遍历运行时的全局处理器,每个处理器都存储了一个runtime.sysmontick结构体:

type sysmontick struct {// 处理器的调度次数schedtick   uint32// 处理器上次调度时间schedwhen   int64// 系统调用的次数syscalltick uint32// 上次系统调用的时间syscallwhen int64
}

runtime.retake中的循环包含了两种不同的抢占逻辑:

// 抢占的目的在于公平地分享CPU时间,防止某个运行时间过长的Goroutine长期占据处理器资源
func retake(now int64) uint32 {n := 0// 遍历所有处理器Pfor i := 0; i < len(allp); i++ {_p_ := allp[i]pd := &_p_.sysmonticks := _p_.status// 如果处理器状态是_Prunning或_Psyscall if s == _Prunning || s == _Psyscall {t := int64(_p_.schedtick)// 如果距离上次处理器调度已经过了forcePreemptNSif pd.schedwhen+forcePreemptNS <= now {// 对处理器进行抢占preemptone(_p_)}}// 对于处于系统调用状态的处理器if s == _Psyscall {// 如果该处理器运行队列为空 && 有正在自旋的处理器或空闲处理器 && 距上次系统调用时间已过10msif runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {continue}// 将处理器状态从_Psyscall改为_Pidleif atomic.Cas(&_p_.status, s, _Pidle) {// 递增改为空闲状态的处理器数n++// 增加系统调用次数_p_.syscalltick++// 让出处理器handoffp(_p_)}}}return uint32(n)
}

1.当处理器处于_Prunning或者_Psyscall状态时,如果上一次触发调度的时间已经过去了10ms,我们就会通过runtime.preemptone抢占当前处理器;

2.当处理器处于_Psyscall状态时,在满足以下两种情况下会调用runtime.handoffp让出处理器的使用权:
(1)当处理器的运行队列不为空或者不存在空闲处理器时;

(2)当系统调用时间超过了10ms时;

系统监控通过在循环中抢占处理器来避免同一个Goroutine占用线程太长时间造成饥饿问题。

垃圾回收

在最后,系统监控还会决定是否需要触发强制垃圾回收,runtime.sysmon会构建runtime.gcTrigger结构体并调用runtime.gcTrigger.test函数判断是否需要触发垃圾回收:

func sysmon() {// ...for {// ...// 如果需要触发垃圾回收 && 当前没有进行垃圾回收if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {lock(&forcegc.lock)// 标记在进行垃圾回收forcegc.idle = 0var list gList// 将垃圾回收的Goroutine加入list,list中存储要执行的Goroutinelist.push(forcegc.g)// 将list中的Goroutine加入调度器的调度队列injectglist(&list)unlock(&forcegc.lock)}// ...}
}

如果需要触发垃圾回收,我们会将用于垃圾回收的Goroutine加入全局队列,让调度器选择合适的处理器去执行。

6.7.3 小结

运行时通过系统监控来触发线程的抢占、网络的轮询、垃圾回收,保证Go语言运行时的可用性。系统监控能够很好地解决尾延迟问题,减少调度器调度Goroutine的饥饿问题并保证计时器在尽可能准确的时间触发。

相关文章:

  • python基础语法十一-赋值、浅拷贝、深拷贝
  • 零知识证明在BSV网络上的应用
  • YOLOv8/v10+DeepSORT多目标车辆跟踪(车辆检测/跟踪/车辆计数/测速/禁停区域/绘制进出线/绘制禁停区域/车道车辆统计)
  • Flask中实现WebSocket需要什么组件
  • 如何在mac上玩使命召唤手游?苹果电脑好玩的第一人称射击游戏推荐
  • 面对Redis数据量庞大时的应对策略
  • 【Linux 内核构建】如何查看 Linux 系统中可以选择的内核有哪一些?如何切换 Linux kernel 版本?
  • docker 安装 rabbitmq
  • Java内存马系列 | SpringMVC内存马 - 下 | SpringMVC 内存马分析
  • 人工智能在行动:利用人工智能扩展您的显示和视频工作
  • AI学习指南深度学习篇-带动量的随机梯度下降法的基本原理
  • QT6聊天室项目 网络通信实现逻辑分析
  • 优化算法与正则化
  • Tomcat服务详解
  • 我与Linux的爱恋:yum和vim以及gcc的使用
  • 【编码】-360实习笔试编程题(二)-2016.03.29
  • 2017-09-12 前端日报
  • CSS中外联样式表代表的含义
  • iOS 系统授权开发
  • Java方法详解
  • python3 使用 asyncio 代替线程
  • socket.io+express实现聊天室的思考(三)
  • Synchronized 关键字使用、底层原理、JDK1.6 之后的底层优化以及 和ReenTrantLock 的对比...
  • Vue全家桶实现一个Web App
  • webpack项目中使用grunt监听文件变动自动打包编译
  • 阿里云应用高可用服务公测发布
  • 看域名解析域名安全对SEO的影响
  • 力扣(LeetCode)21
  • 每个JavaScript开发人员应阅读的书【1】 - JavaScript: The Good Parts
  • 实现菜单下拉伸展折叠效果demo
  • 思否第一天
  • 微信小程序设置上一页数据
  • 学习笔记DL002:AI、机器学习、表示学习、深度学习,第一次大衰退
  • 用Visual Studio开发以太坊智能合约
  • 智能合约开发环境搭建及Hello World合约
  • 追踪解析 FutureTask 源码
  • ​经​纬​恒​润​二​面​​三​七​互​娱​一​面​​元​象​二​面​
  • #预处理和函数的对比以及条件编译
  • $ is not function   和JQUERY 命名 冲突的解说 Jquer问题 (
  • (01)ORB-SLAM2源码无死角解析-(66) BA优化(g2o)→闭环线程:Optimizer::GlobalBundleAdjustemnt→全局优化
  • (Java入门)抽象类,接口,内部类
  • (几何:六边形面积)编写程序,提示用户输入六边形的边长,然后显示它的面积。
  • (四)鸿鹄云架构一服务注册中心
  • (一)C语言之入门:使用Visual Studio Community 2022运行hello world
  • (一)springboot2.7.6集成activit5.23.0之集成引擎
  • (转)视频码率,帧率和分辨率的联系与区别
  • .libPaths()设置包加载目录
  • .NET CLR基本术语
  • .NET Core 版本不支持的问题
  • .NET MAUI学习笔记——2.构建第一个程序_初级篇
  • .Net+SQL Server企业应用性能优化笔记4——精确查找瓶颈
  • .NetCore项目nginx发布
  • .NET国产化改造探索(一)、VMware安装银河麒麟
  • @Autowired注解的实现原理
  • @Bean注解详解