MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Go Goroutine的调度机制解析

2021-09-021.5k 阅读

1. 概述

在 Go 语言中,Goroutine 是实现并发编程的核心概念。Goroutine 类似于线程,但它是由 Go 运行时(runtime)管理的轻量级执行单元。与操作系统线程相比,Goroutine 的创建和销毁成本更低,这使得在 Go 程序中可以轻松创建成千上万的 Goroutine 来实现高并发。而 Goroutine 的高效运行离不开其背后强大的调度机制。

Go 的调度器采用了 M:N 调度模型,即多个 Goroutine 映射到多个操作系统线程上。这种模型既充分利用了多核 CPU 的性能,又能有效地管理大量的轻量级 Goroutine。接下来,我们将深入剖析 Go Goroutine 的调度机制。

2. 相关概念

2.1 Goroutine

Goroutine 是 Go 语言中实现并发的核心实体。可以将其看作是一个轻量级的线程,但它与传统的操作系统线程有很大不同。一个 Go 程序可以同时运行成千上万的 Goroutine,而创建和销毁 Goroutine 的开销非常小。

以下是一个简单的创建 Goroutine 的代码示例:

package main

import (
    "fmt"
    "time"
)

func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}

在上述代码中,通过 go 关键字启动了一个新的 Goroutine 来执行 say("world") 函数,同时主线程继续执行 say("hello") 函数。这两个函数会并发执行,打印出交错的输出。

2.2 M(Machine)

M 代表操作系统线程。在 Go 调度模型中,M 是实际运行在操作系统内核上的线程。每个 M 都有自己的栈空间,用于执行函数调用等操作。Go 运行时会根据系统的 CPU 核心数和负载情况,动态地创建和管理 M。

2.3 P(Processor)

P 是处理器,它是连接 Goroutine 和 M 的桥梁。P 维护着一个本地的 Goroutine 队列,并且包含了运行 Goroutine 所需的上下文信息,如调度器的状态、内存分配器等。P 的数量可以通过 runtime.GOMAXPROCS 函数来设置,默认值是 CPU 的核心数。P 的存在使得调度器可以更高效地管理 Goroutine,将合适的 Goroutine 分配到可用的 M 上执行。

2.4 G(Goroutine)结构体

Goroutine 在 Go 运行时中用 G 结构体表示,它包含了 Goroutine 的状态、栈信息、所属的 P 以及调度相关的字段等。G 结构体的部分重要字段如下:

type g struct {
    stack       stack   // 栈信息
    stackguard0 uintptr // 栈保护相关字段
    stackguard1 uintptr 
    m           *m      // 所属的 M
    sched       gobuf   // 保存当前 Goroutine 的执行现场
    state       int32   // Goroutine 的状态,如 _Gidle、_Grunning、_Gblocked 等
    waitreason  waitReason
    preempt     bool    // 是否被抢占
    lockedm     muintptr
    // 其他字段...
}

3. 调度器的初始化

在 Go 程序启动时,调度器会进行一系列的初始化操作。

首先,会初始化全局变量,如 runtime.sched,它包含了调度器的核心数据结构,如全局 Goroutine 队列、空闲 M 列表、空闲 P 列表等。

// runtime/proc.go
var sched struct {
    // 全局 Goroutine 队列
    runqhead uint32
    runqtail uint32
    runq     [256]guintptr

    // 其他调度器相关字段...
}

接着,根据系统的 CPU 核心数来初始化 P 的数量。默认情况下,runtime.GOMAXPROCS 的值为 CPU 的核心数,调度器会创建相应数量的 P 并将它们加入到空闲 P 列表中。

func schedinit() {
    // 获取 CPU 核心数
    ncpu := getncpu()
    if ncpu <= 0 {
        ncpu = 1
    }
    if GODEBUG&(1<<25) != 0 {
        ncpu = 1
    }
    // 设置 GOMAXPROCS
    gomaxprocs := ncpu
    if gomaxprocs > 1000 {
        gomaxprocs = 1000
    }
    // 创建 P
    for i := 0; i < gomaxprocs; i++ {
        _ = createp()
    }
    // 其他初始化操作...
}

