Go 从 0 到精通 · 第 25 课:goroutine 入门

学习定位:这是整套 Go 教程的第 25 课,也是阶段五(并发与工程阶段)的第一课。
前置要求:已经完成第 24 课,具备用 Go 编写完整小项目的能力。需要熟悉函数、切片、循环等基础知识。
本课目标:理解 Go 并发的基本概念,能创建和运行 goroutine,理解主协程退出问题并掌握基本的等待方式,能初步感受并发编程的威力和复杂性。


1. 本课你要解决的核心问题

到目前为止,你写的所有程序都是顺序执行的——一行一行往下跑,做完一件事才做下一件事。但现实世界不是这样运转的:

  • 一个 Web 服务器要同时处理多个用户的请求
  • 一个下载器要同时下载多个文件
  • 一个聊天程序要同时发送和接收消息

这就是并发要解决的问题。而 Go 语言最大的卖点之一,就是它把并发做得极其简单

你需要搞明白以下问题:

  • 什么是并发,和并行有什么区别
  • goroutine 是什么
  • 怎么创建一个 goroutine
  • 为什么我的 goroutine “没有执行”
  • 怎么等 goroutine 执行完
  • goroutine 和线程有什么区别
  • 开多少个 goroutine 合适

学完这一课,你就迈进了 Go 最强大也最有意思的领域。


2. 先搞清楚概念:并发 vs 并行

这两个词经常被混用,但它们不是一回事。

2.1 并发(Concurrency)

并发是一种程序设计方式——把任务拆成可以独立推进的部分。

想象一个厨师做饭:先把水烧上,等水开的时候去切菜,切完菜再去下面条。一个人,但三件事在"同时"推进。这就是并发——同一时间段内处理多件事

2.2 并行(Parallelism)

并行是真正的同时执行——多个 CPU 核心各干各的。

两个厨师,一个切菜,一个烧水。两件事在同一时刻同时发生。这才是并行。

2.3 关系

1
2
并发:一个人同时管几件事(交替推进)
并行:多个人同时干活(真正同时)

Go 的 goroutine 是并发的设计工具。如果你的机器有多个 CPU 核心,Go 的运行时会自动把 goroutine 分配到不同核心上并行执行。但你写代码时,只需要关心并发设计——怎么拆任务,Go 的运行时帮你搞定并行。


3. goroutine 是什么

3.1 一句话定义

goroutine 是 Go 中的轻量级执行单元。你可以把它理解为一个超轻量的"线程"。

3.2 你已经用过一个 goroutine 了

每个 Go 程序启动时,main 函数就运行在一个 goroutine 里——叫做主 goroutine。之前写的所有程序都是只有这一个 goroutine。

1
2
3
func main() {
fmt.Println("我在主 goroutine 中运行")
}

这一课要学的就是:怎么创建更多的 goroutine。


4. 创建你的第一个 goroutine

4.1 go 关键字

创建 goroutine 只需要一个关键字:go。在函数调用前面加 go,这个函数就会在一个新的 goroutine 中执行:

1
2
3
4
5
6
7
8
9
10
11
12
package main

import "fmt"

func sayHello() {
fmt.Println("Hello from goroutine!")
}

func main() {
go sayHello() // 在新的 goroutine 中执行 sayHello
fmt.Println("Hello from main!")
}

运行一下:

1
Hello from main!

等等,sayHello 的输出呢?

4.2 第一个大坑:主 goroutine 退出,程序就结束了

这是学 goroutine 必须搞懂的第一件事:

main 函数返回时,程序立即结束,不管其他 goroutine 有没有执行完。

上面的代码中,go sayHello() 启动了一个新的 goroutine,但 main 函数紧接着就执行了 fmt.Println("Hello from main!"),然后 main 返回,程序结束。新的 goroutine 还没来得及执行就被"杀"了。

这就像一个公司的老板(主 goroutine)下班走人了,员工(其他 goroutine)不管手里的活干没干完,公司直接关门。

