Go 从 0 到精通 · 第 28 课:同步原语

学习定位:这是整套 Go 教程的第 28 课,也是阶段五(并发与工程阶段)的第四课。
前置要求:已经完成第 27 课,掌握了 select 与并发控制。需要理解 goroutine 和 channel 的基本使用。
本课目标:理解数据竞争问题,掌握 sync.Mutexsync.RWMutexsync.Once 等同步工具的使用,能用 -race 检测数据竞争,理解"什么时候用锁、什么时候用 channel"。


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

前面三课你学了 goroutine、channel、select,已经能写不少并发程序了。但有一类问题还没解决:

1
2
3
4
5
6
7
counter := 0

for i := 0; i < 1000; i++ {
go func() {
counter++ // 1000 个 goroutine 同时修改同一个变量
}()
}

你觉得最终 counter 是 1000?不一定。可能是 987,可能是 952,每次运行结果都不同。这就是数据竞争(data race)

你需要搞明白以下问题:

  • 什么是数据竞争,为什么会发生
  • 怎么用 go run -race 检测数据竞争
  • sync.Mutex 怎么用——互斥锁
  • sync.RWMutex 怎么用——读写锁
  • sync.Once 怎么用——只执行一次
  • sync.WaitGroup 的进阶用法(第 25 课初步介绍过)
  • 什么时候用锁,什么时候用 channel

2. 数据竞争——并发编程的头号敌人

2.1 看一个 bug

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() {
counter := 0
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // 这一行有问题
}()
}

wg.Wait()
fmt.Println("counter =", counter)
}

运行多次,你可能看到不同的结果:

1
2
3
4
counter = 962
counter = 987
counter = 1000
counter = 951

2.2 为什么结果不对

counter++ 看起来是一步操作,实际上是三步:

1
2
3
1. 读取 counter 的值(比如 42
2. 加 1(得到 43
3. 写回 counter

当两个 goroutine 同时执行 counter++ 时:

1
2
3
4
goroutine A: 读取 counter = 42
goroutine B: 读取 counter = 42 ← 读到了同样的值
goroutine A: 写入 counter = 43
goroutine B: 写入 counter = 43 ← 覆盖了 A 的结果

两个 goroutine 各自加了 1,但 counter 只从 42 变到了 43,丢了一次。这就是数据竞争

2.3 用 -race 检测

Go 内置了数据竞争检测器。只需要在运行或编译时加 -race 标志:

1
go run -race main.go

输出会包含类似这样的警告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
==================
WARNING: DATA RACE
Read at 0x00c0000b4010 by goroutine 7:
main.main.func1()
/path/main.go:14 +0x38

Previous write at 0x00c0000b4010 by goroutine 6:
main.main.func1()
/path/main.go:14 +0x4e

Goroutine 7 (running) created at:
main.main()
/path/main.go:12 +0x6c
==================

它会精确告诉你哪一行代码存在数据竞争。养成习惯:开发和测试阶段加 -race

1
2
go test -race ./...    # 测试时检测
go build -race # 编译时开启(性能会降低,不要用于生产)

3. sync.Mutex——互斥锁

3.1 什么是互斥锁

互斥锁保证同一时间只有一个 goroutine 能访问被保护的资源。

就像公共厕所的门锁:进去之前锁门(Lock),出来之后开锁(Unlock)。别人想进去就得等门开了才行。

3.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
package main

import (
"fmt"
"sync"
)

func main() {
counter := 0
var mu sync.Mutex
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()

mu.Lock() // 加锁:其他 goroutine 必须等我解锁
counter++
mu.Unlock() // 解锁:其他 goroutine 可以进来了
}()
}

wg.Wait()
fmt.Println("counter =", counter) // 每次都是 1000
}

现在用 go run -race main.go 运行,不会再有竞争警告,结果每次都是 1000。

3.3 LockUnlock 的规则