最后,创建第一个 M,这个 M 会从全局 Goroutine 队列或某个 P 的本地队列中获取 Goroutine 并执行。

4. Goroutine 的调度流程

4.1 创建 Goroutine

当使用 go 关键字创建一个新的 Goroutine 时,Go 运行时会分配一个新的 G 结构体,并将其加入到当前 P 的本地 Goroutine 队列中。如果本地队列已满,则会将其加入到全局 Goroutine 队列中。

以下是简化后的创建 Goroutine 的代码逻辑:

func newproc(siz int32, fn *funcval) {
    // 获取当前 P
    _g_ := getg()
    p := _g_.m.p.ptr()

    // 创建新的 G
    newg := gfget(_g_.m)
    if newg == nil {
        newg = malg(_StackMin)
        casgstatus(newg, _Gidle, _Gdead)
        allgadd(newg)
    }
    // 设置 G 的相关字段
    newg.sched.sp = (uintptr)(add(unsafe.Pointer(newg.stack.hi), -siz))
    newg.sched.pc = getcallerpc() + sys.PCQuantum
    newg.sched.g = guintptr(unsafe.Pointer(newg))
    *(*funcval)(unsafe.Pointer(&newg.sched.argp)) = *fn

    // 将 G 加入队列
    if atomic.Load(&p.runqtail) != atomic.Load(&p.runqhead) {
        // 本地队列未满,加入本地队列
        runqput(p, newg, true)
    } else {
        // 本地队列满,加入全局队列
        globrunqput(newg)
    }
    // 唤醒调度器
    wakep()
}

4.2 调度器的主循环

每个 M 都有一个主循环,这个循环不断地从 P 的本地队列、全局队列或网络轮询器(netpoller)中获取可运行的 Goroutine 并执行。

func mstart() {
    // 获取当前 M
    _g_ := getg()
    m := _g_.m

    for {
        // 获取 P
        acquirep()
        p := _g_.m.p.ptr()

        // 从本地队列获取 Goroutine
        gp := runqget(p)
        if gp == nil {
            // 本地队列空,从全局队列获取
            gp = globrunqget(_g_.m.p.ptr(), 1)
            if gp == nil {
                // 全局队列也空,从网络轮询器获取
                gp = netpoll()
            }
        }

        if gp != nil {
            // 执行 Goroutine
            execute(gp, false)
        } else {
            // 没有可运行的 Goroutine,释放 P
            releasep()
            // 尝试睡眠或退出
            if m.syscallsp != 0 {
                mstart1()
            } else {
                mexit()
            }
        }
    }
}

4.3 执行 Goroutine

当 M 从队列中获取到一个 Goroutine 后,会切换到该 Goroutine 的执行现场,开始执行其对应的函数。

func execute(gp *g, inheritTime bool) {
    // 切换到 Goroutine 的栈
    gogo(&gp.sched)
}

func gogo(buf *gobuf) {
    // 恢复栈指针、程序计数器等现场
    sp := buf.sp
    pc := buf.pc
    g := buf.g
    argp := buf.argp
    _g_ := getg()
    _g_.m.curg = g
    // 执行函数
    funcPC(goexit).call()
}

4.4 Goroutine 的阻塞与唤醒

当一个 Goroutine 执行系统调用、I/O 操作或调用 runtime.Gosched 等函数时,它会进入阻塞状态。此时,调度器会将该 Goroutine 从运行状态切换到阻塞状态,并将其从当前 P 的队列中移除。

以系统调用为例,当一个 Goroutine 执行系统调用时,M 会进入系统调用状态,调度器会创建一个新的 M 来继续执行其他可运行的 Goroutine。

func syscall() {
    _g_ := getg()
    m := _g_.m
    p := m.p.ptr()
    // 将当前 Goroutine 从运行状态切换到阻塞状态
    casgstatus(_g_, _Grunning, _Gsyscall)
    // 释放 P
    releasep()
    // 进入系统调用
    ret := syscall6(...)
    acquirep()
    // 将当前 Goroutine 从阻塞状态切换回可运行状态
    casgstatus(_g_, _Gsyscall, _Grunnable)
    // 将 Goroutine 重新加入队列
    runqput(p, _g_, true)
    // 唤醒调度器
    wakep()
}

