
Go语言调度器概览
go语言的并发模型基于goroutine,这是一种轻量级的线程。go运行时负责将这些goroutine调度到操作系统线程上执行。go调度器采用m:n模型,即将n个goroutine调度到m个操作系统线程上。runtime.gomaxprocs变量控制了go程序可以使用的最大逻辑处理器(p)数量,每个p可以看作是一个独立的go调度器实例,它会绑定到一个操作系统线程(m)上。当gomaxprocs设置为1时,go程序仅使用一个p和一个操作系统线程来执行所有goroutine。
实验观察:多核下Goroutine分配的性能下降
为了深入理解GOMAXPROCS对goroutine分配性能的影响,我们来看一个具体的实验。以下Go代码创建了大量不执行实际计算的goroutine,它们立即阻塞在一个通道上,等待被关闭。
package main
import (
"fmt"
"runtime"
"time"
)
func waitAround(die chan bool) {
<-die // 阻塞,等待通道关闭
}
func main() {
var startMemory runtime.MemStats
runtime.ReadMemStats(&startMemory)
start := time.Now()
cpus := runtime.NumCPU() // 获取系统CPU核心数
// runtime.GOMAXPROCS(cpus) // 默认或设置为多核
runtime.GOMAXPROCS(1) // 实验对比:设置为单核
die := make(chan bool)
count := 100000 // 创建10万个goroutine
for i := 0; i < count; i++ {
go waitAround(die) // 启动goroutine
}
elapsed := time.Since(start)
var endMemory runtime.MemStats
runtime.ReadMemStats(&endMemory)
fmt.Printf("Started %d goroutines\n%d CPUs\n%f seconds\n",
count, runtime.GOMAXPROCS(-1), elapsed.Seconds()) // GOMAXPROCS(-1) 获取当前设置
fmt.Printf("Memory before %d\nmemory after %d\n", startMemory.Alloc,
endMemory.Alloc)
fmt.Printf("%d goroutines running\n", runtime.NumGoroutine())
fmt.Printf("%d bytes per goroutine\n", (endMemory.Alloc-startMemory.Alloc)/uint64(runtime.NumGoroutine()))
close(die) // 关闭通道,释放所有阻塞的goroutine
}在典型的多核机器上,当runtime.GOMAXPROCS(cpus)(或不设置,Go 1.5+默认使用所有核心)运行时,程序可能需要约0.5秒。然而,如果将runtime.GOMAXPROCS(1)设置为单核模式,执行时间却可能显著缩短到约0.15秒。这种反直觉的现象表明,在某些特定场景下,多核环境可能引入额外的开销。
性能差异解析
单核模式下的高效性 (GOMAXPROCS(1))
当runtime.GOMAXPROCS(1)时,Go运行时仅使用一个逻辑处理器P和一个操作系统线程M。在这种情况下,程序的主goroutine(main函数)是唯一一个实际在运行的goroutine。由于main函数在循环中不断地创建新的waitAround goroutine,并且在这些goroutine被创建后,它并没有主动让出CPU(例如通过runtime.Gosched()或I/O操作)。
因此,在main函数执行完毕并计算时间之前,所有新创建的waitAround goroutine实际上都没有机会被Go调度器调度到M上执行。它们仅仅是作为数据结构被分配到内存中,并注册到Go运行时中。当main函数执行到close(die)时,这些goroutine才会被唤醒并最终退出。整个过程主要涉及内存分配和内部簿记,Go调度器无需进行复杂的上下文切换,因此效率极高。
立即学习“go语言免费学习笔记(深入)”;
多核模式下的开销 (GOMAXMAXPROCS(N > 1))
当runtime.GOMAXPROCS设置为大于1的值时,Go运行时会启动多个逻辑处理器P,并可能绑定到多个操作系统线程M。在这种多核环境下,Go调度器会尝试将新创建的goroutine均匀地分配到这些可用的P上。
这种分配和调度尝试引入了额外的开销:
- 调度器复杂性增加: 多个P之间需要协调,以确保goroutine的公平调度和负载均衡。这涉及到锁、原子操作以及P之间的通信,增加了调度决策的复杂性。
- 潜在的操作系统级上下文切换: 与单核模式不同,在多核模式下,新创建的waitAround goroutine 有更大的机会 在main函数完成其循环之前,被Go调度器实际调度到某个P上,并开始执行(尽管它们会立即阻塞)。一旦goroutine开始在不同的操作系统线程上运行,就可能涉及操作系统层面的上下文切换,这比Go调度器内部的goroutine切换要慢几个数量级。
- 缓存一致性开销: 如果goroutine在不同的CPU核心上运行,可能会导致CPU缓存失效和缓存一致性协议的额外开销。
简而言之,在多核环境下,Go调度器为了实现并发性,会付出更多的努力去“准备”让goroutine运行,即使这些goroutine最终什么也没做。而单核模式下,由于主goroutine的“霸占”,这些“空闲”goroutine甚至没有获得执行的机会,从而避免了大部分调度开销。
注意事项与总结
- 场景特殊性: 这种性能差异主要发生在创建大量“空闲”且不主动让出CPU的goroutine的极端情况下。对于执行实际计算、进行I/O操作或主动调用runtime.Gosched()的goroutine,多核环境通常能显著提升性能。
- Go版本影响: 较新版本的Go语言调度器在抢占式调度方面有所改进,即使在单核模式下,长时间运行的goroutine也可能被抢占。但这并不会改变本例中“不执行实际工作”的goroutine的根本行为。
- 分析工具: 对于更深层次的系统行为分析,可以使用strace(在Linux上)等工具来观察程序在不同GOMAXPROCS设置下的系统调用差异,从而验证操作系统级上下文切换的发生。
总而言之,理解Go调度器在不同GOMAXPROCS设置下的行为模式至关重要。对于大多数实际应用,多核环境能够充分利用系统资源,提升并发性能。然而,在进行微基准测试或处理大量轻量级、不活跃的并发任务时,需要警惕这种由于调度器开销而可能出现的反直觉性能表现。