1
2
Lock()    → 获取锁。如果锁已被占用,当前 goroutine 阻塞等待。
Unlock() → 释放锁。如果没有持有锁就 Unlock,会 panic。

3.4 用 defer 确保解锁

1
2
3
4
5
6
7
8
mu.Lock()
defer mu.Unlock() // 无论如何都会解锁

// 做一些可能 return 或 panic 的操作
if condition {
return // defer 保证这里也会 Unlock
}
doSomething()

永远用 defer mu.Unlock()。如果忘了解锁,其他 goroutine 会永远等下去(死锁)。

3.5 把锁和它保护的数据放在一起

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
// 好的做法:把 Mutex 和它保护的数据封装在一个结构体里
type SafeCounter struct {
mu sync.Mutex
count int
}

func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}

func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}

func main() {
counter := &SafeCounter{}
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Increment()
}()
}

wg.Wait()
fmt.Println("counter =", counter.Value()) // 1000
}

把锁和数据绑在一起,让使用者不需要关心加锁解锁,只需要调用方法。锁是数据的保镖,要跟着数据走。

3.6 并发安全的 map

Go 的 map 不是并发安全的。多个 goroutine 同时读写 map 会直接 panic:

1
2
3
4
5
m := make(map[string]int)

// 多个 goroutine 同时写 map → panic: concurrent map writes
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()

用 Mutex 保护:

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
type SafeMap struct {
mu sync.Mutex
data map[string]int
}

func NewSafeMap() *SafeMap {
return &SafeMap{data: make(map[string]int)}
}

func (m *SafeMap) Set(key string, value int) {
m.mu.Lock()
defer m.mu.Unlock()
m.data[key] = value
}

func (m *SafeMap) Get(key string) (int, bool) {
m.mu.Lock()
defer m.mu.Unlock()
v, ok := m.data[key]
return v, ok
}

func (m *SafeMap) Delete(key string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.data, key)
}

4. sync.RWMutex——读写锁

4.1 问题:读多写少的场景

普通 Mutex 有个缺点:不管读还是写,都要排队。但很多时候,读操作远多于写操作(比如缓存)。多个读操作之间不会冲突,不需要互斥。

4.2 RWMutex 的规则

sync.RWMutex 区分读锁和写锁:

操作 方法 规则
读锁 RLock() / RUnlock() 多个读可以同时持有
写锁 Lock() / Unlock() 独占,读和写都要等
1
2
3
多个读锁可以共存  → 读不阻塞读
写锁和读锁互斥 → 写阻塞读,读也阻塞写
写锁和写锁互斥 → 写阻塞写

4.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
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
package main

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

type Cache struct {
mu sync.RWMutex
data map[string]string
}

func NewCache() *Cache {
return &Cache{data: make(map[string]string)}
}

func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // 读锁:多个 goroutine 可以同时读
defer c.mu.RUnlock()
v, ok := c.data[key]
return v, ok
}

func (c *Cache) Set(key, value string) {
c.mu.Lock() // 写锁:独占
defer c.mu.Unlock()
c.data[key] = value
}

func main() {
cache := NewCache()
cache.Set("name", "Go")

var wg sync.WaitGroup

// 启动 10 个读 goroutine
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 100; j++ {
if v, ok := cache.Get("name"); ok {
_ = v // 使用数据
}
}
fmt.Printf("读者 %d 完成\n", id)
}(i)
}

// 启动 2 个写 goroutine
for i := 0; i < 2; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 10; j++ {
cache.Set("name", fmt.Sprintf("Go_%d_%d", id, j))
time.Sleep(10 * time.Millisecond)
}
fmt.Printf("写者 %d 完成\n", id)
}(i)
}

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

10 个读 goroutine 可以同时读,互不阻塞。只有写操作会独占锁。

4.4 什么时候用 RWMutex

  • 读操作远多于写操作 → 用 RWMutex(读不互斥,性能更好)
  • 读写频率差不多 → 用 Mutex(RWMutex 本身有额外开销)
  • 只有写操作 → 用 Mutex

