Go调度器是runtime内置的GMP模型,无需手动搭建;执行go run即自动初始化P(默认=CPU核心数)、M(按需创建)、G(每个go语句启动一个),调度循环立即启动。

Go 环境本身不需“搭建调度器”,runtime 已内置完整 GMP 调度逻辑;你真正要做的,是理解它如何响应你的代码、何时会出问题、以及哪些写法会让调度器“失效”或“误判”。
go run 时调度器就已启动,但你根本看不到它
只要执行 go run main.go,Go 运行时就自动初始化 P(默认数量 = CPU 核心数)、M(按需创建)、G(每个 go 启动一个),整个调度循环立刻开始。你不需要 import 任何包、也不用调用初始化函数。
常见误解是以为调度器像第三方库一样要手动启停——它不是组件,而是 runtime 的血液。你可以用 runtime.GOMAXPROCS(n) 调整 P 的数量,但不能“关闭”或“替换”调度器。
-
GOMAXPROCS改变的是 P 的个数,不是线程数;M 可以休眠或复用,P 才是决定并行上限的关键 - 设置
GOMAXPROCS(1)并不等于单线程:仍可能有多个 M,只是只有一个 P 可用,其余 M 会阻塞等待 P - 调整后不会立即生效,会在下一个调度点(如系统调用返回、channel 操作完成)才重新分配 G
为什么 goroutine 不总按启动顺序执行?
因为 G 不是直接交给 OS 线程排队,而是先进入 P 的本地队列(FIFO),再由 M 从本地队列取 G 执行。但这个过程受多种因素干扰:
立即学习“go语言免费学习笔记(深入)”;
- 本地队列满时,新 G 会被扔进全局队列,而 M 在本地无 G 时才会去全局队列抢,顺序就乱了
- 某个 G 长时间占用 M(比如死循环、大计算量、未让出的 syscall),会导致同 P 下其他 G 饥饿
- 网络/IO 操作(如
http.Get、time.Sleep)会触发 Goroutine 让出 M,此时调度器有机会切换其他 G - 没有显式让出点的纯计算 loop,会被 runtime 在每 10ms 左右强制抢占(仅限 Go 1.14+,且依赖编译器插入的协作点)
所以别依赖 go f1(); go f2() 的执行先后——这不是 bug,是设计使然。
channel 和 select 是调度器的“开关”,不是“管道”
chan 操作本质是调度器介入的同步点:发送/接收阻塞时,当前 G 会被挂起,M 去找下一个可运行的 G;非阻塞操作(select 中的 default)则直接跳过。
- 无缓冲 channel 的 send/receive 必须配对,否则一方会阻塞并让出 M
- 带缓冲 channel 的 send 只有在缓冲满时才阻塞;receive 只有在缓冲空时才阻塞
-
select对多个 channel 的轮询不是严格公平的,而是伪随机打乱 case 顺序再尝试,避免总是选第一个 - 滥用
for {}+select {}(没 default)会导致 G 永久阻塞,但不会卡死整个程序——其他 G 仍可运行
goroutine 泄漏比内存泄漏更隐蔽,也更难定位
泄漏不是指“G 没被回收”,而是指“G 一直活着但什么也不干”,比如:go func() { ch 向无人接收的 channel 发送,该 G 就永久挂起,且无法 GC。
- 用
pprof查看/debug/pprof/goroutine?debug=2可看到所有 G 的堆栈,重点关注状态为chan receive或select的长期存活 G - 不要在循环里无限制启动 goroutine,尤其当它们依赖外部信号(如 channel 关闭)才能退出时
- 带超时的 channel 操作(
select+time.After)比裸time.Sleep更安全,但注意time.After本身也会泄漏 timer,应优先用context.WithTimeout
调度器不会帮你发现泄漏——它只管“谁该跑”,不管“谁该死”。G 的生命周期,始终由你的代码逻辑决定。


















