Go goroutine的调度原理与优化方向
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,分别执行 printNumbers
和 printLetters
函数。这两个 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 在其生命周期中会经历多种状态转换,主要包括以下几种:
- 新建(new):当通过
go
关键字创建 G 时,G 处于新建状态。此时 G 还没有被放入运行队列,只是初始化了相关字段。 - 就绪(runnable):G 被放入运行队列(本地或全局),等待被 M 调度执行,此时 G 处于就绪状态。
- 运行(running):当 M 从运行队列中取出 G 并开始执行时,G 进入运行状态。
- 阻塞(blocked):当 G 执行系统调用、等待通道操作完成或者执行
time.Sleep
等操作时,G 会进入阻塞状态。此时 M 会与 P 解绑,P 可以调度其他 G 到其他 M 上运行。 - 终止(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.Read
或 net.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 共享数据时,不可避免地需要使用锁来保证数据的一致性。然而,锁的使用会带来竞争,影响性能。为了避免不必要的锁竞争,可以采用以下几种方法:
- 数据分片:将共享数据分成多个片段,每个片段由不同的 goroutine 负责处理,减少锁的粒度。例如,在一个分布式缓存系统中,可以根据 key 的哈希值将缓存数据分片到不同的 goroutine 中进行管理,每个 goroutine 只需要对自己负责的分片加锁。
- 无锁数据结构:使用无锁数据结构,如 Go 标准库中的
sync.Map
,它在高并发场景下的性能优于传统的map
加锁的方式。sync.Map
采用了一种更复杂的结构,通过分段锁和原子操作来减少锁的竞争。 - 消息传递:使用通道(channel)进行数据传递,而不是共享数据。通过通道传递数据可以避免共享数据带来的锁竞争问题,这也是 Go 语言倡导的并发编程方式。例如,在一个生产者 - 消费者模型中,可以通过通道将数据从生产者传递给消费者,而不是让生产者和消费者共享一个数据结构。
总结与展望
Go goroutine 的调度原理是其实现高效并发编程的核心。通过深入理解 G、M、P 之间的关系以及调度器的工作机制,我们能够更好地优化 Go 程序的性能。在优化方向上,合理设置 GOMAXPROCS
、减少全局运行队列竞争、优化网络 I/O 和 goroutine 的创建销毁、避免不必要的锁竞争等,都是提高程序性能的有效途径。
随着硬件技术的不断发展,多核 CPU 的性能越来越强大,Go 语言的调度器也在不断演进以更好地利用这些资源。未来,我们可以期待 Go 调度器在处理超大规模并发任务、优化资源利用等方面有更出色的表现,为开发者提供更高效的并发编程体验。同时,开发者也需要不断学习和掌握新的调度优化技巧,以充分发挥 Go 语言在并发编程领域的优势。