第2章 Goroutine深入:从创建到调度

好,咱们接着聊Goroutine。上一章我们知道了它是什么,这一章,咱们深入进去,看看它到底怎么跑起来的。

我个人觉得,理解Goroutine的底层机制,是区分Go程序员水平的分水岭。你想想看,面试的时候,光会说「go func()」可不够,得能讲清楚它背后的故事。

2.1 Goroutine的创建与启动

创建Goroutine,语法上就一行:go f()。但这一行背后,Go运行时干了三件事。

// 最简单的启动方式
go func() {
    fmt.Println("Hello from goroutine")
}()

// 带参数的启动
msg := "world"
go func(s string) {
    fmt.Println("Hello", s)
}(msg)

嗯,这里要注意:闭包捕获变量时,容易踩坑。我曾经在项目里写过这样的代码:

// 错误的写法
for i := 0; i < 5; i++ {
    go func() {
        fmt.Println(i)  // 可能全部打印5
    }()
}

// 正确的写法
for i := 0; i < 5; i++ {
    go func(val int) {
        fmt.Println(val)
    }(i)
}
避坑指南: 我曾经在并发处理一批任务时,忘了传参,结果所有Goroutine都读取了循环变量的最终值。排查了半天才发现是闭包引用的问题。记住:循环变量要显式传递

2.2 Goroutine的生命周期

一个Goroutine从生到死,经历这几个阶段:

阶段 说明 触发条件
创建 分配栈空间,初始化状态 执行 go 关键字
就绪 等待被调度到M上执行 创建完成或阻塞结束
运行 正在某个M上执行 被调度器选中
阻塞 等待系统调用或channel IO操作、锁、channel
结束 执行完毕或panic 函数返回或异常

说白了,Goroutine不是一创建就立刻跑的。它得排队,等调度器给它分配CPU时间。我刚开始学的时候,以为go func()是立即执行,后来才发现不是这么回事。

小技巧: 可以用 runtime.Gosched() 主动让出CPU,让其他Goroutine有机会执行。但别滥用,频繁调用反而影响性能。

2.3 Goroutine的栈管理

这是Goroutine最巧妙的设计之一。传统线程的栈是固定的,一般2MB起步。Goroutine的栈呢?初始只有2KB!

为什么会这样?你想想看,如果每个Goroutine都分配2MB,那100万个Goroutine就需要2TB内存,这谁受得了?

Go的解决方案是:动态栈。栈不够用了,就自动扩容。我见过一个项目,Goroutine的栈从2KB一路涨到1GB,但这种情况极少见。

// 查看当前Goroutine的栈大小
func main() {
    var stackSize runtime.Stack
    buf := make([]byte, 1024)
    n := runtime.Stack(buf, false)
    fmt.Printf("Stack size: %d bytes\n", n)
}
核心要点:
  • 初始栈:2KB(极轻量)
  • 扩容策略:翻倍增长,上限1GB
  • 缩容策略:GC时检查,空闲超过1/4则缩容
  • 扩容代价:需要拷贝栈数据,涉及指针修正

我在项目中遇到过一个问题:某个Goroutine里递归调用了很深,导致栈频繁扩容。虽然Go能自动处理,但频繁的栈拷贝会影响性能。后来我改成了迭代实现,问题就解决了。

2.4 Goroutine的调度机制

Go的调度器,核心就是GMP模型。我习惯这么理解:

  • G - Goroutine,就是你的任务
  • M - Machine,操作系统线程,真正干活的
  • P - Processor,逻辑处理器,负责调度G到M上

说白了,P就是M和G之间的调度员。默认情况下,P的数量等于CPU核心数。你可以通过 runtime.GOMAXPROCS() 来设置。

// 查看和设置P的数量
fmt.Println("默认P数量:", runtime.GOMAXPROCS(0))

// 设置P为2个
runtime.GOMAXPROCS(2)

调度策略主要有三种:

策略 触发时机 说明
协作式调度 函数调用、GC G主动让出,安全点检查
抢占式调度 长时间运行(>10ms) 强制暂停,防止饿死
阻塞调度 系统调用、channel G阻塞时,P找其他G执行
注意: 我曾经在项目中遇到过一个死循环的Goroutine,没有函数调用,没有IO操作。在Go 1.14之前,这种Goroutine会一直霸占CPU,导致其他Goroutine饿死。Go 1.14引入了基于信号的抢占式调度,才解决了这个问题。

调度器的核心逻辑,我简化一下:

// 伪代码,理解调度流程
func schedule() {
    for {
        // 1. 从本地队列取G
        g := p.localQueue.pop()
        if g == nil {
            // 2. 本地队列空,从全局队列偷
            g = globalQueue.pop()
        }
        if g == nil {
            // 3. 全局队列也空,从其他P偷
            g = stealFromOtherP()
        }
        // 4. 执行G
        execute(g)
    }
}

嗯,这里要注意:工作窃取是调度器的核心优化。每个P都有自己的本地队列,当本地队列空了,就去别的P那里偷一半过来。这样能保证负载均衡。

实战建议:
  • IO密集型任务:可以设置GOMAXPROCS大于CPU核心数
  • CPU密集型任务:GOMAXPROCS等于CPU核心数即可
  • 避免创建过多Goroutine:虽然轻量,但调度也有开销

最后说一句,理解Goroutine的调度机制,不是为了让你去修改它,而是为了写出更高效的并发代码。你想想看,知道调度器怎么工作,你就知道什么时候该让出CPU,什么时候该用channel,什么时候该用锁。

下一章,咱们聊聊channel,Go并发编程的另一个核心武器。