简单规则:不确定就用 Mutex,确认读多写少再换 RWMutex


5. sync.Once——只执行一次

5.1 场景:初始化只做一次

有些操作只应该执行一次,比如初始化数据库连接、加载配置文件。即使多个 goroutine 同时调用,也只执行第一次。

5.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
package main

import (
"fmt"
"sync"
)

var once sync.Once

func initialize() {
fmt.Println("初始化...(只会打印一次)")
}

func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d 尝试初始化\n", id)
once.Do(initialize) // 只有第一个到达的 goroutine 会执行 initialize
fmt.Printf("Worker %d 继续工作\n", id)
}

func main() {
var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}

wg.Wait()
}

可能的输出:

1
2
3
4
5
6
7
8
9
10
11
Worker 3 尝试初始化
初始化...(只会打印一次)
Worker 3 继续工作
Worker 1 尝试初始化
Worker 1 继续工作
Worker 5 尝试初始化
Worker 5 继续工作
Worker 2 尝试初始化
Worker 2 继续工作
Worker 4 尝试初始化
Worker 4 继续工作

“初始化…” 只打印了一次。其他 goroutine 调用 once.Do 时会等第一次执行完毕后直接返回。

5.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
type Database struct {
host string
}

var (
dbInstance *Database
dbOnce sync.Once
)

func GetDB() *Database {
dbOnce.Do(func() {
fmt.Println("创建数据库连接")
dbInstance = &Database{host: "localhost:5432"}
})
return dbInstance
}

func main() {
var wg sync.WaitGroup

// 多个 goroutine 同时获取数据库实例
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
db := GetDB()
fmt.Println("使用数据库:", db.host)
}()
}

wg.Wait()
}

不管多少个 goroutine 调用 GetDB(),数据库连接只会创建一次。

5.4 Once 的特性

  • Do 方法接收一个无参数无返回值的函数
  • 只执行第一次调用传入的函数,后续调用直接返回
  • 即使第一次执行的函数 panic 了,后续调用也不会再执行(认为"已经执行过了")
  • 一个 Once 只能配一个操作,不能重置

6. sync.WaitGroup 进阶

第 25 课初步介绍了 WaitGroup。这里补充几个进阶用法。

6.1 一次性 Add

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var wg sync.WaitGroup
n := 10

// 方式一:循环中每次 Add(1)
for i := 0; i < n; i++ {
wg.Add(1)
go worker(i, &wg)
}

// 方式二:一次性 Add(n)(更清晰)
wg.Add(n)
for i := 0; i < n; i++ {
go worker(i, &wg)
}

两种都可以。方式二更直观——一眼就能看出总共有 n 个任务。但要确保 Add 的数量和实际启动的 goroutine 数量一致。

6.2 嵌套 WaitGroup

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
package main

import (
"fmt"
"sync"
)

func main() {
var outerWg sync.WaitGroup

for stage := 1; stage <= 3; stage++ {
outerWg.Add(1)
go func(s int) {
defer outerWg.Done()

var innerWg sync.WaitGroup
for task := 1; task <= 3; task++ {
innerWg.Add(1)
go func(t int) {
defer innerWg.Done()
fmt.Printf("阶段 %d - 任务 %d 完成\n", s, t)
}(task)
}
innerWg.Wait()
fmt.Printf("=== 阶段 %d 全部完成 ===\n", s)
}(stage)
}

outerWg.Wait()
fmt.Println("所有阶段完成")
}

外层 WaitGroup 等所有阶段完成,每个阶段内部有自己的 WaitGroup 等所有任务完成。


7. sync.Map——并发安全的 map

7.1 标准库提供的并发 map

Go 标准库提供了 sync.Map,不需要自己加锁:

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
package main

import (
"fmt"
"sync"
)

