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

Go调度器的基本概念

2021-05-121.1k 阅读

1. Go语言并发模型概述

在深入探讨Go调度器之前,先简要回顾一下Go语言的并发模型。Go语言以其轻量级的并发原语goroutine和通道(channel)闻名。与传统的线程模型相比,goroutine的创建和销毁成本极低,这使得在Go程序中可以轻松创建数以万计的并发执行单元。

例如,以下是一个简单的创建多个goroutine的代码示例:

package main

import (
    "fmt"
    "time"
)

func worker(id int) {
    fmt.Printf("Worker %d starting\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d done\n", id)
}

func main() {
    for i := 0; i < 3; i++ {
        go worker(i)
    }
    time.Sleep(2 * time.Second)
    fmt.Println("Main function exiting")
}

在这个示例中,通过go关键字启动了3个goroutine。这些goroutine并发执行worker函数。然而,要让这些goroutine高效运行,背后离不开Go调度器的支持。

2. Go调度器的核心组件

2.1 M:N调度模型

Go调度器采用的是M:N调度模型,即多个用户级线程(goroutine)映射到多个内核级线程(操作系统线程)上。与传统的1:1模型(每个用户级线程对应一个内核级线程)相比,M:N模型可以更有效地利用系统资源。

在1:1模型中,如果一个线程因为I/O操作而阻塞,对应的内核级线程也会阻塞,导致整个进程的一部分资源闲置。而在M:N模型中,当一个goroutine因为I/O操作阻塞时,Go调度器可以将其他可运行的goroutine调度到其他空闲的内核级线程上执行,从而提高系统的并发性能。

2.2 G-M-P架构

Go调度器的核心是G-M-P架构,由以下三个关键组件构成:

  • G(Goroutine):代表一个用户级的轻量级线程,也就是我们通过go关键字启动的执行单元。每个G都有自己独立的栈空间和程序计数器。
  • M(Machine):代表一个内核级线程,是真正在操作系统层面执行的线程。每个M都有一个与之关联的栈,用于执行G的代码。
  • P(Processor):Processor是一个逻辑概念,它包含了运行G所需的资源,如本地G队列、调度器上下文等。P的主要作用是管理和调度G到M上执行。

3. Goroutine(G)

3.1 Goroutine的生命周期

一个goroutine从创建开始,经历不同的状态,直到最终结束。其主要状态包括:

  • 新建(new):当使用go关键字创建一个新的goroutine时,它处于新建状态。此时,goroutine的栈空间等资源已经分配,但尚未被调度执行。
  • 可运行(runnable):当一个goroutine准备好执行,但还没有被分配到M上运行时,它处于可运行状态。可运行的goroutine会被放置在全局G队列或P的本地G队列中。
  • 运行(running):当一个goroutine被调度到M上并正在执行时,它处于运行状态。
  • 阻塞(blocked):如果一个goroutine执行了阻塞操作,如I/O操作、channel操作、系统调用等,它会进入阻塞状态。此时,该goroutine会从M上分离,让出M给其他可运行的goroutine。
  • 结束(dead):当一个goroutine的函数执行完毕,或者调用了runtime.Goexit()函数,它会进入结束状态。结束的goroutine所占用的资源会被回收。

以下代码展示了一个goroutine的不同状态转换:

package main

import (
    "fmt"
    "time"
)

func main() {
    go func() {
        fmt.Println("Goroutine starting")
        time.Sleep(2 * time.Second)
        fmt.Println("Goroutine ending")
    }()
    time.Sleep(3 * time.Second)
    fmt.Println("Main function exiting")
}

在这个例子中,新建的goroutine首先处于可运行状态,然后被调度到M上进入运行状态,在time.Sleep期间进入阻塞状态,最后执行完毕进入结束状态。

3.2 Goroutine的栈管理

每个goroutine都有自己独立的栈空间。与传统线程栈不同,goroutine的栈是动态增长和收缩的。初始时,goroutine的栈大小通常为2KB左右,这比传统线程栈的默认大小(如Linux上一般为8MB)小得多。

当goroutine的栈空间不足时,Go运行时会自动将栈空间扩大。同样,当栈空间中有大量空闲区域时,运行时会将栈空间收缩,以节省内存。这种动态栈管理机制使得在大量goroutine并发运行时,内存的使用更加高效。

4. Machine(M)

4.1 M的创建与销毁

M是由Go运行时创建和管理的内核级线程。在程序启动时,Go运行时会根据系统的CPU核心数等因素创建一定数量的M。默认情况下,M的数量与CPU核心数相同,但可以通过GOMAXPROCS环境变量或runtime.GOMAXPROCS函数进行调整。

当一个M长时间没有可运行的G时,Go调度器可能会将其销毁,以节省系统资源。而当有新的G需要执行,且当前活跃的M数量不足时,调度器会创建新的M。

4.2 M与系统调用

当一个正在M上运行的G执行系统调用时,M会进入系统调用状态。在系统调用期间,M不能再运行其他G。为了避免这种情况下资源浪费,Go调度器采用了一些优化策略。

例如,当一个G执行系统调用时,调度器会将该G与M分离,然后将M标记为不可用。同时,调度器会尝试将其他可运行的G调度到其他可用的M上执行。当系统调用完成后,原来执行系统调用的G会重新进入可运行状态,等待被调度到某个M上继续执行。

5. Processor(P)

5.1 P的作用

