Go goroutine

goroutine 是 Go 运行时调度的轻量并发任务:启动成本低,但不自动同步、不自动回收错误,生命周期必须由设计者显式管理。

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go goroutine

这个概念为什么会出现

网络服务、批量 I/O、扇出请求时,如果每个任务都绑定一条操作系统线程,成本会迅速升高。Go 把“启动一个并发任务”做成语言级操作:

go doWork()

这解决了启动成本问题,却立刻带来新问题:

  • 谁等待任务结束?
  • 错误交给谁?
  • 如何取消?
  • 共享变量如何可见?

goroutine 不是“自动正确的并行”,而是必须配套生命周期设计的并发执行单元

[!abstract] 一句话理解 go f()f 在独立的 goroutine 中执行;调用方立即继续。运行时负责多路复用到 OS 线程,但同步、退出与错误传播必须显式设计。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:下单后异步发“欢迎短信”

HTTP handler 写完订单后,不想让用户干等短信网关。
常见写法:go sendWelcomeSMS(userID) 丢到后台。

但有两个硬约束:

  1. main/请求函数如果立刻返回且进程退出,后台任务可能根本没跑完
  2. 必须能回答:谁等待结束?失败了谁知道?(本最小例先解决“等待结束”)

WaitGroup 就是“我启动了 N 个后台活,收工前等它们 Done”的计数器。

package main

import (
	"fmt"
	"sync"
)

// sendWelcomeSMS 模拟“给用户发欢迎短信”的慢 I/O。
// 业务上可能调第三方 SMS API;这里只打印证明任务跑到了。
func sendWelcomeSMS(userID int) {
	fmt.Println("sms sent to user", userID)
}

func main() {
	// 模拟:下单成功后要通知用户 1。
	userID := 1

	var wg sync.WaitGroup
	// 预定:我要等 1 个后台任务。
	wg.Add(1)

	// go:在新的 goroutine 里跑,main 不会自动等它。
	// 参数 userID 按值传入,避免闭包捕获外部变量的歧义
	//(循环里启动多个 go 时尤其重要,见闭包笔记)。
	go func(id int) {
		// defer Done:无论 send 正常返回还是以后加了早期 return,
		// 都会把 WaitGroup 计数减一,避免 main 永久卡住。
		defer wg.Done()
		sendWelcomeSMS(id)
	}(userID)

	// 业务上:进程/请求收尾前必须等通知发出(或交给更完整的 worker 池)。
	// 没有 Wait,main 一返回整个程序结束,短信可能根本来不及发。
	wg.Wait()
	fmt.Println("checkout finished")
}

建议运行:

go run .

期望输出(两行顺序通常如下;严格交错时仍应两行都在):

sms sent to user 1
checkout finished

结合场景再看三个关注点

  1. go 只负责启动,不负责等待
    没有 WaitGroup/channel/errgroupmain 返回会直接杀掉未完成任务。

  2. 参数按值传入更干净
    go func(id int){...}(userID) 把“给谁发短信”固定在启动瞬间。

  3. defer wg.Done() 绑在任务出口
    对应“短信函数怎么返回,计数都要减”,避免少 Done 导致死等。

核心概念与准确模型

go 语句

规范:Go statements

go Expression

表达式通常是函数或方法调用。调用在新的 goroutine 中求值;当前 goroutine 不自动等待。

与 OS 线程的关系

OS 线程goroutine
创建成本相对高相对低(实现细节,量级常远小于线程)
调度内核Go 运行时(G-M-P 模型,见进阶笔记)
数量受限于系统可大量存在,但仍受内存与逻辑约束
阻塞占用线程运行时可能把线程用于其他 G(网络轮询等场景)

语言保证的是并发语义与内存模型,而不是“一定比线程快 N 倍”。性能结论要测量。

生命周期责任

Wiki/Code Review 强调 goroutine 生命周期必须清晰:Goroutine Lifetimes

启动前先回答:

  1. 谁创建?
  2. 谁等待(join)?
  3. 谁取消?
  4. 可能阻塞在哪里(channel、锁、I/O)?
  5. 错误如何回到调用方?
flowchart LR
    Main["main/请求处理"] -->|go| G["goroutine"]
    G -->|结果/错误| Ch["channel 或共享结构"]
    Main -->|cancel| Ctx["context"]
    Ctx --> G
    Main -->|Wait| WG["WaitGroup"]

没有强杀 API

Go 不提供“强制杀掉任意 goroutine”的安全通用 API。任务应:

  • 自然返回,或
  • 监听 context.Done() / 取消 channel

