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

Go goroutine的调度原理与优化方向

2022-06-136.2k 阅读

Go goroutine 基础概念

在深入探讨 Go goroutine 的调度原理与优化方向之前,我们先来明确一下 goroutine 是什么。在 Go 语言中,goroutine 是一种轻量级的并发执行单元。与传统的线程(thread)相比,goroutine 的创建和销毁成本极低。一个程序中可以轻松创建成千上万的 goroutine。

我们来看一段简单的代码示例,展示如何创建和运行 goroutine:

package main

import (
    "fmt"
    "time"
)

func printNumbers() {
    for i := 1; i <= 5; i++ {
        fmt.Printf("Number: %d\n", i)
        time.Sleep(100 * time.Millisecond)
    }
}

func printLetters() {
    for i := 'a'; i <= 'e'; i++ {
        fmt.Printf("Letter: %c\n", i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    go printNumbers()
    go printLetters()

    time.Sleep(1000 * time.Millisecond)
}

在上述代码中,main 函数里通过 go 关键字启动了两个 goroutine,分别执行 printNumbersprintLetters 函数。这两个 goroutine 并发执行,并且 main 函数通过 time.Sleep 等待一段时间,以确保两个 goroutine 有足够的时间执行完毕。

Go 调度模型:M:N 模型

Go 语言采用的是 M:N 调度模型,这意味着多个 goroutine(N 个)可以映射到多个操作系统线程(M 个)上。传统的线程模型通常是 1:1 模型,即一个用户态线程映射到一个内核态线程。而 Go 的 M:N 模型有诸多优势,比如能够更高效地利用系统资源,处理大量的并发任务。

在 Go 的调度模型中,有三个关键的组件:G(goroutine)、M(machine,即操作系统线程)和 P(processor)。

G(goroutine)

G 代表 goroutine,它是 Go 语言中轻量级的执行单元。每个 G 都有自己的栈空间,用于保存局部变量和函数调用信息。G 结构体包含了 goroutine 的状态(如运行中、就绪、阻塞等)、栈指针、程序计数器以及与其他 G 之间的链表关系等重要信息。

M(machine)

M 对应操作系统线程,它负责实际执行 G。每个 M 都有一个关联的 P,M 会从 P 的本地运行队列或者全局运行队列中获取 G 来执行。M 也有自己的状态,比如运行、休眠等。当 M 执行的 G 发生系统调用时,M 可能会阻塞,此时对应的 P 会将其他 G 调度到另外的 M 上运行,以充分利用系统资源。

P(processor)

P 可以理解为一个资源,它包含了一个本地运行队列,用于存放就绪的 G。P 的数量在程序启动时可以通过 GOMAXPROCS 环境变量或者 runtime.GOMAXPROCS 函数进行设置。P 的主要作用是管理 G 的调度,它会将 G 分配给 M 来执行。同时,P 还负责处理一些与调度相关的元数据,比如调度器的状态信息等。

Go goroutine 调度原理

调度器初始化

在 Go 程序启动时,调度器会进行初始化。首先,会根据 GOMAXPROCS 的设置创建相应数量的 P。默认情况下,如果没有显式设置 GOMAXPROCS,它会被设置为 CPU 的核心数。例如,在一个 4 核的 CPU 机器上,默认会创建 4 个 P。

接着,会创建一个主 M(即主线程),主 M 会绑定到其中一个 P 上。主 M 会从全局运行队列或者 P 的本地运行队列中获取 G 来执行。同时,调度器还会初始化一些全局变量,用于管理调度器的状态,比如全局运行队列、空闲 M 列表等。

G 的创建与入队

当我们通过 go 关键字创建一个新的 goroutine 时,会在堆上分配一个新的 G 结构体,并初始化其相关字段,如栈空间、程序计数器等。然后,这个新创建的 G 会被放入到某个 P 的本地运行队列中。如果 P 的本地运行队列已满,G 会被放入到全局运行队列中。

下面是一个简单的示例,展示 G 的创建和入队过程:

package main

import (
    "fmt"
)

func newGoroutine() {
    fmt.Println("New goroutine is running")
}

func main() {
    go newGoroutine()
    // 这里省略等待代码,实际应用中需要等待 goroutine 执行完毕
}

在上述代码中,go newGoroutine() 创建了一个新的 G,这个 G 会被放入某个 P 的本地运行队列(如果队列未满),等待被调度执行。

M 与 P 的绑定及调度

每个 M 都需要绑定一个 P 才能执行 G。当 M 启动时,它会尝试从空闲 P 列表中获取一个 P。如果获取成功,M 就会与这个 P 绑定,并开始从 P 的本地运行队列中获取 G 来执行。如果 P 的本地运行队列为空,M 会尝试从全局运行队列中获取 G。

M 在执行 G 的过程中,会按照一定的调度策略来切换 G。一种常见的调度策略是时间片轮转调度。每个 G 会被分配一个时间片,当时间片用完后,M 会将当前 G 放回 P 的本地运行队列,并从队列中取出下一个 G 继续执行。

G 的状态转换

G 在其生命周期中会经历多种状态转换,主要包括以下几种:

  1. 新建(new):当通过 go 关键字创建 G 时,G 处于新建状态。此时 G 还没有被放入运行队列,只是初始化了相关字段。
  2. 就绪(runnable):G 被放入运行队列(本地或全局),等待被 M 调度执行,此时 G 处于就绪状态。
  3. 运行(running):当 M 从运行队列中取出 G 并开始执行时,G 进入运行状态。
  4. 阻塞(blocked):当 G 执行系统调用、等待通道操作完成或者执行 time.Sleep 等操作时,G 会进入阻塞状态。此时 M 会与 P 解绑,P 可以调度其他 G 到其他 M 上运行。
  5. 终止(dead):当 G 执行完其函数体或者发生 panic 并被 recover 时,G 进入终止状态。终止后的 G 会被回收相关资源。

下面通过一段代码示例来展示 G 的状态转换:

package main

import (
    "fmt"
    "time"
)

func blockedGoroutine() {
    fmt.Println("Blocked goroutine starts")
    time.Sleep(2 * time.Second)
    fmt.Println("Blocked goroutine ends")
}

func main() {
    go blockedGoroutine()
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Main goroutine continues")
    time.Sleep(3 * time.Second)
}

在上述代码中,blockedGoroutine 启动后,会执行 time.Sleep 进入阻塞状态。此时主 goroutine 可以继续执行。

调度原理中的重要机制

抢占式调度

Go 1.14 引入了更完善的抢占式调度机制。在早期版本中,Go 的调度主要是协作式调度,即只有当 G 主动让出 CPU 时,调度器才能进行调度。这在一些情况下会导致某些 G 长时间占用 CPU,影响其他 G 的执行。

抢占式调度则允许调度器在必要时强制暂停正在运行的 G,将 CPU 资源分配给其他 G。实现抢占式调度的关键在于使用操作系统的信号机制。当一个 G 运行时间过长时,调度器会向对应的 M 发送一个信号,M 接收到信号后会暂停当前 G 的执行,将其放回运行队列,然后调度其他 G 执行。

本地队列与全局队列

如前文所述,P 有一个本地运行队列,用于存放就绪的 G。本地队列的优势在于,M 可以快速从本地队列中获取 G 执行,减少了锁的竞争。因为每个 P 的本地队列是独立的,不同 M 访问不同 P 的本地队列无需竞争锁。

全局运行队列则是所有 P 共享的,当某个 P 的本地运行队列为空时,M 会尝试从全局运行队列中获取 G。全局运行队列在多 P 多 M 的环境下,为 G 的调度提供了一个兜底的机制。但是,由于全局运行队列是共享的,访问全局运行队列需要获取全局锁,这在高并发场景下可能会成为性能瓶颈。

网络轮询器(Netpoller)

在 Go 语言中,网络 I/O 操作是异步的。网络轮询器(Netpoller)负责管理这些异步的网络 I/O 操作。当一个 G 执行网络 I/O 操作(如 net.Conn.Readnet.Conn.Write)时,它不会阻塞 M,而是将自身注册到网络轮询器中,并进入阻塞状态。

网络轮询器会使用操作系统提供的异步 I/O 机制(如 Linux 上的 epoll、Windows 上的 I/O Completion Ports 等)来监听网络事件。当网络事件发生(如数据可读或可写)时,网络轮询器会将对应的 G 重新放入运行队列,使其可以继续执行。

Go goroutine 调度的优化方向

合理设置 GOMAXPROCS

GOMAXPROCS 的设置对调度性能有重要影响。如果设置过小,会导致 CPU 资源无法充分利用;如果设置过大,会增加调度开销,因为过多的 P 会导致更多的上下文切换以及全局运行队列的竞争加剧。

一般来说,将 GOMAXPROCS 设置为 CPU 的核心数是一个不错的初始选择。但在实际应用中,需要根据程序的特点进行调整。例如,如果程序主要是 I/O 密集型的,适当增加 GOMAXPROCS 的值可能会提高性能,因为 I/O 操作会使 M 暂时空闲,更多的 P 可以让调度器在这段时间内调度其他 G 执行。

下面通过一个简单的实验来展示 GOMAXPROCS 对性能的影响:

package main

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

func workload() {
    for i := 0; i < 100000000; i++ {
        _ = i * i
    }
}

func main() {
    numCPUs := runtime.NumCPU()
    for _, n := range []int{1, numCPUs, numCPUs * 2} {
        runtime.GOMAXPROCS(n)
        start := time.Now()
        var numGoroutines = 10
        for i := 0; i < numGoroutines; i++ {
            go workload()
        }
        time.Sleep(2 * time.Second)
        elapsed := time.Since(start)
        fmt.Printf("GOMAXPROCS = %d, elapsed: %s\n", n, elapsed)
    }
}

在上述代码中,我们通过改变 GOMAXPROCS 的值,观察执行多个计算密集型 goroutine 的耗时。通过实验结果可以发现,当 GOMAXPROCS 设置为合适的值时,程序的执行效率最高。

减少全局运行队列的竞争

如前文所述,全局运行队列的竞争在高并发场景下可能成为性能瓶颈。为了减少这种竞争,可以尽量将 G 分配到 P 的本地运行队列中。一种方法是在创建 G 时,尽量在与当前 P 相关的上下文中进行。例如,在某个 P 正在执行的 G 中创建新的 G,这样新创建的 G 更有可能被放入当前 P 的本地运行队列。

另外,可以考虑使用工作窃取算法。当一个 P 的本地运行队列为空时,它可以从其他 P 的本地运行队列中窃取一部分 G 到自己的队列中。Go 的调度器已经实现了工作窃取算法,但在一些特定场景下,进一步优化工作窃取的策略可能会提高性能。

优化网络 I/O 操作

由于网络 I/O 操作在 Go 程序中较为常见,优化网络 I/O 操作对整体性能提升有很大帮助。首先,可以尽量复用网络连接,减少连接的创建和销毁开销。Go 的 net/http 包已经对连接复用有了一定的优化,但在自定义的网络客户端和服务器中,需要开发者自己注意连接复用。

其次,合理设置网络 I/O 的缓冲区大小也很重要。过小的缓冲区可能导致频繁的系统调用,增加开销;过大的缓冲区则可能浪费内存。根据实际的网络带宽和数据量来调整缓冲区大小,可以提高网络 I/O 的效率。

例如,在使用 net.Conn 进行数据读写时,可以设置合适的缓冲区:

package main

import (
    "fmt"
    "net"
)

func main() {
    conn, err := net.Dial("tcp", "example.com:80")
    if err!= nil {
        fmt.Println("Dial error:", err)
        return
    }
    defer conn.Close()

    buffer := make([]byte, 4096)
    _, err = conn.Read(buffer)
    if err!= nil {
        fmt.Println("Read error:", err)
        return
    }
    // 处理读取的数据
}

在上述代码中,我们将缓冲区大小设置为 4096 字节,这样可以在一定程度上提高网络读取的效率。

优化 goroutine 的创建与销毁

虽然 goroutine 的创建和销毁成本相对较低,但在高并发场景下,大量的 goroutine 创建和销毁仍然可能带来性能开销。一种优化方法是使用 goroutine 池。通过预先创建一定数量的 goroutine 并放入池中,需要执行任务时从池中获取 goroutine,任务完成后将 goroutine 放回池中,而不是频繁地创建和销毁 goroutine。

下面是一个简单的 goroutine 池的实现示例:

package main

import (
    "fmt"
    "sync"
)

type Task func()

type WorkerPool struct {
    Workers    int
    TaskQueue  chan Task
    WaitGroup  sync.WaitGroup
}

func NewWorkerPool(workers, capacity int) *WorkerPool {
    pool := &WorkerPool{
        Workers:    workers,
        TaskQueue:  make(chan Task, capacity),
    }
    for i := 0; i < workers; i++ {
        pool.WaitGroup.Add(1)
        go func() {
            defer pool.WaitGroup.Done()
            for task := range pool.TaskQueue {
                task()
            }
        }()
    }
    return pool
}

func (p *WorkerPool) Submit(task Task) {
    p.TaskQueue <- task
}

func (p *WorkerPool) Shutdown() {
    close(p.TaskQueue)
    p.WaitGroup.Wait()
}

func main() {
    pool := NewWorkerPool(5, 10)
    for i := 0; i < 20; i++ {
        i := i
        pool.Submit(func() {
            fmt.Printf("Task %d is running\n", i)
        })
    }
    pool.Shutdown()
}

在上述代码中,我们实现了一个简单的 goroutine 池。通过预先创建 5 个 worker goroutine,并将任务放入任务队列,避免了频繁创建和销毁 goroutine,从而提高了性能。

避免不必要的锁竞争

在多个 goroutine 共享数据时,不可避免地需要使用锁来保证数据的一致性。然而,锁的使用会带来竞争,影响性能。为了避免不必要的锁竞争,可以采用以下几种方法:

  1. 数据分片:将共享数据分成多个片段,每个片段由不同的 goroutine 负责处理,减少锁的粒度。例如,在一个分布式缓存系统中,可以根据 key 的哈希值将缓存数据分片到不同的 goroutine 中进行管理,每个 goroutine 只需要对自己负责的分片加锁。
  2. 无锁数据结构:使用无锁数据结构,如 Go 标准库中的 sync.Map,它在高并发场景下的性能优于传统的 map 加锁的方式。sync.Map 采用了一种更复杂的结构,通过分段锁和原子操作来减少锁的竞争。
  3. 消息传递:使用通道(channel)进行数据传递,而不是共享数据。通过通道传递数据可以避免共享数据带来的锁竞争问题,这也是 Go 语言倡导的并发编程方式。例如,在一个生产者 - 消费者模型中,可以通过通道将数据从生产者传递给消费者,而不是让生产者和消费者共享一个数据结构。

总结与展望

Go goroutine 的调度原理是其实现高效并发编程的核心。通过深入理解 G、M、P 之间的关系以及调度器的工作机制,我们能够更好地优化 Go 程序的性能。在优化方向上,合理设置 GOMAXPROCS、减少全局运行队列竞争、优化网络 I/O 和 goroutine 的创建销毁、避免不必要的锁竞争等,都是提高程序性能的有效途径。

随着硬件技术的不断发展,多核 CPU 的性能越来越强大,Go 语言的调度器也在不断演进以更好地利用这些资源。未来,我们可以期待 Go 调度器在处理超大规模并发任务、优化资源利用等方面有更出色的表现,为开发者提供更高效的并发编程体验。同时,开发者也需要不断学习和掌握新的调度优化技巧,以充分发挥 Go 语言在并发编程领域的优势。