4.3 临时方案:用 time.Sleep 等一等

最粗暴的办法是让主 goroutine 睡一会儿,给其他 goroutine 时间执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"time"
)

func sayHello() {
fmt.Println("Hello from goroutine!")
}

func main() {
go sayHello()

time.Sleep(100 * time.Millisecond) // 等 100 毫秒
fmt.Println("Hello from main!")
}

输出:

1
2
Hello from goroutine!
Hello from main!

这次看到 goroutine 的输出了。但这是一个很烂的解决方案

  • 你不知道 goroutine 需要多少时间才能执行完
  • 睡少了——goroutine 还没执行完
  • 睡多了——白白浪费时间
  • 生产环境绝对不能用这种方式

但是作为学习工具,time.Sleep 能帮你直观地看到 goroutine 确实在运行。后面我们马上学正确的等待方式。


5. 启动多个 goroutine

5.1 看看多个 goroutine 交替执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"time"
)

func printNumbers(name string) {
for i := 1; i <= 5; i++ {
fmt.Printf("[%s] %d\n", name, i)
time.Sleep(50 * time.Millisecond) // 模拟耗时操作
}
}

func main() {
go printNumbers("A")
go printNumbers("B")
go printNumbers("C")

time.Sleep(500 * time.Millisecond)
fmt.Println("全部完成")
}

可能的输出(每次运行可能不同):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[C] 1
[A] 1
[B] 1
[A] 2
[C] 2
[B] 2
[B] 3
[C] 3
[A] 3
[A] 4
[B] 4
[C] 4
[C] 5
[A] 5
[B] 5
全部完成

关键观察

  1. A、B、C 的输出是交错的——它们在并发执行
  2. 每次运行的顺序可能不同——这是并发的本质特征
  3. 三个任务本来需要 3 × 250ms = 750ms,但并发执行只用了约 250ms

5.2 顺序执行 vs 并发执行的对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
"fmt"
"time"
)

func task(name string) {
fmt.Printf("%s 开始\n", name)
time.Sleep(200 * time.Millisecond) // 模拟耗时操作
fmt.Printf("%s 结束\n", name)
}

func main() {
// 顺序执行
fmt.Println("=== 顺序执行 ===")
start := time.Now()

task("任务1")
task("任务2")
task("任务3")

fmt.Printf("耗时: %v\n\n", time.Since(start))

// 并发执行
fmt.Println("=== 并发执行 ===")
start = time.Now()

go task("任务1")
go task("任务2")
go task("任务3")

time.Sleep(300 * time.Millisecond)
fmt.Printf("耗时: %v\n", time.Since(start))
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=== 顺序执行 ===
任务1 开始
任务1 结束
任务2 开始
任务2 结束
任务3 开始
任务3 结束
耗时: 600ms

=== 并发执行 ===
任务3 开始
任务1 开始
任务2 开始
任务1 结束
任务2 结束
任务3 结束
耗时: 300ms

顺序执行 600ms,并发执行约 300ms——快了一倍。这就是并发的价值:多件事同时推进,总时间取决于最慢的那一个。


6. 正确的等待方式:sync.WaitGroup

time.Sleep 是猜时间,不靠谱。Go 标准库提供了 sync.WaitGroup,让你精确地等待一组 goroutine 执行完毕。

6.1 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"sync"
"time"
)

func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 执行完毕,计数器减 1

fmt.Printf("Worker %d 开始\n", id)
time.Sleep(100 * time.Millisecond) // 模拟耗时操作
fmt.Printf("Worker %d 完成\n", id)
}

func main() {
var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
wg.Add(1) // 计数器加 1
go worker(i, &wg) // 启动 goroutine
}

wg.Wait() // 阻塞,直到计数器归零
fmt.Println("所有 Worker 执行完毕")
}

输出:

1
2
3
4
5
6
7
Worker 3 开始
Worker 1 开始
Worker 2 开始
Worker 1 完成
Worker 3 完成
Worker 2 完成
所有 Worker 执行完毕