func main() {
var m sync.Map

var wg sync.WaitGroup

// 并发写入
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
m.Store(fmt.Sprintf("key_%d", n), n*n)
}(i)
}

wg.Wait()

// 读取
m.Range(func(key, value any) bool {
fmt.Printf("%s = %v\n", key, value)
return true // 返回 true 继续遍历,false 停止
})

// 单个读取
if v, ok := m.Load("key_5"); ok {
fmt.Println("key_5 =", v)
}

// 删除
m.Delete("key_3")

// LoadOrStore:存在就返回旧值,不存在就存入新值
actual, loaded := m.LoadOrStore("key_5", 999)
fmt.Println("key_5:", actual, "已存在:", loaded) // key_5: 25 已存在: true
}

7.2 sync.Map 的 API

方法 作用
Store(key, value) 存入键值对
Load(key) 读取,返回 (value, ok)
Delete(key) 删除
LoadOrStore(key, value) 存在返回旧值,不存在存入新值
LoadAndDelete(key) 读取并删除
Range(func) 遍历所有键值对

7.3 sync.Map vs 自己加锁的 map

特性 sync.Map Mutex + map
使用难度 简单,不用管锁 需要自己加锁解锁
类型安全 不安全(key 和 value 都是 any 安全(有具体类型)
适用场景 key 相对固定、读多写少 通用场景
性能 特定场景更好 一般场景更稳定

选择建议

  • 大多数情况用 Mutex + 普通 map(类型安全、更灵活)
  • 两种特殊场景用 sync.Map:key 只增不删、各 goroutine 读写不同的 key

8. 锁 vs channel——怎么选

这是 Go 并发编程中最常见的疑问。

8.1 用 channel 的场景

  • goroutine 之间传递数据
  • 协调多个 goroutine 的执行顺序
  • 流水线、生产者-消费者模式
  • 通知和信号
1
2
3
4
// 适合 channel:数据从一个 goroutine 流向另一个
results := make(chan Result)
go func() { results <- compute() }()
r := <-results

8.2 用锁的场景

  • 保护共享状态(计数器、缓存、连接池)
  • 多个 goroutine 读写同一个数据结构
  • 简单的互斥访问
1
2
3
4
// 适合锁:多个 goroutine 操作同一个计数器
mu.Lock()
counter++
mu.Unlock()

8.3 一句话总结

传递数据用 channel,保护数据用锁。

场景 选择 原因
goroutine 间传递数据 channel 数据在"流动"
保护共享的计数器 Mutex 数据在"原地修改"
保护共享的 map Mutex / sync.Map 数据在"原地修改"
通知某件事完成 channel 这是一种通信
只执行一次初始化 sync.Once 专用工具
等待一组 goroutine WaitGroup 专用工具

9. 实战示例

9.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
package main

import (
"fmt"
"sync"
)

type Account struct {
mu sync.Mutex
balance int
}

func NewAccount(initial int) *Account {
return &Account{balance: initial}
}

func (a *Account) Deposit(amount int) {
a.mu.Lock()
defer a.mu.Unlock()
a.balance += amount
fmt.Printf(" 存入 %d, 余额: %d\n", amount, a.balance)
}

func (a *Account) Withdraw(amount int) bool {
a.mu.Lock()
defer a.mu.Unlock()
if a.balance < amount {
fmt.Printf(" 取款 %d 失败,余额不足: %d\n", amount, a.balance)
return false
}
a.balance -= amount
fmt.Printf(" 取出 %d, 余额: %d\n", amount, a.balance)
return true
}

func (a *Account) Balance() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.balance
}

func main() {
account := NewAccount(1000)

var wg sync.WaitGroup

// 模拟多人同时操作同一个账户
operations := []struct {
op string
amount int
}{
{"deposit", 200},
{"withdraw", 500},
{"deposit", 300},
{"withdraw", 800},
{"withdraw", 100},
{"deposit", 150},
}

for _, op := range operations {
wg.Add(1)
go func(action string, amount int) {
defer wg.Done()
switch action {
case "deposit":
account.Deposit(amount)
case "withdraw":
account.Withdraw(amount)
}
}(op.op, op.amount)
}

wg.Wait()
fmt.Printf("\n最终余额: %d\n", account.Balance())
}

