第 9 章:Goroutine 与并发基础

jerry北京市2026年5月8日Go 22 次阅读 约 10 分钟
第 9 章:Goroutine 与并发基础

理解 goroutine 的原理、GMP 调度模型,以及 goroutine 与线程的本质区别。


9.1 什么是 Goroutine

Goroutine 是 Go 的轻量级协程,由 Go 运行时管理:

func sayHello(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

func main() {
    go sayHello("Alice")  // 启动一个 goroutine
    go sayHello("Bob")

    time.Sleep(time.Second)  // 等待 goroutine 执行(临时方案)
}

Goroutine vs 线程

特性 Goroutine OS 线程
初始栈大小 2-8 KB(可动态增长) 1-8 MB(固定)
创建成本 极低(纳秒级) 较高(微秒级)
调度 Go 运行时调度(用户态) 操作系统调度(内核态)
数量 轻松创建数十万个 通常数千个就是极限
切换成本 低(只需保存少量寄存器) 高(需要内核态切换)

9.2 GMP 调度模型(面试重点)

Go 的调度器基于 GMP 模型:

G (Goroutine) —— 要执行的任务
M (Machine)   —— 操作系统线程
P (Processor) —— 逻辑处理器,持有本地运行队列
┌─────────────────────────────────────────┐
│              全局运行队列                  │
│         [G] [G] [G] [G] [G]             │
└─────────────────────────────────────────┘
        │                    │
   ┌────┴────┐          ┌────┴────┐
   │    P    │          │    P    │
   │ 本地队列 │          │ 本地队列 │
   │ [G][G]  │          │ [G][G]  │
   └────┬────┘          └────┬────┘
        │                    │
   ┌────┴────┐          ┌────┴────┐
   │    M    │          │    M    │
   │ OS线程  │          │ OS线程  │
   └─────────┘          └─────────┘

核心机制:

  1. P 的数量默认等于 CPU 核心数(GOMAXPROCS
  2. 每个 P 维护一个本地 G 队列
  3. M 必须绑定一个 P 才能执行 G
  4. 当本地队列为空时,会从全局队列或其他 P 偷取 G(work stealing)

调度时机

Goroutine 在以下情况会被调度(让出 CPU):

  • channel 操作(发送/接收阻塞时)
  • 系统调用(如文件 I/O)
  • time.Sleep
  • runtime.Gosched() 主动让出
  • 函数调用时的栈检查点(Go 1.14+ 支持抢占式调度)

9.3 启动 Goroutine 的方式

// 方式一:普通函数
go doWork()

// 方式二:匿名函数
go func() {
    fmt.Println("匿名 goroutine")
}()

// 方式三:带参数的匿名函数
name := "Alice"
go func(n string) {
    fmt.Println("Hello", n)
}(name)  // 注意:通过参数传值,避免闭包陷阱

9.4 等待 Goroutine 完成

使用 sync.WaitGroup

var wg sync.WaitGroup

for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Printf("Worker %d 完成\n", id)
    }(i)
}

wg.Wait()  // 阻塞直到所有 goroutine 完成
fmt.Println("全部完成")

使用 channel

done := make(chan bool)

go func() {
    fmt.Println("工作中...")
    time.Sleep(time.Second)
    done <- true
}()

<-done  // 阻塞直到收到信号
fmt.Println("完成")

9.5 Goroutine 泄漏

Goroutine 泄漏是指 goroutine 无法退出,持续占用资源:

// 泄漏示例:没有人接收 channel
func leak() {
    ch := make(chan int)
    go func() {
        ch <- 42  // 永远阻塞,因为没有接收者
    }()
    // 函数返回,但 goroutine 永远不会结束
}

// 解决方案:使用 context 控制生命周期
func noLeak(ctx context.Context) {
    ch := make(chan int)
    go func() {
        select {
        case ch <- 42:
        case <-ctx.Done():
            return  // context 取消时退出
        }
    }()
}

常见泄漏场景:

  • 向无缓冲 channel 发送但无接收者
  • 从无缓冲 channel 接收但无发送者
  • 无限循环没有退出条件
  • 等待永远不会完成的 I/O

9.6 GOMAXPROCS

import "runtime"

// 获取当前值
fmt.Println(runtime.GOMAXPROCS(0))  // 默认等于 CPU 核心数

// 设置
runtime.GOMAXPROCS(4)  // 设置为 4

// 获取 CPU 核心数
fmt.Println(runtime.NumCPU())

// 获取当前 goroutine 数量
fmt.Println(runtime.NumGoroutine())

9.7 面试要点

  1. Goroutine 和线程的区别?

    • 栈大小:goroutine 2-8KB 可增长,线程 1-8MB 固定
    • 调度:goroutine 用户态调度,线程内核态调度
    • 创建成本:goroutine 极低,线程较高
  2. GMP 模型是什么?

    • G=Goroutine,M=OS线程,P=逻辑处理器
    • P 的数量决定并行度,默认等于 CPU 核心数
    • M 必须绑定 P 才能执行 G
  3. 什么是 work stealing?

    • 当 P 的本地队列为空时,从其他 P 的队列偷取一半 G
  4. Goroutine 什么时候会被调度?

    • channel 操作、系统调用、time.Sleep、runtime.Gosched()
    • Go 1.14+ 支持基于信号的抢占式调度
  5. 如何避免 goroutine 泄漏?

    • 使用 context 控制生命周期
    • 确保 channel 有对应的发送/接收方
    • 使用 select + done channel 模式

练习

  1. 启动 10 个 goroutine 并发打印数字,使用 WaitGroup 等待完成
  2. 使用 runtime.NumGoroutine() 观察 goroutine 数量变化
  3. 故意制造一个 goroutine 泄漏,然后用 context 修复它
  4. 实现一个简单的并发下载器(模拟)

← 上一章:包管理与项目组织 | 下一章:Channel 与并发模式 →

评论

登录 后发表评论

暂无评论