G11第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

数据竞争与同步

数据竞争与同步 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

这课先制造一次真正的数据竞争,再用竞争检测器抓住它,最后用互斥锁和原子操作修好。读完你能把「跑得慢」和「跑得错」区分开,并把 -race 变成日常命令。

问题场景

给书签服务加个访问统计:每个接口被访问就计数。测试全绿、平时跑得好好的,压测一开数字开始飘,偶尔还崩。这就是数据竞争的典型画像:两个 goroutine 同时读写同一个变量,结果取决于调度时序——它不报错、不总是复现,是最阴的一类 bug。

核心解释

先造一个坏的:

var visits int64 // 裸全局可变状态

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				visits++ // 读-改-写三步,交错执行就丢更新
			}
		}()
	}
	wg.Wait()
	fmt.Println("final:", visits) // 期望 100000,常常更少
}

visits++ 不是一步:读出值、加一、写回。100 个 goroutine 交错执行,两次「读到同一个旧值」就丢一次更新。用竞争检测器跑:

go run -race .
# ==================
# WARNING: DATA RACE
# Read at 0x00c000014098 by goroutine 8:
#   main.main.func1()
#       main.go:22 +0x3a
# ...

-race 让运行时记录每次内存访问的读写集合,检测到「并发访问且至少一个是写」就报警,连同两个访问点的堆栈一起给出来。它有运行时开销(内存 5-10 倍、速度慢 2-20 倍),适合测试和排查,不适合生产常开——但 CI 里的 go test -race 应该是标配。

修复第一式:互斥锁保护临界区,而且锁跟着数据走:

type counter struct {
	mu sync.Mutex
	n  int64
}

func (c *counter) add() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *counter) value() int64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

纪律是「谁的数据谁配锁」:mu 是 counter 的私有字段,调用方根本看不见它——比一把全局锁包住大段代码安全得多。

atomic 只适合无复合操作的单变量(计数、标志位):声明 var visits atomic.Int64,用 visits.Add(1) 自增、visits.Load() 读取;map 或多字段结构体这类「两步更新要原子」的复合状态必须 mutex,atomic 救不了。比选锁更重要的是消灭裸全局变量:把 counter 做成 store 的字段或显式注入,测试并行跑也不打架。

示例拆解

go run -race .          # 修复前:WARNING: DATA RACE,final 常小于 100000
go vet ./...            # 部分问题 vet 也能发现(如带锁结构体被拷贝)
go run -race .          # 修复后:安静,final 恒等于 100000
go test -race ./...     # 这条命令进 CI

验证修复要跑够次数:竞争是概率性的,改完只跑一次就宣布修好不可信。把循环量加大、用 go test -count=10 重复跑,持续安静才算证据。第二个观察点:竞争修复后数字不止不崩,还精确等于 100000——丢更新是静默的,普通测试根本测不出来,这正是必须用检测器的原因。

动手任务

  1. 用 map 写个带竞争的路径计数(两个 goroutine 同时写同一 key),-race 抓出来,再用 sync.Mutex 修复。
  2. 把 visits 换成 atomic.Int64,确认 -race 安静,并解释为什么 map 计数不能照搬 atomic。
  3. 把 counter 改成 NewCounter() 构造注入,写一个 10 goroutine 并发 add 的测试,用 go test -race 验证。

常见错误

  • 「测试过了就是没竞争」:不加 -race 的测试对竞争几乎零检出,必须显式跑 race 版本。
  • 锁粒度过大:一把全局锁包住整个 handler,并发优势归零,锁应贴着被保护数据。
  • 值拷贝带锁的结构体:sync.Mutex 不可复制(vet 会报 CopyLock),传指针、方法用指针接收者。
  • 修了计数忘了 map:同一段代码里每个共享变量都要过一遍「谁保护它」的审问。

检查与复盘

完成下方检查题。自查清单:

  • go test -race 在本地和 CI 都是标配,不是出事才跑
  • 共享可变状态有唯一属主(类型内建锁或显式注入),没有裸全局变量
  • atomic 与 mutex 的选择有依据:单变量无复合操作用 atomic,复合状态用 mutex

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1故障定位较难

bug 现场:并发计数服务结果偶发偏小,go test -race 报 WARNING: DATA RACE(完整文件为 package main,import fmt/sync)。根因与修法是?

var counter int

func worker(wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 1000; i++ {
		counter++
	}
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go worker(&wg)
	}
	wg.Wait()
	fmt.Println(counter) // 期望 10000,实际经常小于 10000
}
Q2多选中等(多选,全部选对才算正确)

关于 Go 的数据竞争与同步(Go 1.22+),下列说法正确的有哪些?(多选)

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...