每个操作都加了锁,保证余额计算不会被打乱。

9.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
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
80
81
82
83
84
package main

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

type PageCounter struct {
mu sync.RWMutex
counts map[string]int
}

func NewPageCounter() *PageCounter {
return &PageCounter{counts: make(map[string]int)}
}

func (pc *PageCounter) Visit(page string) {
pc.mu.Lock()
defer pc.mu.Unlock()
pc.counts[page]++
}

func (pc *PageCounter) GetCount(page string) int {
pc.mu.RLock()
defer pc.mu.RUnlock()
return pc.counts[page]
}

func (pc *PageCounter) TopPages(n int) []string {
pc.mu.RLock()
defer pc.mu.RUnlock()

// 收集所有页面
type kv struct {
page string
count int
}
var pages []kv
for p, c := range pc.counts {
pages = append(pages, kv{p, c})
}

// 按访问量排序(第 23 课)
for i := 0; i < len(pages)-1; i++ {
for j := i + 1; j < len(pages); j++ {
if pages[j].count > pages[i].count {
pages[i], pages[j] = pages[j], pages[i]
}
}
}

result := []string{}
for i := 0; i < n && i < len(pages); i++ {
result = append(result, fmt.Sprintf("%s: %d次", pages[i].page, pages[i].count))
}
return result
}

func main() {
counter := NewPageCounter()
pages := []string{"/home", "/about", "/api/users", "/home", "/products", "/home", "/about"}

var wg sync.WaitGroup

// 模拟并发访问
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
page := pages[i%len(pages)]
counter.Visit(page)
}(// 注意这里没有传 i,闭包直接捕获了 i
)
}

// 上面的代码有闭包 bug!修正版本:
wg.Wait()

// 打印结果
for _, line := range counter.TopPages(5) {
fmt.Println(line)
}
}

等一下——上面的代码有闭包陷阱(第 25 课)。让我用正确的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
counter := NewPageCounter()
pages := []string{"/home", "/about", "/api/users", "/home", "/products", "/home", "/about"}

var wg sync.WaitGroup

for i := 0; i < 100; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
page := pages[idx%len(pages)]
counter.Visit(page)
time.Sleep(time.Millisecond) // 模拟请求耗时
}(i)
}

wg.Wait()

fmt.Println("=== 页面访问排行 ===")
for _, line := range counter.TopPages(5) {
fmt.Println(line)
}
}

用了 RWMutexVisit 用写锁(修改数据),GetCountTopPages 用读锁(只读数据)。多个读请求可以同时执行。

9.3 用 Once 实现懒加载配置

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
package main

import (
"encoding/json"
"fmt"
"os"
"sync"
)

type Config struct {
AppName string `json:"app_name"`
Port int `json:"port"`
Debug bool `json:"debug"`
}

var (
config *Config
configOnce sync.Once
configErr error
)

func GetConfig() (*Config, error) {
configOnce.Do(func() {
fmt.Println("加载配置文件...(只执行一次)")

data, err := os.ReadFile("config.json")
if err != nil {
configErr = fmt.Errorf("读取配置失败: %w", err)
return
}

config = &Config{}
if err := json.Unmarshal(data, config); err != nil {
configErr = fmt.Errorf("解析配置失败: %w", err)
config = nil
}
})

return config, configErr
}

func main() {
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
cfg, err := GetConfig()
if err != nil {
fmt.Printf("Worker %d: 配置加载失败: %v\n", id, err)
return
}
fmt.Printf("Worker %d: 使用配置 %s:%d\n", id, cfg.AppName, cfg.Port)
}(i)
}

wg.Wait()
}

