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

系统调用

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

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

go 复制代码
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改为_Gsyscall
    casgstatus(_g_, _Grunning, _Gsyscall)
    
    // 将P的syscalltick同步到M的syscalltick,用于跟踪系统调用次数
    _g_.m.syscalltick = _g_.m.p.ptr().syscalltick
    // 应该追踪系统调用的阻塞情况
    _g_.sysblocktraced = true
    // 进入系统调用时,清空M关联的缓存
    _g_.m.mcache = nil
    // 获取M关联的P
    pp := _g_.m.p.ptr()
    // 取消P关联的M
    pp.m = 0
    // 备份旧的P
    _g_.m.oldp.set(pp)
    // 取消M关联的P
    _g_.m.p = 0
    // 将P的状态原子地设为_Psyscall
    atomic.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函数:

go 复制代码
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改为_Grunning
        casgstatus(_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 = false
        
        return
    }
    // 不能快速从系统调用返回时,会走到此处
    
    // 执行清理操作
    _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调度器的启动过程:

go 复制代码
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的执行过程如下:

go 复制代码
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 = 0
    p.status = _Pidle
    // 将p与当前M关联
    acquirep(p)
    
    // release resources from unused P's(释放不再需要的P)
    // ...
    
    // trim allp.(调整allp数组大小)
    // ...
    
    // 处理每个P的状态
    var runnablePs *p
    for i := nprocs - 1; i >= 0; i-- {
        p := allp[i]
        // 跳过当前P
        if _g_.m.p.ptr() == p {
            continue
        }
        // 将P的状态设置为闲置
        p.status = _Pidle
        // 如果P的运行队列为空
        if runqempty(p) {
            // 将P加入空闲队列
            pidleput(p)
        } else {
            // 分配一个M来运行P
            p.m.set(mget())
            // 更新可运行P的列表
            p.link.set(runnablePs)
            runnablePs = p
        }
    }
    // 重置处理器的窃取算法的窃取顺序
    stealOrder.reset(uint32(nprocs))
    // 原子地更新最大处理器数量
    var int32p *int32 = &gomaxprocs
    atomic.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等系统调用对文件进行读写时,应用程序就可能会被阻塞:

c 复制代码
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语言中,我们可以使用如下所示的代码片段将一个文件描述符设置成非阻塞的:

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个文件描述符的可读或可写状态:

c 复制代码
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等多路复用模块都要实现以下五个函数,这五个函数构成一个虚拟的接口:

go 复制代码
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结构体的状态,该结构会封装操作系统的文件描述符:

go 复制代码
type pollDesc struct {
    link *pollDesc
    
    lock mutex
    fd   uintptr
    // ...
    rseq uintptr
    rg   uintptr
    rt   timer
    rd   int64
    wseq uintptr
    wg   uintptr
    wt   timer
    wd   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中:

go 复制代码
type pollCache struct {
    lock  mutex
    first *pollDesc
}

runtime.pollCache是运行时包中的全局变量,该结构体中包含一个用于保护轮询数据的互斥锁和链表头:

运行时会在第一次调用runtime.pollCache.alloc方法初始化总大小约为4KB的runtime.pollDesc结构体,runtime.persistentalloc会保证这些数据结构初始化在不会触发垃圾回收的内存中,让这些数据结构只能被内部的epollkqueue模块引用:

go 复制代码
func (c *pollCache) alloc() *pollDesc {
    // 加锁保护c.first
    lock(&c.lock)
    // 如果空闲列表为空
    if c.first == nil {
        // 获取pollDesc结构的大小
        const pdSize = unsafe.Sizeof(pollDesc{})
        // 一次能获取多少个pollDesc结构,pollBlockSize是预设的值
        n := pollBlockSize / pdSize
        if 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.first
            c.first = pd
        }
    }
    // 从first链表中取出链表头pd,然后返回它
    pd := c.first
    c.first = pd.link
    unlock(&c.lock)
    return pd
}

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

