主题
Goroutine:启动以后,怎样让它按时结束
写一个 go f() 很容易。把它放进服务里之后,还要能回答:这项工作归谁管,卡住时谁通知它退出,调用方怎样知道它真的退出了。这篇文章用一个后台 worker 把这三个问题连起来,再看创建成本和泄漏排查。
先约定范围:goroutine 是 Go 程序里独立推进的一条执行路径;并发允许多条路径交错推进,并行才表示它们同时在处理器上运行。启动多个 goroutine 不承诺它们同时运行,也不承诺先启动的先完成。go 语句的函数和参数在当前 goroutine 中求值,函数调用在新 goroutine 中执行;返回值会被丢弃。[1]
从调用方看,一项后台工作至少有两个出口
假设 worker 不断从 jobs 收到整数,把它们交给 visit 处理。它正常结束的条件是发送方关闭 jobs;提前结束的条件是调用方取消 context。退出时再关闭一个只用来通知完成的 done。
正在绘制图示…
查看图源代码
sequenceDiagram
participant O as 调用方
participant S as Start 函数
participant W as worker
participant J as jobs
O->>S: Start(ctx, jobs, visit)
S->>W: go func(),启动工作循环
S-->>O: 返回 done
J->>W: 一个任务
W->>W: visit(任务)
alt 没有后续任务
J->>W: close(jobs)
else 调用方提前停止
O->>W: cancel(),ctx.Done() 可接收
end
W->>W: 返回,执行 defer
W-->>O: close(done)
O->>O: 接收 done 后继续清理
这里有两种不同的信息:cancel() 是“请停下”,<-done 是“已经停下”。CancelFunc 不等待工作完成;把这两个动作分开,调用方才不会在 worker 还使用文件或连接时就把资源收走。[2]
worker 的主体只需要十几行:
go
for {
select {
case <-ctx.Done():
return
case n, ok := <-jobs:
if !ok {
return
}
visit(n)
}
}外面用 go func() { defer close(done); ... }() 包住。完整函数在文末。ok 不能省:关闭且已读空的 channel 会一直返回元素零值,若把零值当任务处理,worker 就不会结束。[3]
调用方发送一个任务,然后取消并等待:
go
jobs <- 7
cancel()
<-done
fmt.Println("joined; total:", total)本次实际输出:
text
joined; total: 7jobs 是无缓冲 channel,发送返回说明 worker 已接收 7。它接下来会执行 visit(7),之后才回到 select 观察取消。等 done 关闭再读结果,既确认了退出,也获得了关闭 channel 对应的同步关系。示例还用互斥锁保护计数器,便于以后增加“工作期间查询总数”的操作;若只在 join 后读取,按当前代码的读写关系,这把锁可以省掉。[4]
“谁启动谁负责退出”,怎样落实到接口
这是一条设计责任,不是 Go 自动施加的规则。启动者可以把管理权清楚地交给上层,但不能只返回一个 channel,把“什么时候停、怎样等”留给调用者猜。
| 工作种类 | 合理的结束条件 | 调用方需要的接口 | 容易遗漏的部分 |
|---|---|---|---|
| 有限的一批计算 | 所有输入处理完 | 返回结果;并发部分用 WaitGroup 或 errgroup 等齐 | 某个任务报错后,其余任务是否还在运行 |
| 请求里的后台分支 | 请求结束、超时或分支完成 | 传入请求 context,并在返回前 join | 只取消,没有等待 |
| 长期订阅或后台刷新 | 所属服务关闭 | Start 配 Stop/Close 或 cancel,加完成信号 | 每次重连都启动一个新循环,旧循环没退出 |
| 不受控的阻塞调用 | 底层 API 返回、连接关闭或超时 | 使用底层 API 支持的取消、deadline 或关闭方法 | 外层有 context,底层却从不检查它 |
Go 没有一个通用 API 可以从外部安全地强制终止任意 goroutine。函数应当自己返回;main 返回也不会替你等待其他 goroutine,所以不能用“程序最后会退出”代替生命周期管理。[1:1][2:1]
sync.WaitGroup 只管等待,不提供取消或错误传播;errgroup.WithContext 能把首个错误变成共同取消信号,但任务仍须主动返回。工具能减轻协调代码,不能替业务决定生命周期。[5][6]
每个阻塞点都要单独检查
把循环顶部加一个取消判断,并不能覆盖循环内部所有等待。最典型的是 worker 正卡在向下游发送:
go
select {
case results <- value:
case <-ctx.Done():
return
}这段发送模式让“下游不再读取”和“取消”有一条可行的退出路径。若写成裸的 results <- value,上游的 context 关闭也不能把它从发送语句里拉出来。关闭另一个 channel 不会自动中断这次发送。[7]
同样,visit(n) 是一个普通函数调用。本文测试故意让回调停在一个尚未关闭的 release channel 上:取消后 worker 没有退出;测试关闭 release 后,它才执行完回调并退出。这说明取消只影响写出来的检查点。真实回调如果做 HTTP、数据库或网络读写,应该接收并向下传递 context,或者使用该 API 自己的 deadline、关闭机制。[2:2]
还有一个边界:select 中工作分支和取消分支同时就绪时,规范允许选中工作分支。因此本文循环不承诺“调用 cancel 后绝不会再处理一个任务”。如果业务有严格的停止接单边界,要把接单状态和任务入队放进同一个同步协议,而不是把取消当成事务开关。[7:1]
创建成本:便宜到可以常用,但不是没有上限
goroutine 由 Go 运行时调度,多个 goroutine 可以复用操作系统线程。用户代码不需要为每次 go 调用显式创建一个 OS 线程;栈也会按需要增长。[8]
网上常见“一个 goroutine 只占 2 KB”,容易把实现里的一个数字误当成完整成本。Go 1.27.2 的运行时源码里 stackMin 是 2048,但实际栈分配还受平台附加空间、对齐和 startingStackSize 影响;后者可根据 GC 观察到的平均栈使用量调整。这个数字既不是语言保证,也不包含 goroutine 元数据、引用的堆对象、channel 缓冲和业务资源。[9]
| 成本在哪里 | 为什么 goroutine 多了仍会出问题 | 控制办法 |
|---|---|---|
| 栈和运行时元数据 | 每条未结束的执行路径都要保留状态 | 限制活跃任务数,别无限启动 |
| 被引用的业务对象 | 堆栈上的引用可能使大对象仍然可达 | 及时退出,缩短对象生命周期 |
| 调度和同步 | runnable goroutine 需要调度;大量任务争同一把锁仍会串行 | 按实际负载测量,不靠增大数量猜性能 |
| 文件、连接、请求 | 每个任务可能占用比 goroutine 本身大得多的资源 | 按稀缺资源设置并发上限 |
| 等待队列 | 只限制“正在工作”的数量,仍可能无限堆积等待者 | 在启动前获取名额,或使用固定 worker 和有界队列 |
这里不给“创建只需多少纳秒”的通用结论。本次没有做创建成本基准;不同程序里的逃逸、同步、栈增长和负载都可能改变结果。要测自己的调用链,应把启动、业务和等待各自包含什么写清楚,避免把“创建并等一次空函数”误当成服务吞吐量。
泄漏通常藏在一个再也等不到的事件里
goroutine 泄漏指工作已经没有用,却仍因阻塞或循环而无法结束。它与 CPU 是否繁忙无关:一个永远等待接收的 goroutine 可能几乎不耗 CPU,仍会保留栈和引用。Go 的垃圾回收不会替你把这些 goroutine 结束掉。[10]
| 堆栈停留的位置 | 先追问什么 | 常见修正方向 |
|---|---|---|
| channel send | 接收者是不是已经返回? | 发送也监听取消,或明确谁负责把结果读完 |
| channel receive | 发送者还存在吗?谁会关闭输入? | 明确关闭者,或增加取消路径 |
WaitGroup.Wait | 有没有漏掉 Done?计数是不是晚加了? | 启动前登记,任务入口 defer Done(),或用 WaitGroup.Go |
Mutex.Lock | 持锁者正在等什么?有没有反向锁顺序? | 缩短临界区,统一获取顺序,避免持锁做不可控 I/O |
| 网络或回调调用 | 这个具体 API 是否支持 context/deadline? | 把取消传到真正等待的位置 |
| 持续重试循环 | 谁拥有重试的停止条件? | 取消感知的等待,限制重试和任务范围 |
排查时先看趋势,再看堆栈。runtime.NumGoroutine() 返回当前数量,可用于观察请求结束后是否回落;单次数字高不等于泄漏,因为运行时和库也有自己的 goroutine。runtime/pprof 的 goroutine profile 能列出当前堆栈,WriteTo(w, 2) 使用适合人工阅读的详细格式。把稳定增长的相同调用栈找出来,再沿着那个阻塞点寻找缺失的退出条件。[11]
不建议只在测试结束时断言“goroutine 总数与开始相同”。调度和库后台工作都会让数量变化。更可靠的验证是为自己启动的每项任务拿到 done,明确触发输入关闭、取消、错误和下游提前退出,然后在测试超时内等到它完成。本文分别验证了输入处理完、等待输入时取消、回调期间取消三条路径;这些测试不代表所有业务回调都能及时退出。
依据与本次验证
本次环境为 Go 1.27.2、Linux/amd64,go run .、go test -race -count=1 ./... 和 go vet ./... 均实际执行。正常输出见上文。race 通过只说明这次执行路径没有检测到数据竞争,不证明不存在死锁、泄漏或业务错误。本文没有测创建耗时,也没有把运行时栈下限当作单 goroutine 的实测占用。
完整代码与复现方法
以同一目录为根,按下面标出的相对路径保存文件。使用 Go 1.27.2 运行:
sh
go run .
go test -race -count=1 ./...
go vet ./...go.mod
go.mod
module example.com/go-articles/01_goroutine
go 1.25.0main.go
代码块 · 48 行 · 点击收起 / 展开
go
package main
import (
"context"
"fmt"
"sync"
)
// Start owns the goroutine and returns a join signal for its caller.
func Start(ctx context.Context, jobs <-chan int, visit func(int)) <-chan struct{} {
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
return
case n, ok := <-jobs:
if !ok {
return
}
visit(n) // This callback must finish; cancellation cannot interrupt it.
}
}
}()
return done
}
func SendResult(ctx context.Context, results chan<- int, value int) {
select {
case results <- value:
case <-ctx.Done():
return
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
jobs := make(chan int)
var mu sync.Mutex
total := 0
done := Start(ctx, jobs, func(n int) { mu.Lock(); total += n; mu.Unlock() })
jobs <- 7
cancel()
<-done
mu.Lock()
fmt.Println("joined; total:", total)
mu.Unlock()
}main_test.go
代码块 · 64 行 · 点击收起 / 展开
go
package main
import (
"context"
"testing"
"time"
)
func TestCancelAndJoin(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := Start(ctx, make(chan int), func(int) { t.Error("unexpected work") })
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("worker did not exit")
}
}
func TestDrainAndJoin(t *testing.T) {
jobs := make(chan int, 3)
for _, n := range []int{1, 2, 3} {
jobs <- n
}
close(jobs)
sum := 0
<-Start(context.Background(), jobs, func(n int) { sum += n })
if sum != 6 {
t.Fatalf("sum = %d", sum)
}
}
func TestCancelDoesNotInterruptCallback(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
jobs := make(chan int, 1)
jobs <- 1
entered, release := make(chan struct{}), make(chan struct{})
done := Start(ctx, jobs, func(int) { close(entered); <-release })
<-entered
cancel()
select {
case <-done:
t.Fatal("callback was interrupted")
default:
}
close(release)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("worker did not exit")
}
}
func TestBlockedSendCanCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { defer close(done); SendResult(ctx, make(chan int), 7) }()
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("send did not stop")
}
}语言规范:Go statements;程序退出还规定
main返回时程序退出,不会自动等待其他 goroutine。 ↩︎ ↩︎context,Go 1.27.2,
Context、CancelFunc和包约定。 ↩︎ ↩︎ ↩︎x/sync v0.16.0 errgroup 固定源码,
WithContext、Go、Wait。 ↩︎Go 1.27.2 runtime/stack.go,
stackMin、fixedStack、startingStackSize与gcComputeStartingStackSize。属于固定版本实现细节。 ↩︎Go 官方:Pipelines and cancellation,Stopping short 一节说明阻塞发送与资源保留。 ↩︎
runtime.NumGoroutine;runtime/pprof,goroutine profile 与
debug=2。 ↩︎