当阻塞的条件解除时,如 I/O 操作完成,调度器会将该 Goroutine 重新标记为可运行状态,并将其加入到某个 P 的本地队列或全局队列中,等待被调度执行。

5. 调度器的优化策略

5.1 本地队列优先

调度器优先从 P 的本地队列中获取 Goroutine 执行,因为本地队列的访问速度更快,减少了锁的竞争。只有当本地队列空时,才会尝试从全局队列或网络轮询器中获取。

5.2 工作窃取

当某个 P 的本地队列空,而其他 P 的本地队列有大量 Goroutine 时,调度器会采用工作窃取机制。空闲的 P 会从其他繁忙的 P 的本地队列的尾部窃取一半的 Goroutine 到自己的本地队列中,这样可以更均衡地分配工作负载。

func runqsteal(from, to *p) bool {
    // 从 from 的本地队列尾部窃取 Goroutine 到 to 的本地队列
    n := atomic.Load(&from.runqtail) - atomic.Load(&from.runqhead)
    if n == 0 {
        return false
    }
    if n == 1 {
        return false
    }
    n = (n + 1) / 2
    for i := 0; i < int(n); i++ {
        gp := runqget(from)
        if gp == nil {
            break
        }
        runqput(to, gp, false)
    }
    return true
}

5.3 抢占式调度

Go 1.2 引入了抢占式调度,在此之前,Goroutine 只能通过主动调用 runtime.Gosched 等函数来让出执行权。抢占式调度使得调度器可以在某些情况下强制暂停正在运行的 Goroutine,从而更及时地调度其他 Goroutine。

目前,抢占式调度主要通过两种方式实现:协作式抢占和异步抢占。协作式抢占是指在函数调用时检查是否需要抢占,异步抢占则是通过信号机制在运行时强制抢占。

6. 总结

Go Goroutine 的调度机制是 Go 语言实现高效并发编程的关键。通过 M:N 调度模型、本地队列优先、工作窃取和抢占式调度等策略,Go 调度器能够在多核环境下高效地管理大量的轻量级 Goroutine,充分利用系统资源。理解 Goroutine 的调度机制对于编写高性能、高并发的 Go 程序至关重要,开发者可以根据调度机制的特点,合理地设计程序结构,避免性能瓶颈。希望本文的解析能帮助读者深入理解 Go Goroutine 的调度机制,从而在实际开发中更好地应用 Go 语言的并发特性。

通过以上对 Go Goroutine 调度机制的深入解析,相信你对 Go 语言的并发底层实现有了更清晰的认识。在实际应用中,可以根据这些原理优化自己的并发代码,提高程序的性能和稳定性。例如,合理设置 runtime.GOMAXPROCS 的值,避免 Goroutine 长时间阻塞等。同时,随着 Go 语言的不断发展,调度机制也可能会有进一步的优化和改进,需要持续关注相关的官方文档和社区动态。

在编写并发程序时,还需要注意数据竞争等问题。虽然 Go 语言的调度机制可以高效地管理 Goroutine,但如果在多个 Goroutine 之间共享数据时没有进行适当的同步,就可能导致数据不一致等错误。可以使用 Go 语言提供的同步原语,如 sync.Mutexsync.RWMutexsync.Cond 等,来保证数据的一致性。

此外,在处理大量 Goroutine 时,还需要关注内存管理和资源消耗。每个 Goroutine 都有自己的栈空间,虽然栈空间的初始大小较小,但随着函数调用和数据的压栈,栈空间可能会动态增长。如果创建了过多的 Goroutine,可能会导致内存占用过高,甚至引发内存溢出错误。因此,需要根据实际需求合理控制 Goroutine 的数量,避免资源浪费。

总之,深入理解 Go Goroutine 的调度机制是成为一名优秀 Go 开发者的重要基础,只有掌握了这些底层原理,才能编写出高效、稳定的并发程序。希望读者通过本文的学习,能够在 Go 语言的并发编程领域取得更好的成果。