配置只加载一次,后续所有调用直接返回缓存的结果。


10. 常见坑总结

10.1 忘记解锁——死锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func bad() {
mu.Lock()
if someCondition {
return // 忘了 Unlock!其他 goroutine 永远进不来
}
mu.Unlock()
}

// 解决:用 defer
func good() {
mu.Lock()
defer mu.Unlock()
if someCondition {
return // defer 会自动 Unlock
}
}

10.2 重复加锁——死锁

1
2
3
4
func doublelock() {
mu.Lock()
mu.Lock() // 同一个 goroutine 再次 Lock → 死锁!
}

Go 的 sync.Mutex 不是可重入锁。同一个 goroutine 对同一个 Mutex 加锁两次会死锁。

1
2
3
4
5
6
7
8
9
10
11
12
// 常见的隐蔽场景
func (s *SafeData) A() {
s.mu.Lock()
defer s.mu.Unlock()
s.B() // B 里面也加锁了!
}

func (s *SafeData) B() {
s.mu.Lock() // 死锁!A 已经持有锁了
defer s.mu.Unlock()
// ...
}

解决:提取一个不加锁的内部方法,让公开方法加锁后调用内部方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (s *SafeData) A() {
s.mu.Lock()
defer s.mu.Unlock()
s.b() // 调用不加锁的版本
}

func (s *SafeData) B() {
s.mu.Lock()
defer s.mu.Unlock()
s.b()
}

func (s *SafeData) b() {
// 实际逻辑,不加锁(调用方负责加锁)
}

10.3 锁的粒度太大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 坏:整个操作都在锁内,包括耗时的 IO 操作
func bad(mu *sync.Mutex) {
mu.Lock()
defer mu.Unlock()

data := fetchFromNetwork() // 网络请求,可能要几秒
result := processData(data) // 数据处理
saveResult(result) // 保存结果
}

// 好:只锁需要保护的部分
func good(mu *sync.Mutex) {
data := fetchFromNetwork() // 不需要锁
result := processData(data) // 不需要锁

mu.Lock()
saveResult(result) // 只有这步需要锁
mu.Unlock()
}

锁的持有时间越短越好。 持有锁的时候做耗时操作,会让其他 goroutine 白白等待。

10.4 复制 Mutex

1
2
3
4
5
6
7
8
9
10
type Data struct {
mu sync.Mutex
v int
}

func modify(d Data) { // 值传递!mu 被复制了
d.mu.Lock() // 锁的是副本,原始的 mu 没被锁
defer d.mu.Unlock()
d.v++
}

sync.Mutexsync.WaitGroup 等同步原语不能被复制。如果包含在结构体中,结构体也不能被值传递,必须用指针。

1
2
3
4
5
func modify(d *Data) {  // 指针传递
d.mu.Lock()
defer d.mu.Unlock()
d.v++
}

10.5 读写锁中用错了锁类型

1
2
3
4
5
6
// 写操作却用了读锁——没有保护效果!
func (c *Cache) Set(key, value string) {
c.mu.RLock() // 应该用 Lock()
defer c.mu.RUnlock()
c.data[key] = value // 写操作没有被保护
}

读操作用 RLock/RUnlock,写操作用 Lock/Unlock。搞反了就失去了保护。


11. 本课练习

练习 1:并发安全的计数器

要求:

  • 实现一个 Counter 结构体,支持 Add(n)Sub(n)Value() 方法
  • 所有方法并发安全
  • 启动 100 个 goroutine 各 Add(1),再启动 50 个各 Sub(1)
  • 最终值应该是 50

练习 2:并发安全的排行榜

要求:

  • 实现一个 Leaderboard 结构体,支持 AddScore(name, score)Top(n) 方法
  • AddScore 累加分数
  • Top(n) 返回前 n 名(按分数降序)
  • RWMutex 保护:AddScore 用写锁,Top 用读锁
  • 启动多个 goroutine 并发添加分数和查询排名

