关于goroutine以及在什么场景下使用
Goroutine:什么时候该用,什么时候不该用
Go 最迷人的地方之一是 go 这个关键字——一个词就能把函数丢到后台跑。但"能开"和"该开"是两码事。这篇文章聊聊 goroutine 到底适合什么场景,以及在哪些情况下它会变成负担。
一、先搞清楚 goroutine 到底是什么
goroutine 是 Go runtime 调度的轻量级执行单元。它有两个关键特征:
- 轻:初始栈只有几 KB,可以动态增长。开几万个都不算夸张。
- 由 runtime 调度:不直接绑定 OS 线程,阻塞时会自动让出,用少量线程撑起海量并发。
但"轻"不等于"免费",更不等于"可以无限开"。它解决的核心问题只有一个:重叠等待。
二、适合使用的场景
1. I/O 密集型并发
这是 goroutine 最大的价值所在。网络请求、数据库查询、文件读写、RPC 调用,这些操作大部分时间在等待。goroutine 在阻塞时会让出线程,所以用很少的线程就能撑起大量并发。
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
resp, err := http.Get(u)
// ...
}(url)
}
wg.Wait()2. 任务天然可并行拆分
一批独立数据、并行分片计算、map-reduce 风格的任务,各子任务之间没有依赖,可以同时推进。
3. 服务端处理每个连接/请求
典型的 HTTP server 模型:每个请求一个 goroutine。代码写起来是同步风格,底层却是高并发——这是 Go 相比回调式/异步框架的核心优势。
4. 后台常驻任务
定时任务、消息消费循环、监控上报、日志刷盘等,用 goroutine 常驻,配合 context 控制生命周期。
5. 流水线(pipeline)模式
多个阶段通过 channel 连接,每个阶段一个或多个 goroutine,数据像流水线一样流动。适合数据加工、ETL 类场景。
6. 超时/取消控制
用 context.WithTimeout + goroutine,可以给一个操作加超时,到点就放弃,不用等它自然结束。
三、不适合使用的场景
1. CPU 密集型且无法拆分
纯计算任务,goroutine 再多也只能跑在 GOMAXPROCS 个核上。开太多反而增加调度和上下文切换开销。这类场景要控制并发度,通常设成核数附近。
2. 需要严格顺序执行
有先后依赖的操作强行拆成 goroutine,只会引入同步复杂度。串行代码更清晰。
3. 共享状态频繁读写
多个 goroutine 频繁读写同一份数据时,锁竞争会让并发退化成串行,甚至更慢。这种情况要么用 channel 传递所有权,要么干脆单 goroutine 处理。
4. 生命周期难以管理的 "fire-and-forget"
随手 go func() 却不管它何时结束、出错怎么办,是 goroutine 泄漏的重灾区。没有退出机制、没有错误处理、没有 context 控制的场景,不要用 goroutine。
5. 短小、无阻塞的纯函数调用
启动 goroutine 本身有开销(栈分配、调度)。任务只是简单计算、几微秒就结束时,直接调用更快。
6. 数量不可控的无限 spawn
在循环里对每个元素无条件 go,元素可能百万级,会瞬间打爆内存和调度器。必须用 worker pool 或信号量限制并发数。
四、受限并发:别让 goroutine 失控
问题:无限制并发
for _, task := range tasks { // 可能 100 万个
go process(task) // 瞬间创建 100 万个 goroutine
}- 内存爆炸
- 下游服务被 100 万并发打挂
- 调度器管理海量 goroutine,开销巨大
方案一:信号量(带缓冲 channel)
const maxConcurrent = 10
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
sem <- struct{}{} // 拿名额,满了就阻塞
go func(t Task) {
defer wg.Done()
defer func() { <-sem }() // 释放名额
process(t)
}(task)
}
wg.Wait()方案二:Worker Pool
固定几个 worker 长期待命,从任务队列里循环取活:
const numWorkers = 10
jobs := make(chan Task, 100)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs { // 不断取活
process(job)
}
}()
}
for _, task := range tasks {
jobs <- task
}
close(jobs) // 封口,worker 陆续下班
wg.Wait()核心三件套:固定 worker 数 + 任务 channel + for range 取活。
方案三:errgroup(最推荐)
g := new(errgroup.Group)
g.SetLimit(10) // 并发上限
for _, task := range tasks {
g.Go(func() error {
return process(task)
})
}
if err := g.Wait(); err != nil {
// 处理错误
}兼顾限流、错误传播和取消。
并发数怎么定
| 场景 | 建议 |
|---|---|
| I/O 密集型 | 大一些,取决于下游承受力 |
| CPU 密集型 | runtime.NumCPU() 附近 |
| 有下游限制 | 不超过下游承载能力 |
五、GOMAXPROCS 与并发限制不是一回事
这两个容易混:
| 控制什么 | 解决什么 | |
|---|---|---|
GOMAXPROCS |
并行执行的 CPU 核数 | 让 CPU 密集任务高效利用多核 |
| Worker pool | 同时运行的 goroutine 数 | 控制内存、保护下游 |
GOMAXPROCS=8 只是说"最多 8 个 goroutine 在 CPU 上跑",但你开 1 万个请求照样会打爆数据库。限制并发数靠 worker pool,不是靠 GOMAXPROCS。
六、无法从外部杀死:最关键的一条约束
一个 goroutine 一旦启动,只有它自己的函数返回才会结束。 没人能从外部把它 kill 掉。
runtime.Goexit() 也只作用于调用它的那个 goroutine 自己——它只是"自杀",不是"他杀"。
为什么这么设计
强制 kill 一个 goroutine 会带来无法解决的问题:
- 可能正持有锁:强杀后锁永远不释放,其他 goroutine 全死锁
- 可能正持有资源:文件、连接、内存结构直接泄漏
- 可能处于不一致的中间状态:强杀留下损坏的数据
Java 当年废弃 Thread.stop() 就是踩了这些坑。Go 从设计上就不给这个能力。
那怎么停止一个 goroutine
只能请它自己退出:
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done(): // 收到取消信号
return // 自己主动退出
case task := <-taskCh:
process(task)
}
}
}
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
// 想停的时候:
cancel() // 只是发信号,不是杀它关键:cancel() 本身不会终止 goroutine,它只是让 goroutine 在下次检查时自己决定退出。如果 goroutine 卡在不响应 context 的阻塞调用里,它照样退不出来。
这就带来 goroutine 泄漏
因为无法从外部杀,如果一个 goroutine 卡在 channel 收发、无超时 I/O、或死循环里没人通知它停,它就永远活着,占着内存,直到进程结束。
// 泄漏示例:没人往 ch 发,也没人 close
go func() {
val := <-ch // 永远阻塞
fmt.Println(val) // 永远到不了
}()实践建议
- 每个 goroutine 都要有明确的退出条件——答不上来就会泄漏
- 阻塞操作带 context 或超时
- 用
select兼顾干活和响应停止 - 退出前用
defer清理
七、判断标准:一张决策流程
- 任务是否阻塞在 I/O 或等待上? 是 → 适合;否 → 下一条
- 任务是否可拆成独立子任务? 是且 CPU 密集 → 用受限并发,别无脑 spawn
- 是否有清晰的退出和错误处理路径? 没有 → 先设计好 context/channel
- 并发数是否可控? 不可控 → 加并发上限
- 拆成并发后代码是否明显更复杂? 是且收益不大 → 保持串行
八、核心原则
不要为了并发而并发。 goroutine 解决的是"等待"的浪费,不是"计算"的加速。
用它来重叠 I/O 等待、解耦生产消费、管理生命周期,而不是给 CPU 任务堆数量。
记住两条纪律:
- 每个 goroutine 都要有明确的退出条件
- 每个 goroutine 都要有错误传递的出口
做到这两点,绝大多数 goroutine 使用问题都能避免。
Go 的并发模型把复杂度从"线程管理"转移到了"生命周期管理"。理解了 goroutine 不能被外部杀死这一点,你就理解了为什么 context 在 Go 里如此重要——它不是可选项,而是管理并发生命周期的必需品。