P在Go调度器中起着承上启下的关键作用。它主要负责以下几个方面:

  • 本地G队列管理:每个P都有一个本地G队列,用于存放可运行的G。当一个新的G创建时,它通常会被优先放入创建它的P的本地G队列中。这样可以减少全局G队列的竞争,提高调度效率。
  • 调度上下文维护:P维护着调度器的上下文信息,包括当前正在运行的G、下一个要运行的G等。这些信息帮助调度器决定何时、如何将G调度到M上执行。
  • 资源分配:P为G的运行提供必要的资源,如内存分配等。

5.2 P的数量与调度策略

P的数量在程序启动时确定,默认情况下,P的数量与CPU核心数相同。可以通过runtime.GOMAXPROCS函数来调整P的数量。

调度策略方面,P采用一种称为“工作窃取(work - stealing)”的算法。当一个P的本地G队列空了,而其他P的本地G队列中有可运行的G时,该P会从其他P的本地G队列中“窃取”一半的G到自己的本地G队列中,从而保证所有的M都有G可运行,提高系统的整体并发性能。

6. Go调度器的调度流程

6.1 调度器初始化

在程序启动时,Go调度器会进行一系列的初始化工作。首先,根据系统信息和用户设置确定M和P的数量,并创建相应的M和P实例。然后,初始化全局G队列、每个P的本地G队列等数据结构。

6.2 普通调度流程

  1. G的创建与入队:当通过go关键字创建一个新的G时,它会被优先放入创建它的P的本地G队列中。如果本地G队列已满,G会被放入全局G队列。
  2. M获取G:每个M会尝试从与之关联的P的本地G队列中获取一个G来执行。如果本地G队列为空,M会尝试从全局G队列中获取G。如果全局G队列也为空,M会尝试从其他P的本地G队列中“窃取”G。
  3. G的执行:当M获取到一个G后,它会将G设置为运行状态,并开始执行G的代码。在执行过程中,G可能会因为各种原因进入阻塞状态,如I/O操作、channel操作等。
  4. G的阻塞与调度切换:当G进入阻塞状态时,M会将G从运行状态切换到阻塞状态,并将其从M上分离。然后,M会尝试从本地G队列、全局G队列或其他P的本地G队列中获取新的G来执行,从而实现调度切换。

6.3 系统调用时的调度流程

  1. 进入系统调用:当一个正在M上运行的G执行系统调用时,M会进入系统调用状态。同时,G会从M上分离,进入阻塞状态。
  2. 调度器调整:调度器会标记该M为不可用,并尝试将其他可运行的G调度到其他可用的M上执行。如果此时没有其他可用的M,调度器可能会创建新的M。
  3. 系统调用返回:当系统调用完成后,原来执行系统调用的G会重新进入可运行状态。调度器会将该G放入某个P的本地G队列或全局G队列中,等待被调度到某个M上继续执行。

7. 代码示例深入理解调度器

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

var wg sync.WaitGroup

func worker(id int) {
    defer wg.Done()
    fmt.Printf("Worker %d started\n", id)
    time.Sleep(time.Second)
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    runtime.GOMAXPROCS(1)
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go worker(i)
    }
    wg.Wait()
    fmt.Println("All workers completed")
}

在这个示例中,通过runtime.GOMAXPROCS(1)将P的数量设置为1。这样,所有的goroutine都只能在一个P上调度执行。通过观察输出结果,可以看到goroutine是顺序执行的,因为同一时间只有一个M可以运行在这个P上。

package main

import (
    "fmt"
    "runtime"
    "sync"
    "time"
)

var wg sync.WaitGroup

func worker(id int) {
    defer wg.Done()
    fmt.Printf("Worker %d started\n", id)
    if id == 2 {
        fmt.Println("Worker 2 going to sleep for 3 seconds")
        time.Sleep(3 * time.Second)
    }
    fmt.Printf("Worker %d finished\n", id)
}

func main() {
    runtime.GOMAXPROCS(2)
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go worker(i)
    }
    wg.Wait()
    fmt.Println("All workers completed")
}

在这个改进的示例中,将runtime.GOMAXPROCS设置为2,即有两个P。当worker函数中id为2的goroutine进入睡眠时,其他goroutine可以在另一个P上的M执行,从而提高了并发性能。通过观察输出结果,可以看到其他goroutine不会因为worker 2的睡眠而全部阻塞。

8. Go调度器的优化与改进

8.1 减少锁争用

Go调度器在设计上尽量减少锁的使用,以降低锁争用带来的性能开销。例如,每个P都有自己的本地G队列,这样在大部分情况下,G的入队和出队操作不需要获取全局锁,从而提高了并发性能。

8.2 更好的负载均衡

通过“工作窃取”算法,Go调度器可以在不同的P之间动态平衡负载。即使某个P上的G执行时间较长,其他P也可以通过窃取G来保持忙碌,避免资源浪费。

8.3 对网络I/O的优化

Go调度器对网络I/O操作进行了优化。当一个G执行网络I/O操作时,调度器会将其与M分离,使得M可以继续运行其他可运行的G。同时,调度器使用了异步I/O技术,如epoll(在Linux上),来提高网络I/O的效率。

9. 总结与展望

Go调度器是Go语言高效并发编程的核心支撑。其基于G - M - P架构的设计,通过动态栈管理、工作窃取算法等机制,实现了轻量级线程(goroutine)的高效调度和资源的合理利用。

随着硬件技术的发展和应用场景的不断拓展,Go调度器也在不断演进。未来,我们可以期待Go调度器在更复杂的分布式环境、异构计算环境等方面有进一步的优化和改进,为Go语言的并发编程带来更高的性能和更好的可扩展性。同时,开发者在使用Go语言进行并发编程时,深入理解调度器的原理和机制,有助于编写出更高效、更稳定的并发程序。