essay

关于goroutine以及在什么场景下使用

#go

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) // 永远到不了
}()

实践建议

  1. 每个 goroutine 都要有明确的退出条件——答不上来就会泄漏
  2. 阻塞操作带 context 或超时
  3. 用 select 兼顾干活和响应停止
  4. 退出前用 defer 清理

七、判断标准:一张决策流程

  1. 任务是否阻塞在 I/O 或等待上? 是 → 适合;否 → 下一条
  2. 任务是否可拆成独立子任务? 是且 CPU 密集 → 用受限并发,别无脑 spawn
  3. 是否有清晰的退出和错误处理路径? 没有 → 先设计好 context/channel
  4. 并发数是否可控? 不可控 → 加并发上限
  5. 拆成并发后代码是否明显更复杂? 是且收益不大 → 保持串行

八、核心原则

不要为了并发而并发。 goroutine 解决的是"等待"的浪费,不是"计算"的加速。

用它来重叠 I/O 等待、解耦生产消费、管理生命周期,而不是给 CPU 任务堆数量。

记住两条纪律:

  • 每个 goroutine 都要有明确的退出条件
  • 每个 goroutine 都要有错误传递的出口

做到这两点,绝大多数 goroutine 使用问题都能避免。


Go 的并发模型把复杂度从"线程管理"转移到了"生命周期管理"。理解了 goroutine 不能被外部杀死这一点,你就理解了为什么 context 在 Go 里如此重要——它不是可选项,而是管理并发生命周期的必需品。

comments如果有不同意见或者补充,直接留在这里。
contact

在别处继续找到我

如果你想聊技术、设计,或者只是打个招呼。

暂未配置外部链接

欢迎