练习 3:限流器

要求:

  • 实现一个 RateLimiter 结构体,限制每秒最多处理 N 个请求
  • Mutex + 时间戳数组实现
  • Allow() 方法返回 bool,表示当前请求是否被允许

提示:记录最近 N 个请求的时间戳,新请求来时检查最早那个是否在 1 秒内。


练习 4:数据竞争检测练习

要求:

  • 写一段故意有数据竞争的代码
  • go run -race 运行,观察输出
  • 用 Mutex 修复,再用 -race 确认修复成功

练习 5:读写锁性能对比

要求:

  • 分别用 MutexRWMutex 保护一个 map
  • 模拟 90% 读 + 10% 写的场景
  • time.Since 测量两种方式的耗时
  • 对比结果

12. 自测题

12.1 概念题

  1. 什么是数据竞争?counter++ 为什么不是并发安全的?
  2. go run -race 做了什么?应该在什么阶段使用?
  3. sync.MutexLockUnlock 分别做什么?
  4. 为什么总应该用 defer mu.Unlock() 而不是手动调用 Unlock()
  5. sync.RWMutexsync.Mutex 有什么区别?什么场景用 RWMutex
  6. sync.OnceDo 方法被调用多次会怎样?
  7. Go 的 sync.Mutex 是可重入的吗?同一个 goroutine 连续 Lock 两次会怎样?
  8. "传递数据用 channel,保护数据用锁"这句话怎么理解?

12.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
package main

import (
"fmt"
"sync"
)

func main() {
data := make(map[string]int)
var mu sync.Mutex
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
key := fmt.Sprintf("key_%d", n)

mu.Lock()
data[key] = n * n
mu.Unlock()
}(i)
}

wg.Wait()

for k, v := range data {
fmt.Println(k, v)
}
}
点击查看答案

没有数据竞争。

解释:

  1. 所有对 data 的写操作都在 mu.Lock()mu.Unlock() 之间——受锁保护
  2. wg.Wait() 确保所有写操作完成后才开始读(for range
  3. 主 goroutine 中的 for range 是在所有 goroutine 结束后执行的,此时没有并发写入
  4. 闭包变量 i 通过参数 n 传入,每个 goroutine 有自己的副本

这段代码是安全的。用 go run -race 运行不会报任何警告。


13. 本课总结

这一课你学到了如何在并发环境中保护共享数据。

工具 用途 关键点
sync.Mutex 互斥锁 同一时间只有一个 goroutine 能访问
sync.RWMutex 读写锁 多读不互斥,写独占
sync.Once 只执行一次 初始化、单例
sync.WaitGroup 等待一组 goroutine Add → Done → Wait
sync.Map 并发安全 map API 是 any 类型,少数场景适用
-race 数据竞争检测 开发测试阶段必用

最重要的三件事:

  1. 多个 goroutine 读写同一个变量就有数据竞争——用 Mutex 保护,用 -race 检测
  2. defer mu.Unlock() 是铁律——防止忘记解锁导致死锁
  3. 传递数据用 channel,保护数据用锁——两者互补,不要只用一种

14. 下一课预告

你现在能创建 goroutine、用 channel 通信、用 select 多路复用、用锁保护共享数据。但还有一个重要的问题:怎么取消一个正在运行的 goroutine?

想象一个场景:你发起了一个 HTTP 请求,但用户取消了操作。请求的 goroutine 还在傻傻等待响应。你需要一种机制告诉它"不用干了,回来吧"。

下一课:context 与取消机制

会重点讲:

  • context 是什么——goroutine 的生命周期管理器
  • context.Background()context.TODO()
  • context.WithCancel——手动取消
  • context.WithTimeoutcontext.WithDeadline——自动超时取消
  • 怎么在 goroutine 中监听取消信号
  • context 的传递约定

学完下一课,你就能优雅地管理 goroutine 的生命周期了。