Day 11|goroutine 与并发模型基础 [Go 语言 30 天系统学习计划]

学习目标

  1. 理解 goroutine 的本质与调度模型
  2. 区分并发(concurrency)与并行(parallelism)
  3. 掌握 goroutine 的生命周期与常见误区
  4. 建立“goroutine 很轻,但不是免费的”工程认知

一、什么是 goroutine

goroutine 是 Go 提供的用户态并发执行单元,由 Go runtime 负责调度。

创建 goroutine:

go func() {
    fmt.Println("hello goroutine")
}()

与操作系统线程相比,goroutine 的典型特征:

  • 创建成本低
  • 初始栈小(会按需增长)
  • 调度由 Go runtime 完成(不是直接由 OS 调度 goroutine)

工程结论:Go 可以轻松创建大量 goroutine,但滥用依然会导致资源耗尽与不可控并发。


二、并发(Concurrency) vs 并行(Parallelism)

并发:多个任务同时存在(结构上同时推进),不要求同一时刻都在执行。

并行:多个任务同一时刻真正同时执行,依赖多核 CPU 与运行时配置。

理解例子:

  • 单核 CPU 上,多个 goroutine 轮流运行:并发
  • 多核 CPU 上,多个 goroutine 同时运行:并行

补充:是否并行与 GOMAXPROCS(可用 P 的数量)相关。默认通常等于 CPU 核心数,但工程上不建议随意修改,除非你明确知道原因。


三、Go 调度模型(G-P-M)

Go runtime 使用 G-P-M 模型调度 goroutine(理解到“角色职责”层面即可)。

  • G(Goroutine):要执行的任务实体(函数、栈、状态等)
  • P(Processor):逻辑处理器,承载运行 goroutine 的上下文(队列、调度资源)
  • M(Machine):操作系统线程,真正执行指令的载体

一句话概括:

  • G 是任务
  • P 是调度资源与队列
  • M 是 OS 线程执行者

工程结论:你创建的是 G(goroutine),真正执行的是 M(线程),P 决定可并行的程度。


四、goroutine 的生命周期与“主 goroutine 退出”问题

goroutine 的生命周期与其所属进程绑定:主 goroutine 退出,进程结束,其它 goroutine 会被直接终止

常见错误示例:

func main() {
    go func() {
        fmt.Println("work")
    }()
    // main 结束太快,子 goroutine 可能来不及执行
}

可观察修复(仅用于演示,不是工程推荐手段):

func main() {
    go func() {
        fmt.Println("work")
    }()
    time.Sleep(100 * time.Millisecond) // 仅演示:让 goroutine 有机会运行
}

工程推荐的正确方式:使用同步原语(例如 WaitGroup)。

func main() {
    var wg sync.WaitGroup
    wg.Add(1)

    go func() {
        defer wg.Done()
        fmt.Println("work")
    }()

    wg.Wait() // 等待 goroutine 完成
}

五、for + goroutine 的闭包捕获陷阱(必须掌握)

错误示例(经典坑):

func main() {
    for i := 0; i < 3; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
    time.Sleep(time.Second)
}

可能输出(不稳定,但常见):

3
3
3

原因:

  • 闭包捕获的是同一个变量 i(地址/引用),不是每次循环的值
  • goroutine 执行时循环可能已经结束

正确写法 1:通过参数传值(推荐)

for i := 0; i < 3; i++ {
    go func(v int) {
        fmt.Println(v)
    }(i)
}

正确写法 2:重新声明变量(可用但不如传参直观)

for i := 0; i < 3; i++ {
    i := i
    go func() {
        fmt.Println(i)
    }()
}

六、goroutine “很轻,但不是免费的”

goroutine 不是免费资源,常见成本来源:

  • 内存:每个 goroutine 都有栈与运行期结构体(数量大了就显著)
  • 调度:大量 goroutine 会增加调度与上下文切换开销
  • 阻塞:I/O、锁、channel 阻塞会导致堆积
  • 泄漏:goroutine 无法结束(等待永远不会发生的事件)

工程结论:你要控制并发规模,而不是“想开多少 goroutine 就开多少”。


七、goroutine 泄漏(Goroutine Leak)概念与例子

goroutine 泄漏指 goroutine 长期阻塞,无法退出,随时间积累导致资源耗尽。

示例:向无人接收的 channel 发送导致永远阻塞

func leak() {
    ch := make(chan int)
    go func() {
        ch <- 1 // 没有接收者,会一直阻塞,goroutine 无法退出
    }()
}

修复思路:

  • 确保有接收者
  • 使用带缓冲 channel
  • 使用 context 做取消与超时(后续 Day 15 会系统讲)

八、goroutine 与 panic(复习 Day 10 的关键点)

goroutine 中 panic 若不 recover,会导致整个进程崩溃。

推荐在 goroutine 入口加 recover 兜底(边界层):

go func() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("goroutine panic:", r)
        }
    }()
    // goroutine logic
}()

工程建议:recover 只做兜底 + 记录,不能把 panic 当业务流程。


预告:Day 12|channel 基础(通信与同步原语)

下一天你将系统掌握:

  • 无缓冲 / 有缓冲 channel 的语义差异
  • 发送/接收的阻塞行为
  • close 的规则与常见误用
  • 如何用 channel 建模生产者/消费者

发表评论