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

Go调度器的工作原理

2024-01-023.9k 阅读

Go调度器的概述

Go语言的调度器是其运行时系统的核心组件之一,它负责管理和调度Go程序中的并发任务。与传统的操作系统线程调度不同,Go调度器采用了一种更轻量级、更高效的方式来处理并发,这使得Go语言在处理大量并发任务时表现出色。

在Go中,并发执行的单位是goroutine。一个goroutine可以看作是一个轻量级的线程,它由Go运行时系统而不是操作系统内核来调度。Go调度器的设计目标是在多个操作系统线程上高效地运行大量的goroutine,充分利用多核CPU的性能,同时提供简洁易用的并发编程模型。

调度器的架构

Go调度器采用了M:N的调度模型,即多个goroutine映射到多个操作系统线程上。这种模型结合了1:1(一个用户线程映射到一个内核线程)和N:1(多个用户线程映射到一个内核线程)模型的优点,既避免了N:1模型中单个内核线程阻塞导致所有用户线程阻塞的问题,又减少了1:1模型中大量内核线程带来的上下文切换开销。

Go调度器的架构主要由三个组件组成:G(goroutine)、M(machine,代表操作系统线程)和P(processor,处理器)。

G(goroutine)

goroutine是Go语言中并发执行的基本单位。每个goroutine都有自己的栈空间、程序计数器和局部变量等上下文信息。goroutine的创建非常轻量级,相比操作系统线程,创建和销毁goroutine的开销极小。

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

package main

import (
    "fmt"
)

func printMessage(message string) {
    fmt.Println(message)
}

func main() {
    go printMessage("Hello from goroutine")
    fmt.Println("Main function")
}

在上述代码中,通过go关键字创建了一个新的goroutine来执行printMessage函数。主函数会继续执行,不会等待这个goroutine完成。

M(machine)

M代表操作系统线程,它负责执行实际的代码。每个M都有一个与之关联的栈空间,用于保存函数调用的上下文。M由操作系统内核调度,在不同的CPU核心上运行。

P(processor)

P是处理器,它是调度器的核心组件之一。P的主要作用是管理一组可运行的goroutine,并将它们分配给M来执行。每个P都有一个本地的goroutine队列,同时也可以从全局的goroutine队列中获取goroutine。

P的数量可以通过GOMAXPROCS环境变量或runtime.GOMAXPROCS函数来设置,默认值是机器的CPU核心数。这意味着默认情况下,Go调度器会充分利用机器的多核性能。例如,通过以下代码可以设置GOMAXPROCS的值:

package main

import (
    "fmt"
    "runtime"
)

func main() {
    numProcs := runtime.GOMAXPROCS(2)
    fmt.Printf("Set GOMAXPROCS to %d\n", numProcs)
}

在上述代码中,runtime.GOMAXPROCS(2)GOMAXPROCS设置为2,即使用2个处理器。

调度器的工作原理

初始化阶段

在Go程序启动时,调度器会进行一系列的初始化操作。首先,会创建一定数量的M和P。M的数量通常是一个较小的固定值,而P的数量由GOMAXPROCS决定。

然后,调度器会初始化全局的goroutine队列,这个队列用于存放所有新创建但还未分配到P本地队列的goroutine。

goroutine的创建与调度

当使用go关键字创建一个新的goroutine时,它会被放入调度器的队列中。如果当前P的本地队列有空闲空间,新创建的goroutine会被优先放入本地队列。否则,它会被放入全局队列。

M会从P的本地队列或全局队列中获取goroutine来执行。当M从P的本地队列获取goroutine时,它会从队列头部取出一个goroutine并开始执行。如果P的本地队列为空,M会尝试从全局队列中获取一批(通常为1/64)goroutine,并将它们放入P的本地队列。

如果全局队列也为空,M会尝试从其他P的本地队列中窃取一半的goroutine(工作窃取算法)。以下是一个简单的模拟工作窃取的示例代码:

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup, tasks chan int) {
    defer wg.Done()
    for task := range tasks {
        fmt.Printf("Worker %d is working on task %d\n", id, task)
    }
}

func main() {
    var wg sync.WaitGroup
    numWorkers := 3
    tasks := make(chan int, 10)

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, &wg, tasks)
    }

    for i := 0; i < 10; i++ {
        tasks <- i
    }
    close(tasks)

    wg.Wait()
}

在上述代码中,多个worker goroutine从tasks通道中获取任务。如果某个worker的任务队列空了,它可以从其他worker的队列中窃取任务,以实现负载均衡。

goroutine的阻塞与唤醒

当一个goroutine执行某些阻塞操作(如系统调用、channel操作、锁操作等)时,它会让出当前的M,使得M可以去执行其他的goroutine。

