Skip to content

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 后继续清理
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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: 7

jobs 是无缓冲 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.0

main.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")
	}
}

  1. 语言规范:Go statements;程序退出还规定 main 返回时程序退出,不会自动等待其他 goroutine。 ↩︎ ↩︎

  2. context,Go 1.27.2,Context、CancelFunc 和包约定。 ↩︎ ↩︎ ↩︎

  3. 语言规范:接收操作与 Close。 ↩︎

  4. Go 内存模型:Channel communication。 ↩︎

  5. sync.WaitGroup,Go 1.27.2。 ↩︎

  6. x/sync v0.16.0 errgroup 固定源码,WithContext、Go、Wait。 ↩︎

  7. 语言规范:Select statements。 ↩︎ ↩︎

  8. Go FAQ:goroutine 与线程。 ↩︎

  9. Go 1.27.2 runtime/stack.go,stackMin、fixedStack、startingStackSize 与 gcComputeStartingStackSize。属于固定版本实现细节。 ↩︎

  10. Go 官方:Pipelines and cancellation,Stopping short 一节说明阻塞发送与资源保留。 ↩︎

  11. runtime.NumGoroutine;runtime/pprof,goroutine profile 与 debug=2。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准