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

理解 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线程 │
└─────────┘ └─────────┘
核心机制:
- P 的数量默认等于 CPU 核心数(
GOMAXPROCS) - 每个 P 维护一个本地 G 队列
- M 必须绑定一个 P 才能执行 G
- 当本地队列为空时,会从全局队列或其他 P 偷取 G(work stealing)
调度时机
Goroutine 在以下情况会被调度(让出 CPU):
- channel 操作(发送/接收阻塞时)
- 系统调用(如文件 I/O)
time.Sleepruntime.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 面试要点
-
Goroutine 和线程的区别?
- 栈大小:goroutine 2-8KB 可增长,线程 1-8MB 固定
- 调度:goroutine 用户态调度,线程内核态调度
- 创建成本:goroutine 极低,线程较高
-
GMP 模型是什么?
- G=Goroutine,M=OS线程,P=逻辑处理器
- P 的数量决定并行度,默认等于 CPU 核心数
- M 必须绑定 P 才能执行 G
-
什么是 work stealing?
- 当 P 的本地队列为空时,从其他 P 的队列偷取一半 G
-
Goroutine 什么时候会被调度?
- channel 操作、系统调用、time.Sleep、runtime.Gosched()
- Go 1.14+ 支持基于信号的抢占式调度
-
如何避免 goroutine 泄漏?
- 使用 context 控制生命周期
- 确保 channel 有对应的发送/接收方
- 使用 select + done channel 模式
练习
- 启动 10 个 goroutine 并发打印数字,使用 WaitGroup 等待完成
- 使用
runtime.NumGoroutine()观察 goroutine 数量变化 - 故意制造一个 goroutine 泄漏,然后用 context 修复它
- 实现一个简单的并发下载器(模拟)
评论
登录 后发表评论
暂无评论