例如,当一个goroutine执行系统调用时,对应的M会进入阻塞状态。此时,调度器会将该M与当前的P分离,并创建一个新的M(如果有可用的资源)来继续执行P本地队列中的其他goroutine。当阻塞操作完成后,被阻塞的goroutine会被重新加入到调度队列中,等待被调度执行。

以下是一个简单的channel操作导致goroutine阻塞的示例:

package main

import (
    "fmt"
)

func main() {
    ch := make(chan int)
    go func() {
        fmt.Println("Sending value to channel")
        ch <- 10
        fmt.Println("Value sent")
    }()

    fmt.Println("Receiving value from channel")
    value := <-ch
    fmt.Printf("Received value: %d\n", value)
}

在上述代码中,ch <- 10这一行会阻塞goroutine,直到有其他goroutine从ch通道中接收数据。同样,<-ch也会阻塞主goroutine,直到有数据发送到ch通道。

调度器的抢占式调度

Go调度器从Go 1.2版本开始引入了协作式调度,后来在Go 1.14版本中加入了更高效的抢占式调度。

在协作式调度中,goroutine需要主动调用一些特定的函数(如runtime.Gosched)来让出CPU,以便调度器可以调度其他goroutine。例如:

package main

import (
    "fmt"
    "runtime"
)

func busyWork() {
    for i := 0; i < 1000000000; i++ {
        // 模拟繁忙工作
    }
    runtime.Gosched()
    fmt.Println("Busy work done and yielded")
}

func main() {
    go busyWork()
    for i := 0; i < 10; i++ {
        fmt.Println("Main is doing something")
    }
}

在上述代码中,runtime.Gosched函数使得busyWork goroutine主动让出CPU,让调度器可以调度主goroutine。

而在抢占式调度中,调度器可以在任何时候暂停一个正在运行的goroutine,而不需要该goroutine主动协作。这是通过在每个函数调用时插入一小段代码来实现的。当调度器检测到某个goroutine运行时间过长时,它会设置一个抢占标志,在下一次函数调用时,该goroutine会被暂停并被调度器重新调度。

调度器的优化与改进

Go调度器一直在不断优化和改进,以提高性能和资源利用率。

减少锁的竞争

调度器在设计上尽量减少锁的使用,特别是在频繁访问的数据结构(如全局goroutine队列和P的本地队列)上。通过采用无锁数据结构或细粒度锁,调度器可以减少锁竞争带来的性能开销。

缓存友好的设计

调度器的设计考虑了缓存局部性原理,尽量让频繁访问的数据(如goroutine的上下文信息)能够在CPU缓存中命中,从而减少内存访问的延迟。例如,将goroutine的栈空间分配在连续的内存区域,并且将常用的调度信息与goroutine结构体紧密关联,以提高缓存命中率。

自适应调整

调度器能够根据系统的负载和资源使用情况进行自适应调整。例如,当系统负载较高时,调度器会调整goroutine的调度策略,以确保每个goroutine都能得到合理的执行时间。同时,调度器也会根据可用的CPU核心数动态调整P的数量,以充分利用多核性能。

调度器与操作系统的交互

Go调度器虽然是在用户空间实现的,但它与操作系统之间存在着密切的交互。

系统调用的处理

当一个goroutine执行系统调用时,调度器需要与操作系统进行协作。如前文所述,当goroutine执行系统调用时,对应的M会进入阻塞状态,调度器会将该M与当前的P分离,并可能创建新的M来继续执行其他goroutine。当系统调用完成后,调度器会将阻塞的goroutine重新加入调度队列。

内存管理

调度器与Go语言的内存管理模块紧密配合。由于goroutine的栈空间是动态分配和增长的,调度器需要与内存管理模块协同工作,确保栈空间的分配和释放是高效且安全的。同时,调度器也需要考虑内存的碎片化问题,以提高内存的利用率。

多核CPU的利用

调度器通过GOMAXPROCS设置的P数量来充分利用多核CPU的性能。每个P会在不同的M上运行,从而实现多个goroutine在不同CPU核心上并行执行。调度器还会根据CPU的负载情况动态调整goroutine在不同P和M之间的分配,以达到更好的负载均衡。

总结

Go调度器是Go语言实现高效并发编程的关键组件。它通过独特的M:N调度模型、工作窃取算法、抢占式调度等技术,在多个操作系统线程上高效地调度大量的goroutine。调度器在设计上注重减少锁竞争、提高缓存命中率以及自适应调整,同时与操作系统紧密协作,充分利用多核CPU的性能。理解Go调度器的工作原理对于编写高性能的并发Go程序至关重要,开发者可以根据调度器的特性来优化代码,避免潜在的性能瓶颈,从而充分发挥Go语言在并发编程方面的优势。