6.2 WaitGroup 三步曲

sync.WaitGroup 就三个方法,像一个计数器:

方法 作用 什么时候调用
wg.Add(n) 计数器加 n 启动 goroutine 之前
wg.Done() 计数器减 1 goroutine 结束时
wg.Wait() 阻塞等待,直到计数器变为 0 主 goroutine 中

工作流程:

1
2
3
4
5
6
7
8
Add(1) → 计数器: 1    启动第 1 个 goroutine
Add(1) → 计数器: 2 启动第 2 个 goroutine
Add(1) → 计数器: 3 启动第 3 个 goroutine
Wait() → 阻塞...
Done() → 计数器: 2 第 X 个 goroutine 完成
Done() → 计数器: 1 第 Y 个 goroutine 完成
Done() → 计数器: 0 第 Z 个 goroutine 完成
Wait() → 解除阻塞 继续执行

6.3 defer wg.Done() 的重要性

1
2
3
4
5
6
7
8
9
10
11
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done() // 用 defer 确保无论如何都会调用 Done

// 即使中间 return 或 panic,defer 也会执行
if id == 2 {
fmt.Printf("Worker %d 跳过\n", id)
return // 提前返回,defer wg.Done() 仍然会执行
}

fmt.Printf("Worker %d 工作中\n", id)
}

为什么用 defer(第 17 课):如果函数有多个 return 路径,或者中间可能 panic,用 defer wg.Done() 保证计数器一定会减 1。如果忘了调 Done()Wait() 会永远阻塞,程序就卡死了。

6.4 WaitGroup 必须传指针

1
2
3
4
5
6
7
8
9
10
11
// 错误:传值,worker 里操作的是副本
func worker(id int, wg sync.WaitGroup) { // 值传递!
defer wg.Done() // 操作的是副本,对原始 WaitGroup 没有影响
// ...
}

// 正确:传指针
func worker(id int, wg *sync.WaitGroup) { // 指针传递
defer wg.Done() // 操作的是同一个 WaitGroup
// ...
}

sync.WaitGroup 是一个结构体,传值会拷贝一份(第 12 课)。你在副本上调 Done(),原始的计数器不会变,Wait() 永远等不到。


7. 匿名函数 goroutine

7.1 基本写法

不是每个 goroutine 都需要单独定义一个函数。用匿名函数更简洁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("goroutine %d 执行\n", id)
}(i) // 注意:把 i 作为参数传进去
}

wg.Wait()
fmt.Println("完成")
}

输出(顺序不确定):

1
2
3
4
goroutine 3 执行
goroutine 1 执行
goroutine 2 执行
完成

7.2 第二个大坑:闭包变量捕获

这是 Go 并发编程中最经典的 bug。看看不传参数会怎样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
wg.Add(1)
go func() { // 没有传参数
defer wg.Done()
fmt.Printf("goroutine %d 执行\n", i) // 直接用外层的 i
}()
}

wg.Wait()
}

你可能期望输出 1、2、3,但实际输出很可能是:

1
2
3
goroutine 4 执行
goroutine 4 执行
goroutine 4 执行

为什么全是 4?

  1. for 循环执行得非常快,三个 goroutine 被创建出来了,但还没来得及运行
  2. 循环结束时 i 的值已经变成了 4(i++ 之后不满足 i <= 3,退出循环)
  3. 三个 goroutine 开始执行,它们都去读 i 的值——此时 i 已经是 4 了
  4. 三个 goroutine 共享同一个变量 i,这就是闭包的特性

解决办法:把变量作为参数传进去,每个 goroutine 拿到的是一份独立的拷贝。

1
2
3
4
5
6
7
8
9
10
11
12
// 方法一:作为参数传入(推荐)
go func(id int) {
fmt.Printf("goroutine %d 执行\n", id)
}(i)