runtime.Goexit 只结束当前 goroutine,不是跨任务取消框架。

错误不会自动传播

goroutine 内的 return err 只影响该函数。常见模式:

  • 结果 channel:chan result
  • errgroupgolang.org/x/sync/errgroup
  • 在文档化的同步点收集错误

panic 会在该 goroutine 内展开;若未 recover,默认使整个程序崩溃。不要依赖跨 goroutine 的 panic 传递。

设计动机

  1. 语言级并发
    把启动任务写进语法,而不是库回调。
  2. CSP 风格主线
    与 channel 搭配,鼓励“通信共享内存”。
  3. 工程默认值
    降低线程心智,但通过显式同步把正确性成本留给设计。

边界情况与反直觉行为

1. main 结束即进程结束

go fmt.Println("maybe never")
// 无等待直接返回

2. 闭包捕获循环变量

Go 1.22 起 for 循环变量语义有调整;跨版本代码仍建议显式传参:

for i := 0; i < 3; i++ {
    go func(i int) { fmt.Println(i) }(i)
}

3. 数据竞争

// 危险:无同步并发写
go func() { x++ }()
go func() { x++ }()

go test -race 检测(见 race detector)。

4. 泄漏

goroutine 卡在永远无发送方的接收、或未读满的发送上,会常驻到进程结束。

5. 并发 ≠ 并行

单核或 GOMAXPROCS=1 时仍是并发交错;并行需要多核与运行时调度允许。

常见误区

[!warning] 常见误区:把 goroutine 当线程 API 错误:按 pthread 思维创建/join/kill。
正确:任务 + channel/context/WaitGroup 协作。

[!warning] 常见误区:fire-and-forget 无边界 错误:请求路径随意 go 且不限流。
正确:有超时、有取消、有并发上限(worker pool)。

[!warning] 常见误区:在 goroutine 里直接写请求的 ResponseWriter 错误:HTTP handler 返回后仍写响应。
正确:理解请求生命周期;需要异步则自行设计完成协议。

工程实践

  1. 先同步写对,再 go
  2. 每个 go 都有退出路径
  3. 用 context 贯穿取消go-context
  4. 限制并发度:信号量 channel 或 worker pool(go-worker-pool
  5. 测试加 -race
  6. 日志带请求 ID,否则并发堆栈难读
  7. 不要在库中偷偷启动不受控 goroutine;若必须启动,文档说明谁负责停

可验证实验

  1. 去掉 WaitGroup,观察输出是否丢失。
  2. 有意制造数据竞争并用 go test -race
  3. 启动向无接收者的无缓冲 channel 发送的 goroutine,用 pprof/超时观察泄漏。
  4. 对比 GOMAXPROCS 对 CPU 密集任务的影响(测量,不下绝对结论)。

本节总结

  • 本质:轻量并发任务,由运行时调度。
  • 关键规则:不自动等待、不自动传错、无强杀、共享必须同步。
  • 最易错:泄漏、闭包、main 提前退出、无取消。
  • 下一步channel 作为通信与同步主线。

自测题

概念题

  1. 为什么说 goroutine 不是 OS 线程的别名?
  2. 启动 goroutine 前应回答哪几个生命周期问题?
  3. goroutine 内返回的 error 会怎样?

代码推理题

func main() {
    ch := make(chan int)
    go func() { ch <- 1 }()
    // 无接收
}

程序行为可能是什么?

工程思考题

API handler 要并行请求 3 个下游。如何保证:任一失败可取消其余、总超时 2s、错误可返回客户端?

参考答案

展开
  1. 由 Go 运行时多路复用,语义与成本模型不同。
  2. 等待者、取消者、阻塞点、错误归属。
  3. 仅留在该调用栈,除非经 channel/结构体传出。
    代码题:main 可能在发送完成前结束;或短暂阻塞后进程退出;属于未定义完整同步的坏例子。
    工程:context+timeout,errgroup 或 channel 收集,下游请求绑定 ctx。

延伸阅读与资料来源

资料类型支撑
Spec — Go statements规范go 语句
CodeReviewComments — Goroutine lifetimes官方 Wiki生命周期审查标准
Effective Go — Concurrency官方文档并发风格
Go Memory Model规范可见性与同步

笔记元信息

  • 建议文件名:go-goroutines.md
  • 所属阶段:阶段四
  • 本篇状态:已深化
  • 建议下一篇:Go channel
创建于 2026/6/20 更新于 2026/7/15