go 复制代码
func (c *pollCache) free(pd *pollDesc) {
    lock(&c.lock)
    pd.link = c.first
    c.first = pd
    unlock(&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两个函数:

go 复制代码
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事件加入监听;

go 复制代码
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

go 复制代码
func netpollBreak() {
    for {
        var b byte
        n := 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初始化轮询事件:

go 复制代码
// 初始化描述符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 = 0
    pd.wd = 0
    unlock(&pd.lock)
    
    var errno int32
    // 开始轮询fd
    errno = netpollopen(fd, pd)
    return pd, int(errno)
}

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

go 复制代码
func netpollopen(fd uintptr, pd *pollDesc) int32 {
    var ev epollevent
    ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
    *(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
    return -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等待文件描述符的可读或可写:

go 复制代码
// 等待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.rg
    if 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的单位是纳秒,下面这段代码会将纳秒转换成毫秒:

go 复制代码
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陷入等待(没有指定超时时间):

go 复制代码
    // 声明一个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时,意味着被监控的文件描述符出现了待处理的事件,我们在如下所示的循环中依次处理这些事件:

go 复制代码
    // 遍历每个返回的可用描述符
    var toRun gList
    for 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处理:

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

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

截止日期

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

go 复制代码
// 设置轮询描述符pollDesc的超时时间
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
    // 先获取旧的读超时和写超时的时间
    rd0, wd0 := pd.rd, pd.wd
    if d > 0 {
        // 设置超时时间为从当前时间起的绝对时间加上d
        d += nanotime()
    }
    pd.rd = d
    // ...
    // 如果读超时函数未设置(可能是第一次设置超时时间,或者存在其他的超时函数变化的逻辑路径)
    if pd.rt.f == nil {
        // 如果设置了读超时时间
        if pd.rd > 0 {
            // 设置读超时函数
            pd.rt.f = netpollReadDeadline
            pd.rt.arg = pd
            pd.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函数的最后,会重新检查轮询信息中存储的截止日期:

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

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

runtime.poll_runtime_pollSetDeadline函数中直接调用runtime.netpollgoready是相对比较特殊的情况。正常情况下,运行时都会在计时器到期时调用runtime.netpollDeadlineruntime.netpollReadDeadlineruntime.netpollWriteDeadline三个函数:

上述三个函数都会通过runtime.netpolldeadlineimpl调用runtime.netpollgoready直接唤醒相应的Goroutine:

go 复制代码
func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) {
    // 获取当前操作(读或写)的序列号
    currentSeq := pd.rseq
    if !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上因读操作而阻塞的Goroutine
        rg = netpollunblock(pd, 'r', false)
    }
    // ...
    // 如果有因读操作而阻塞的Goroutine
    if 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启动主程序,该函数会在系统栈中创建新的线程:

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

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

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

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

go 复制代码
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函数启动系统监控:

go 复制代码
func sysmon() {
    // 增加系统线程计数
    sched.nmsys++
    // 检查死锁
    checkdead()
    
    lasttrace := int64(0)
    // idle是累计多少个循环没有唤起过Goroutine了
    idle := 0
    delay := 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语言运行时中正在运行的线程数量,我们通过调度器中的多个字段计算该值的结果:

go 复制代码
func checkdead() {
    var run0 int32
    run := 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,我们需要进一步检查程序的运行状态:

go 复制代码
func checkdead() {
    // ...
    // 处于等待或被抢占状态的Goroutine计数
    grunning := 0
    // 遍历所有Goroutine
    for 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不应该出现在这里
            // 应该在前面已经返回,这些情况下正在运行的线程数不可能为0
            print("runtime: checkdead: find g ", gp.goid, " in status ", s, "\n")
            throw("checkdead: runnable g")
        }
    }
    unlock(&allglock)
    // 如果正在运行的Goroutine为0
    if 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时,我们会检查处理器中存在的计时器:

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

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

运行计时器

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

go 复制代码
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 = 0
                delay = 20
            }
            unlock(&sched.lock)
        }
        // ...
        // 如果下一个事件该执行了
        if next < now {
            // 启动一个M执行它
            startm(nil, false)
        }
    }
}

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

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

轮询网络

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

go 复制代码
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加入全局运行队列中:

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

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

抢占处理器

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

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

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

go 复制代码
// 抢占的目的在于公平地分享CPU时间,防止某个运行时间过长的Goroutine长期占据处理器资源
func retake(now int64) uint32 {
    n := 0
    // 遍历所有处理器P
    for i := 0; i < len(allp); i++ {
        _p_ := allp[i]
        pd := &_p_.sysmontick
        s := _p_.status
        // 如果处理器状态是_Prunning或_Psyscall 
        if s == _Prunning || s == _Psyscall {
            t := int64(_p_.schedtick)
            // 如果距离上次处理器调度已经过了forcePreemptNS
            if pd.schedwhen+forcePreemptNS <= now {
                // 对处理器进行抢占
                preemptone(_p_)
            }
        }
        
        // 对于处于系统调用状态的处理器
        if s == _Psyscall {
            // 如果该处理器运行队列为空 && 有正在自旋的处理器或空闲处理器 && 距上次系统调用时间已过10ms
            if runqempty(_p_) && atomic.Load(&sched.nmspinning)+atomic.Load(&sched.npidle) > 0 && pd.syscallwhen+10*1000*1000 > now {
                continue
            }
            // 将处理器状态从_Psyscall改为_Pidle
            if 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函数判断是否需要触发垃圾回收:

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

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

6.7.3 小结

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

相关推荐
我爱挣钱我也要早睡!4 小时前
Java 复习笔记
java·开发语言·笔记
知识分享小能手7 小时前
React学习教程,从入门到精通, React 属性(Props)语法知识点与案例详解(14)
前端·javascript·vue.js·学习·react.js·vue·react
汇能感知9 小时前
摄像头模块在运动相机中的特殊应用
经验分享·笔记·科技
阿巴Jun9 小时前
【数学】线性代数知识点总结
笔记·线性代数·矩阵
茯苓gao9 小时前
STM32G4 速度环开环,电流环闭环 IF模式建模
笔记·stm32·单片机·嵌入式硬件·学习
是誰萆微了承諾9 小时前
【golang学习笔记 gin 】1.2 redis 的使用
笔记·学习·golang
DKPT10 小时前
Java内存区域与内存溢出
java·开发语言·jvm·笔记·学习
aaaweiaaaaaa10 小时前
HTML和CSS学习
前端·css·学习·html
ST.J10 小时前
前端笔记2025
前端·javascript·css·vue.js·笔记
Suckerbin11 小时前
LAMPSecurity: CTF5靶场渗透
笔记·安全·web安全·网络安全