// 方法二:在循环内创建局部变量
for i := 1; i <= 3; i++ {
id := i // 每次循环创建一个新变量
go func() {
fmt.Printf("goroutine %d 执行\n", id)
}()
}

两种都可以,第一种更明确。


8. goroutine 有多轻量

8.1 创建大量 goroutine

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"fmt"
"sync"
"time"
)

func main() {
count := 100000 // 十万个 goroutine
var wg sync.WaitGroup

start := time.Now()

for i := 0; i < count; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// 每个 goroutine 做一点简单的事
_ = id * id
}(i)
}

wg.Wait()
fmt.Printf("创建并完成 %d 个 goroutine,耗时: %v\n", count, time.Since(start))
}

在普通电脑上,十万个 goroutine 通常在几百毫秒内就能创建并完成。

8.2 goroutine vs 线程

特性 goroutine 操作系统线程
初始栈大小 约 2KB 约 1MB
创建开销 极低(微秒级) 较高(毫秒级)
能创建的数量 轻松十万级 通常几千就很多了
调度方式 Go 运行时调度(用户态) 操作系统调度(内核态)
切换开销 很低 较高

关键区别:goroutine 不是线程。Go 运行时会把成千上万个 goroutine 映射到少量的操作系统线程上(默认等于 CPU 核心数)。goroutine 的调度完全在用户态完成,不需要进入操作系统内核,所以非常快。

8.3 goroutine 的栈是动态增长的

1
2
3
4
5
6
7
8
操作系统线程:
创建时分配 1MB 栈空间(固定大小)
1万个线程 = 10GB 内存

goroutine:
创建时只分配约 2KB 栈空间
需要更多时自动增长(最大 1GB)
1万个 goroutine ≈ 20MB 内存

这就是为什么 Go 能轻松创建大量 goroutine——内存占用非常小。


9. goroutine 的执行顺序

9.1 顺序不确定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println(id)
}(i)
}

wg.Wait()
}

运行多次,你会看到不同的输出顺序:

1
2
3
第一次: 5 3 1 2 4
第二次: 1 5 2 4 3
第三次: 3 1 5 4 2

并发程序的执行顺序是不确定的。如果你的程序依赖 goroutine 的执行顺序,那说明设计有问题。goroutine 之间的协调要通过同步机制(WaitGroup、channel 等),不能靠"碰巧的执行顺序"。

9.2 不能假设先启动的先执行

1
2
3
4
5
6
go funcA()  // 先启动
go funcB() // 后启动

// 不能假设 funcA 一定比 funcB 先执行
// 也不能假设 funcB 一定比 funcA 先执行
// 谁先执行完全由 Go 运行时决定

10. 有返回值的函数怎么办

goroutine 启动的函数不能直接获取返回值

1
2
// 错误:go 语句没法接收返回值
result := go computeSomething() // 编译错误!

这是因为 go 启动的函数是异步执行的,调用方不会等它完成,自然也没法立刻拿到返回值。

那怎么拿到 goroutine 的执行结果? 目前可以用共享变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
results := make([]int, 5) // 预分配好结果切片

for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
results[idx] = idx * idx // 每个 goroutine 写自己的位置
}(i)
}

wg.Wait()
fmt.Println(results) // [0 1 4 9 16]
}

这里能正常工作是因为每个 goroutine 写的是 results 中不同的索引位置,不存在竞争。

但如果多个 goroutine 要写同一个变量,就有问题了——这叫数据竞争(data race)。下一课会讲用 channel 来安全地传递数据,第 28 课会讲用 Mutex 来保护共享数据。


11. 实战示例

11.1 并发下载模拟器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package main

import (
"fmt"
"math/rand"
"sync"
"time"
)

type DownloadResult struct {
URL string
Size int
Duration time.Duration
Err error
}

