《Go 语言编程入门》10.1 goroutine 与调度直觉

TaskAPI 一直串行处理任务,一旦要定时扫描到期任务就会阻塞主流程。本节引入 goroutine:用 go 关键字把到期扫描放到后台,实测它的启动成本与内存开销,讲清主 goroutine 返回即进程结束这个经典陷阱,并给出一份后台扫描器。

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() { /* ... */ }()       // 调用匿名函数,注意末尾的 ()

关键点有三个,逐条记住:

  1. go 后面的表达式必须是一次函数调用。go f 是语法错误,go f() 才对。
  2. go 语句立即返回,不会等待函数执行完。它只是「登记一个新的执行单元」,然后当前 goroutine 继续往下跑。
  3. 传给 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 小结与练习

  1. go 是「登记一个新执行单元并立即返回」,不是「执行并等待」。
  2. goroutine 很便宜(初始栈约 2 KB,实测数据在 10.1.3),但「便宜」不等于「可以随便泄漏」。
  3. main goroutine 一返回,进程立刻结束,其他 goroutine 会被直接掐掉。
  4. runtime.NumGoroutine() 是排查泄漏的第一把扳手。

练习:

  • 把 10.1.6 的扫描器改成「每处理一个到期任务就打印一次当前 goroutine 数」,观察它的变化。
  • 把 close(done) 改成向 done 发送一个值(done <- struct{}{}),看看程序行为有什么不同,并思考为什么。
  • 用 runtime.NumGoroutine() 写一段代码,故意制造一个 goroutine 泄漏(提示:在 goroutine 里向一个没人接收的无缓冲 channel 发送),观察数字不回落。

下一节我们正式进入 channel,把「任务」变成可以传递的值,并用 select 处理多路输入。

阅读导航:上一节:9.3 泛型的取舍 · 下一节:10.2 channel 与 select 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练