竞态检测器 (-race) 与 sync.Cond 条件变量
竞态检测器 (-race) 与 sync.Cond 条件变量
第一部分:竞态检测器
什么是数据竞争(Data Race)
数据竞争是指两个或多个 goroutine 并发访问同一个内存位置,且至少有一个是写操作,而它们之间没有任何同步机制。
用代码来直观感受:
var counter int
func main() {
go func() { counter++ }()
go func() { counter++ }()
time.Sleep(time.Second)
fmt.Println(counter)
}
这段代码的输出是不确定的——可能是 1,可能是 2。因为 counter++ 不是原子操作:它包含读取、加一、写回三个步骤,两个 goroutine 可能交错执行,导致一个增量被覆盖丢失。
-race 标志
Go 自带一个强大的数据竞争检测工具,用法极其简单:
go build -race # 编译时注入检测代码
go run -race main.go # 直接运行
go test -race ./... # 测试时启用
工作原理:编译器会对程序进行"插桩",记录每个内存访问操作和同步事件(如 Lock/Unlock、channel 操作、go 语句等)。运行时监控这些记录,如果发现两个访问之间存在数据竞争,输出详细报告。
报告解读
假设 go test -race 输出:
WARNING: DATA RACE
Write at 0x00c00011a018 by goroutine 7:
main.increment()
/app/main.go:15 +0x3c
Previous write at 0x00c00011a018 by goroutine 6:
main.increment()
/app/main.go:15 +0x3c
解读要点:
Write at:当前 goroutine 的写操作(第 15 行)Previous write:另一个 goroutine 的写操作(也是第 15 行)- 两者都访问同一个地址
0x00c00011a018 - 结论:
increment函数存在数据竞争
实战:用 -race 发现 bug
看一个有隐患的缓存实现:
type Cache struct {
data map[string]string
}
func (c *Cache) Get(key string) string {
if c.data == nil { // 读
c.data = make(map[string]string) // 写
}
return c.data[key] // 读
}
并发调用 Get 会导致 map 的并发读写。加上 -race 参数运行测试,会立刻报告:
- goroutine A 在读
c.data - goroutine B 在写
c.data - 没有同步保护
修复方式:加 Mutex、或者改用 sync.Map、或者用 sync.Once 惰性初始化。
竞态检测器的局限性
- 只能检测实际发生的竞争:代码执行不到的分支,检查器也看不到。所以测试覆盖率很重要。
- 有性能开销:插桩后的程序大约慢 5-10 倍,内存消耗也更高。不建议在生产环境长期运行(不过短暂运行用于诊断是可以的)。
- 不是静态分析:不会在编译时报告"这里可能有竞争",只在运行时检测。
第二部分:sync.Cond 条件变量
为什么需要 Cond
Mutex 解决了"同一时刻只有一个 goroutine 访问"的问题。但有时候我们还需要"等待某个条件满足"——比如:
- 消费者:等待队列非空才能消费
- 任务调度器:等待有空闲 worker 才能分配任务
- 连接池:等待有空闲连接才能返回
最"朴素"的做法是轮询:
for !condition() {
time.Sleep(10 * time.Millisecond)
}
这要么浪费 CPU(sleep 太短),要么响应延迟太大(sleep 太长)。
Cond 就是用来解决"等待条件"这个问题的:让 goroutine 阻塞在条件上,条件满足时被唤醒。
Cond 的三个方法
c := sync.NewCond(&sync.Mutex{})
c.Wait() // 1. 当前 goroutine 阻塞,等待被唤醒
c.Signal() // 2. 唤醒一个等待的 goroutine
c.Broadcast() // 3. 唤醒所有等待的 goroutine
标准使用模式
Cond 必须和一个 Mutex 配合使用。标准模板:
var mu sync.Mutex
cond := sync.NewCond(&mu)
// 等待方
mu.Lock()
for !condition() { // 注意:用 for 而不是 if!
cond.Wait() // Wait 内部会:解锁 → 阻塞 → 被唤醒后重新加锁
}
// 条件满足,执行操作
mu.Unlock()
// 通知方
mu.Lock()
// 修改条件相关的状态
cond.Signal() // 或 cond.Broadcast()
mu.Unlock()
为什么用 for 而不是 if?
因为 Wait 返回不意味着条件一定满足——可能是"虚假唤醒"(虽然 Go 的实现避免了这个问题,但文档仍然建议用 for),也可能在 Wait 返回和实际执行之间条件又被其他 goroutine 改变了。
Wait 的内部行为:
- 将当前 goroutine 加入等待队列
- 释放锁(
mu.Unlock()) - 阻塞当前 goroutine
- 被 Signal/Broadcast 唤醒后,重新获取锁(
mu.Lock()) - Wait 返回
Cond vs Channel
很多时候,channel 可以替代 Cond:
| 场景 | 用 Channel | 用 Cond |
|---|---|---|
| 一对一通知 | ch <- struct{}{} |
Cond |
| 广播通知 | 关闭 channel(但只能一次) | Broadcast(可多次) |
| 条件反复变化 | 需要重建 channel | 天然支持 |
Cond 的独特优势:
- 可以反复 Broadcast:不像关闭 channel 只能发一次信号
- 不传递数据,只传递信号:轻量、语义清晰
- 适用于多个条件组的情况:不同条件可以有不同的 Cond
实战示例:有界队列
type BoundedQueue struct {
mu sync.Mutex
cond *sync.Cond
items []interface{}
cap int
closed bool
}
func NewBoundedQueue(cap int) *BoundedQueue {
q := &BoundedQueue{cap: cap}
q.cond = sync.NewCond(&q.mu)
return q
}
// Put 在队列满时阻塞
func (q *BoundedQueue) Put(item interface{}) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) >= q.cap && !q.closed {
q.cond.Wait() // 等待空间
}
if q.closed {
return
}
q.items = append(q.items, item)
q.cond.Broadcast() // 通知等待的消费者
}
// Get 在队列空时阻塞
func (q *BoundedQueue) Get() interface{} {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 && !q.closed {
q.cond.Wait() // 等待数据
}
if q.closed && len(q.items) == 0 {
return nil
}
item := q.items[0]
q.items = q.items[1:]
q.cond.Broadcast() // 通知等待的生产者(有空位了)
return item
}
Signal 还是 Broadcast
这是使用 Cond 时最常见的选择问题:
- Signal:只唤醒一个等待者。适用于每个等待者都能独立满足条件的情况(如任务队列中每来一个任务只需一个 worker 处理)。
- Broadcast:唤醒所有等待者。适用于条件变化可能让多个等待者同时满足的情况(如队列容量从满变为有 10 个空位)。
不恰当的选择:
- 该 Broadcast 用了 Signal → 其他本可以工作的 goroutine 永久阻塞
- 该 Signal 用了 Broadcast → 性能损失(惊群效应),但不影响正确性
理解核心:Signal 说的是"有变化了,你们中的一个可以检查一下",Broadcast 说的是"条件大变,所有人都重新检查一下"。
Cond 的实际使用场景
在实际工程中,Cond 用得不多——大多数场景 channel 更简洁。但以下场景 Cond 确实是利器:
- 连接池的等待队列:没有可用连接时阻塞,归还连接时通知
- 多消费者等待同一个事件:比如配置热更新后通知所有模块重新加载
- 限流器的等待机制:超过 QPS 限制时阻塞,令牌桶释放时唤醒
小结
- -race 是 Go 程序员的安全带,养成
go test -race ./...的习惯,能节省大量 debug 时间。 - Cond 解决的是"等待条件"问题,它不传递数据,只协调 goroutine 的等待与唤醒。
- 能用 channel 解决的问题优先用 channel;需要广播或条件反复反转时,Cond 是更好的选择。

浙公网安备 33010602011771号