func download(url string) DownloadResult {
start := time.Now()

// 模拟下载耗时(100ms ~ 500ms 随机)
duration := time.Duration(100+rand.Intn(400)) * time.Millisecond
time.Sleep(duration)

// 模拟下载结果
size := 1024 + rand.Intn(9216) // 1KB ~ 10KB

return DownloadResult{
URL: url,
Size: size,
Duration: time.Since(start),
}
}

func main() {
urls := []string{
"https://example.com/file1.txt",
"https://example.com/file2.txt",
"https://example.com/file3.txt",
"https://example.com/file4.txt",
"https://example.com/file5.txt",
}

// === 顺序下载 ===
fmt.Println("=== 顺序下载 ===")
start := time.Now()

for _, url := range urls {
result := download(url)
fmt.Printf(" %s: %dKB, 耗时 %v\n", result.URL, result.Size/1024, result.Duration)
}

seqDuration := time.Since(start)
fmt.Printf("顺序下载总耗时: %v\n\n", seqDuration)

// === 并发下载 ===
fmt.Println("=== 并发下载 ===")
start = time.Now()

var wg sync.WaitGroup
results := make([]DownloadResult, len(urls))

for i, url := range urls {
wg.Add(1)
go func(idx int, u string) {
defer wg.Done()
results[idx] = download(u)
}(i, url)
}

wg.Wait()

for _, result := range results {
fmt.Printf(" %s: %dKB, 耗时 %v\n", result.URL, result.Size/1024, result.Duration)
}

concDuration := time.Since(start)
fmt.Printf("并发下载总耗时: %v\n", concDuration)
fmt.Printf("加速比: %.1fx\n", float64(seqDuration)/float64(concDuration))
}

可能的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=== 顺序下载 ===
file1.txt: 5KB, 耗时 347ms
file2.txt: 2KB, 耗时 156ms
file3.txt: 8KB, 耗时 423ms
file4.txt: 3KB, 耗时 201ms
file5.txt: 6KB, 耗时 389ms
顺序下载总耗时: 1.516s

=== 并发下载 ===
file1.txt: 4KB, 耗时 289ms
file2.txt: 7KB, 耗时 412ms
file3.txt: 1KB, 耗时 134ms
file4.txt: 5KB, 耗时 367ms
file5.txt: 3KB, 耗时 198ms
并发下载总耗时: 412ms
加速比: 3.7x

顺序下载需要所有时间加起来,并发下载只需要最慢那个的时间。5 个任务并发执行,获得了约 3~4 倍的加速。

11.2 并发计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package main

import (
"fmt"
"sync"
"time"
)

// 计算 start 到 end 之间所有整数的和
func sumRange(start, end int) int {
sum := 0
for i := start; i <= end; i++ {
sum += i
}
return sum
}

func main() {
total := 1_000_000_000 // 10 亿

// === 单 goroutine ===
start := time.Now()
result := sumRange(1, total)
fmt.Printf("单 goroutine: 结果=%d, 耗时=%v\n", result, time.Since(start))

// === 4 个 goroutine 分段计算 ===
start = time.Now()
numWorkers := 4
chunkSize := total / numWorkers
partialResults := make([]int, numWorkers)

var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
s := idx*chunkSize + 1
e := (idx + 1) * chunkSize
if idx == numWorkers-1 {
e = total // 最后一段包含余数
}
partialResults[idx] = sumRange(s, e)
}(i)
}

wg.Wait()

// 汇总结果
finalResult := 0
for _, r := range partialResults {
finalResult += r
}
fmt.Printf("4 goroutine: 结果=%d, 耗时=%v\n", finalResult, time.Since(start))
}

这个例子展示了分治思想:把一个大计算拆成 4 段,每段一个 goroutine 并行计算,最后汇总。在多核机器上,你能看到明显的加速。

11.3 并发执行多个独立任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup

// 模拟多个独立的初始化任务
tasks := map[string]time.Duration{
"加载配置": 200 * time.Millisecond,
"连接数据库": 300 * time.Millisecond,
"初始化缓存": 150 * time.Millisecond,
"加载模板": 100 * time.Millisecond,
}

