10.1 goroutine 与调度直觉
到第 9 章为止,TaskAPI 的一切都是串行的:add、list、toggle 一个接一个执行,谁也不会同时发生。这在命令行小工具阶段没问题,但只要你想要一个「每隔几秒检查一次有没有任务到期,到期就自动关闭」的功能,串行模型立刻就撑不住了——扫描必须常驻,而主流程不能停在那里等它。
Go 给出的答案是 goroutine。它看起来只是 go 关键字加一个函数调用,但背后的语义、成本和陷阱都需要先建立正确直觉,否则后面三章会处处踩坑。
本节把 TaskAPI 推进到:新增一个后台 goroutine,周期性扫描并关闭到期任务,主流程不再被扫描逻辑阻塞。
10.1.1 先看一个「必须并发」的需求
假设 Task 增加一个 Due 字段,表示截止时间:
type Task struct {
ID int64
Title string
Done bool
Due time.Time // 零值表示不设截止时间
}
现在需要一个「到期自动关闭」的能力。如果写成同步函数:
func scanDueTasks(tasks []Task) int {
closed := 0
for i := range tasks {
if !tasks[i].Done && !tasks[i].Due.IsZero() && tasks[i].Due.Before(time.Now()) {
tasks[i].Done = true
closed++
}
}
return closed
}
这个函数本身没错,但它只能被「调用一次」。要让它持续生效,你就得写一个 for { scanDueTasks(...); time.Sleep(5*time.Second) }。一旦这个循环和主流程写在同一个 goroutine 里,主流程就永远走不到下一行。
并发在这里不是为了「快」,而是为了让两件事同时活着。
10.1.2 go 关键字做了什么
语法只有一条:go 函数调用。
go scanDueTasks(tasks) // 调用具名函数
go func() { /* ... */ }() // 调用匿名函数,注意末尾的 ()
关键点有三个,逐条记住:
go后面的表达式必须是一次函数调用。go f是语法错误,go f()才对。go语句立即返回,不会等待函数执行完。它只是「登记一个新的执行单元」,然后当前 goroutine 继续往下跑。- 传给
go的参数在go语句执行时就被求值,不是在 goroutine 真正开始运行时。
第 3 点是最容易被忽略的,而它正是「闭包捕获循环变量」这个经典坑的来源。在 Go 1.22 之前,下面这段代码会打印出三个 4:
for i := 1; i <= 3; i++ {
go func() { fmt.Println(i) }()
}
原因是三个闭包共享同一个 i,等它们真正运行时循环早已结束。Go 1.22 起,for 语句中声明的循环变量每次迭代都是全新变量,这个问题被语言层面修掉了。实测(go1.27.0)三种写法的区别:
var wg sync.WaitGroup
// A. 循环变量在 for 内声明:每次迭代新变量,输出 1、2、3(顺序不定)
for i := 1; i <= 3; i++ {
wg.Go(func() { fmt.Println("A:", i) })
}
wg.Wait()
// B. 变量在循环外声明:所有闭包仍然共享它,通常输出 4、4、4(多核下偶尔会提前跑起来,看到 2 或 3)
i := 0
for i = 1; i <= 3; i++ {
wg.Go(func() { fmt.Println("B:", i) })
}
wg.Wait()
// C. 显式传参:任何版本都安全,输出 1、2、3
for j := 1; j <= 3; j++ {
wg.Add(1)
go func(n int) { defer wg.Done(); fmt.Println("C:", n) }(j)
}
wg.Wait()
实测输出:
A: 3
A: 1
A: 2
B: 4
B: 4
B: 4
C: 3
C: 1
C: 2
所以结论不是「1.22 之后就可以随便捕获了」,而是:只要变量是在循环体外声明的,坑就还在。显式传参(写法 C)是唯一不依赖语言版本、读代码的人一眼就懂的写法,推荐在团队代码里统一采用。
(上面的 wg.Go 是 Go 1.25 引入的 sync.WaitGroup 方法,等价于 Add(1) + go func(){ defer Done(); ... }(),第 11.2 节会详细讲。)
10.1.3 调度直觉:goroutine 为什么便宜
你不需要理解调度器内部结构,只需要记住三件事,就足以做出工程判断:
| 维度 | 操作系统线程 | goroutine |
|---|---|---|
| 初始栈大小 | MB 量级 | 约 2 KB,按需增长 |
| 创建/销毁成本 | 需要陷入内核,微秒~毫秒级 | 用户态完成,纳秒级 |
| 谁来调度 | 操作系统内核调度器 | Go 运行时调度器 |
| 数量级 | 几百到几千 | 十万到百万 |
「约 2 KB」不是我凭记忆写的。下面这段程序创建 10 万个阻塞中的 goroutine,然后对比 runtime.MemStats.StackInuse 的前后差值:
const n = 100_000
var started atomic.Int64
var wg sync.WaitGroup
block := make(chan struct{})
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
started.Add(1)
<-block // 全部卡在这里,保持存活
wg.Done()
}()
}
for started.Load() < n {
runtime.Gosched()
}
runtime.ReadMemStats(&m2)
close(block)
wg.Wait()
delta := m2.StackInuse - m1.StackInuse
fmt.Printf("平均每 goroutine 栈 = %.0f 字节\n", float64(delta)/n)
实测输出(Apple M1 Pro,go1.27.0):
栈内存增量 = 195.5 MB, 平均每 goroutine 栈 = 2050 字节
也就是说,10 万个 goroutine 的栈加起来不到 200 MB,而且这些栈是惰性增长的——一个只调用几层函数就结束的 goroutine,永远用不到 2 KB。
结论:goroutine 的贵贱取决于你在里面做什么,而不是创建它本身。创建十万个做轻量计算是常规操作;创建一百个各占几百 MB 的则显然不行。
10.1.4 最大的坑:main 返回,程序就结束了
这是初学者 90% 会踩的坑。看这段代码:
func scanDueTasks() {
time.Sleep(200 * time.Millisecond)
fmt.Println("[扫描] 处理完 3 个到期任务")
}
func main() {
go scanDueTasks()
fmt.Println("main 退出,程序立刻结束")
}
实际输出:
main 退出,程序立刻结束
[扫描] 处理完 3 个到期任务 永远不会打印。原因很简单:Go 程序的生命周期等于 main goroutine 的生命周期。main 函数一返回,运行时不会去等其他 goroutine,直接终止整个进程。
这不是 bug,而是设计。Go 官方文档明确写着:程序不会等待其他 goroutine 结束。所以「启动一个后台 goroutine 就不管了」这种写法,只有在主流程确实还活着的时候才成立。
要让它正确工作,你必须显式地等待。第 11 章会讲 sync.WaitGroup,本节先用最朴素的 channel 做一次「握手」:
func main() {
done := make(chan struct{})
go func() {
scanDueTasks()
close(done) // 干完活,关闭信号通道
}()
<-done // 阻塞在这里,直到 done 被关闭
fmt.Println("扫描完成,可以安全退出")
}
<-done 会让 main goroutine 挂起,直到 close(done) 执行。这是最简单的「等待一个 goroutine 结束」的模式,后面的章节会给出更通用的写法。
10.1.5 用 runtime.NumGoroutine 观察数量
调试并发问题时,最有效的第一个动作往往是「看看现在有多少 goroutine」。runtime.NumGoroutine() 返回当前存活的 goroutine 数量:
func main() {
fmt.Println("启动前 goroutine 数:", runtime.NumGoroutine())
go func() {
time.Sleep(50 * time.Millisecond)
fmt.Println("后台扫描完成")
}()
fmt.Println("启动后 goroutine 数:", runtime.NumGoroutine())
time.Sleep(100 * time.Millisecond)
fmt.Println("结束后 goroutine 数:", runtime.NumGoroutine())
}
实测输出:
启动前 goroutine 数: 1
启动后 goroutine 数: 2
后台扫描完成
结束后 goroutine 数: 1
注意「启动前」是 1 而不是 0——那是 main goroutine 自己。这个数字在长期运行的服务里非常有用:如果它随着请求量单调上涨、从不回落,那你几乎肯定有 goroutine 泄漏(某个 goroutine 卡在永远读不到的 channel 上,或者卡在没人调用的 cancel() 上,后者第 12 章会讲)。
10.1.6 给 TaskAPI 加后台到期扫描器
现在把前面的碎片拼成一个真正能用的东西。下面这段程序是 TaskAPI 的第一个并发组件:一个常驻的后台扫描器,每 20 毫秒检查一次到期任务,并支持被外部优雅停止。
package main
import (
"fmt"
"time"
)
type Task struct {
ID int64
Title string
Done bool
Due time.Time
}
func main() {
now := time.Now()
tasks := []Task{
{ID: 1, Title: "写周报", Due: now.Add(-2 * time.Hour)},
{ID: 2, Title: "评审 PR", Due: now.Add(3 * time.Hour)},
{ID: 3, Title: "交房租", Due: now.Add(-30 * time.Minute)},
}
done := make(chan struct{})
go func() {
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
for i := range tasks {
if !tasks[i].Done && tasks[i].Due.Before(time.Now()) {
tasks[i].Done = true
fmt.Printf("到期任务已关闭: #%d %s\n", tasks[i].ID, tasks[i].Title)
}
}
case <-done:
fmt.Println("扫描器收到停止信号,退出")
return
}
}
}()
time.Sleep(60 * time.Millisecond)
close(done)
time.Sleep(20 * time.Millisecond)
fmt.Println("main 结束")
}
实测输出:
到期任务已关闭: #1 写周报
到期任务已关闭: #3 交房租
扫描器收到停止信号,退出
main 结束
这份代码里有三个后面章节会展开的伏笔:
time.NewTicker+for+select是「周期性任务」的标准骨架。用time.Sleep循环也能跑,但Ticker的节奏更稳,且能被select中断。done这个「关闭即广播」的 channel 是 Go 里最常用的停止信号。close一个 channel 会同时唤醒所有在它上面等待的 goroutine,这一点第 10.2 节会详细讲。tasks切片在这里只有扫描器一个 goroutine 在写,main 不碰它,所以是安全的。一旦多个 goroutine 同时读写同一个tasks,就必须上锁——那是第 11 章的主题。
10.1.7 什么时候不该用 goroutine
并发不是免费的,它会带来三类成本:
| 成本 | 说明 | 什么时候会痛 |
|---|---|---|
| 调试成本 | 执行顺序不确定,出问题难以复现 | 逻辑本身有依赖关系时 |
| 同步成本 | 需要锁、channel、等待,代码变复杂 | 共享数据多、交互频繁时 |
| 调度成本 | 上下文切换、缓存失效 | goroutine 数量远超 CPU 核数且都在跑 |
典型的「不该并发」场景:任务之间有严格先后依赖、任务本身只有几微秒、你还没想清楚「谁来等、谁取消、谁收集结果」。判断标准可以简化成一句话:如果两个任务之间不需要交换中间结果,才考虑并发。
10.1.8 小结与练习
go是「登记一个新执行单元并立即返回」,不是「执行并等待」。- goroutine 很便宜(初始栈约 2 KB,实测数据在 10.1.3),但「便宜」不等于「可以随便泄漏」。
- main goroutine 一返回,进程立刻结束,其他 goroutine 会被直接掐掉。
runtime.NumGoroutine()是排查泄漏的第一把扳手。
练习:
- 把 10.1.6 的扫描器改成「每处理一个到期任务就打印一次当前 goroutine 数」,观察它的变化。
- 把
close(done)改成向done发送一个值(done <- struct{}{}),看看程序行为有什么不同,并思考为什么。 - 用
runtime.NumGoroutine()写一段代码,故意制造一个 goroutine 泄漏(提示:在 goroutine 里向一个没人接收的无缓冲 channel 发送),观察数字不回落。
下一节我们正式进入 channel,把「任务」变成可以传递的值,并用 select 处理多路输入。
阅读导航:上一节:9.3 泛型的取舍 · 下一节:10.2 channel 与 select 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。