start := time.Now()

for name, duration := range tasks {
wg.Add(1)
go func(taskName string, taskDuration time.Duration) {
defer wg.Done()
fmt.Printf("[开始] %s\n", taskName)
time.Sleep(taskDuration)
fmt.Printf("[完成] %s (耗时 %v)\n", taskName, taskDuration)
}(name, duration)
}

wg.Wait()
fmt.Printf("\n所有初始化完成,总耗时: %v\n", time.Since(start))
}

可能的输出:

1
2
3
4
5
6
7
8
9
10
[开始] 加载模板
[开始] 加载配置
[开始] 连接数据库
[开始] 初始化缓存
[完成] 加载模板 (耗时 100ms)
[完成] 初始化缓存 (耗时 150ms)
[完成] 加载配置 (耗时 200ms)
[完成] 连接数据库 (耗时 300ms)

所有初始化完成,总耗时: 301ms

四个任务如果顺序执行需要 750ms,并发执行只需要 300ms(取决于最慢的任务)。这在 Web 服务的启动阶段很常见——多个初始化任务互不依赖,可以并行完成。


12. 常见坑总结

12.1 忘了等 goroutine 完成

1
2
3
4
func main() {
go doSomething()
// main 直接返回,goroutine 来不及执行
}

解决:用 sync.WaitGroup 等待。

12.2 WaitGroup 传值而不是传指针

1
2
3
4
5
6
7
8
9
// 错误
func worker(wg sync.WaitGroup) {
defer wg.Done() // 操作的是副本!
}

// 正确
func worker(wg *sync.WaitGroup) {
defer wg.Done()
}

12.3 Add 和 go 的顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 错误:可能在 Add 之前 Wait 就执行了
for i := 0; i < 3; i++ {
go func() {
wg.Add(1) // 在 goroutine 内部 Add
defer wg.Done()
}()
}
wg.Wait() // 可能在任何 goroutine 执行 Add 之前就到这了

// 正确:先 Add,再 go
for i := 0; i < 3; i++ {
wg.Add(1) // 在主 goroutine 中 Add
go func() {
defer wg.Done()
}()
}
wg.Wait()

wg.Add(1) 必须在 go 之前调用。如果在 goroutine 内部调用,可能还没来得及 Add,主 goroutine 就已经 Wait 了(此时计数器为 0,Wait 直接返回)。

12.4 闭包捕获循环变量

1
2
3
4
5
6
7
8
9
10
11
12
13
// 经典 bug
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // 全部输出 3
}()
}

// 正确
for i := 0; i < 3; i++ {
go func(id int) {
fmt.Println(id) // 输出 0、1、2(顺序不定)
}(i)
}

12.5 在 goroutine 里 panic

1
2
3
4
5
6
func main() {
go func() {
panic("goroutine 挂了") // 整个程序崩溃!
}()
time.Sleep(time.Second)
}

goroutine 里的 panic 会导致整个程序崩溃,不仅仅是那一个 goroutine。如果需要防止这种情况,要在 goroutine 内部自己 recover(第 17 课):

1
2
3
4
5
6
7
8
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Printf("goroutine 恢复: %v\n", r)
}
}()
panic("出错了")
}()

12.6 goroutine 泄漏

1
2
3
4
5
6
7
8
9
10
func leak() {
go func() {
for {
// 永远不会结束的循环
time.Sleep(time.Second)
}
}()
// 函数返回了,但 goroutine 还在后台运行
// 没有任何方式让它停下来
}

如果不断调用 leak(),每次都会创建一个永远不会结束的 goroutine,内存会持续增长。这就是 goroutine 泄漏。后面第 29 课会讲用 context 来控制 goroutine 的生命周期。


13. 本课练习

练习 1:并发计时器

要求:

  • 启动 3 个 goroutine
  • 每个 goroutine 每隔 500ms 打印一次自己的名字和当前时间
  • 每个 goroutine 打印 5 次后结束
  • 主 goroutine 等所有 goroutine 完成后打印"全部完成"

练习 2:并发求和

要求:

  • 给定一个有 100 个元素的 int 切片
  • 把它分成 10 段,每段 10 个元素
  • 启动 10 个 goroutine 分别计算每段的和
  • 主 goroutine 汇总结果并输出总和

练习 3:并发文件处理

要求:

  • 创建 5 个文本文件(file1.txt ~ file5.txt),每个文件写入一些文本
  • 启动 5 个 goroutine 同时读取这 5 个文件
  • 每个 goroutine 统计文件中的字符数
  • 主 goroutine 汇总并输出总字符数

提示:用第 19 课学的文件操作配合 goroutine。


练习 4:感受闭包陷阱

要求:

  • 写一段代码,故意触发闭包捕获循环变量的 bug,观察输出
  • 然后用两种方式修复它(传参数、创建局部变量),对比输出

练习 5:goroutine 数量实验

要求:

  • 写一个程序,分别创建 100、1000、10000、100000 个 goroutine
  • 每个 goroutine 做一个简单操作(比如把数字加到结果中)
  • 记录并打印每种数量下的总耗时
  • 观察 goroutine 数量和耗时的关系

14. 自测题

14.1 概念题

  1. go 关键字的作用是什么?
  2. 主 goroutine 退出后,其他 goroutine 会怎样?
  3. sync.WaitGroup 的三个方法分别是什么?各在什么时候调用?
  4. 为什么 WaitGroup 作为函数参数时必须传指针?
  5. 为什么 wg.Add(1) 必须在 go 语句之前而不是在 goroutine 内部?
  6. goroutine 和操作系统线程有什么区别?
  7. 什么是闭包变量捕获问题?怎么解决?
  8. goroutine 中发生 panic 会怎样?

14.2 代码阅读题

预测以下代码的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup

for i := 0; i < 3; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("%d ", n*n)
}(i)
}

wg.Wait()
fmt.Println("done")
}
点击查看答案
1
X X X done

其中 X 是 0、1、4 的某种排列(比如 4 0 1 done0 1 4 done)。

解释:

  1. 循环启动 3 个 goroutine,分别传入 i=0、1、2
  2. 每个 goroutine 计算 n*n 并打印:0*0=0、1*1=1、2*2=4
  3. 三个 goroutine 的执行顺序不确定,所以 0、1、4 可能以任意顺序出现
  4. wg.Wait() 保证三个 goroutine 都完成后才打印 “done”
  5. “done” 一定是最后输出的

15. 本课总结

这一课你学到了 Go 并发编程的基础。

场景 做法
创建 goroutine 函数调用前加 go
等待 goroutine 完成 sync.WaitGroup
goroutine 中用循环变量 作为参数传入,避免闭包陷阱
goroutine 中防 panic 在 goroutine 内部 defer recover
获取 goroutine 的结果 预分配结果切片,按索引写入

最重要的三件事:

  1. go 关键字创建 goroutine,但主 goroutine 退出就全完了——必须用 WaitGroup 等待
  2. goroutine 的执行顺序不确定——不要依赖顺序,要用同步机制协调
  3. 闭包捕获循环变量是经典 bug——永远把循环变量作为参数传给 goroutine

16. 下一课预告

这一课你学会了启动多个 goroutine 并行工作。但你有没有发现一个问题:goroutine 之间怎么通信?

目前我们只能用共享变量传递数据,而且还很容易出问题。Go 有一句著名的格言:

“不要通过共享内存来通信,而要通过通信来共享内存。”

下一课:channel 入门

会重点讲:

  • channel 是什么——goroutine 之间的通信管道
  • 无缓冲 channel 和有缓冲 channel 的区别
  • 怎么用 channel 发送和接收数据
  • channel 的阻塞行为
  • range 遍历 channel
  • 关闭 channel

学完下一课,你就能让 goroutine 之间安全、优雅地互相传递数据了。