应只锁访问共享变量的最小代码段,避免锁住无关逻辑;优先用 sync/atomic 做数值操作;用 sync.Once 保证初始化一次;务必用 go run -race 检测竞态。

用 sync.Mutex 保护共享变量,但别锁整个函数
资源竞争本质是多个 goroutine 同时读写同一块内存。最直接的解法是加锁,但常见错误是把 sync.Mutex 当成“安全开关”——在函数开头 mu.Lock(),结尾 mu.Unlock(),结果整段逻辑被串行化,吞掉并发收益。
真正该锁的,只是那几行真正访问共享变量的代码。比如一个计数器更新:
// ❌ 错误:锁住无关逻辑
func badInc() {
mu.Lock()
time.Sleep(10 * time.Millisecond) // 模拟其他耗时操作
counter++
mu.Unlock()
}
// ✅ 正确:只锁关键赋值
func goodInc() {
mu.Lock()
counter++
mu.Unlock()
time.Sleep(10 * time.Millisecond) // 这部分完全可并发
}
- 锁粒度越小,吞吐越高;但粒度太小可能漏保护,需结合数据流仔细圈定临界区
- 避免在持有锁时调用可能阻塞或调用其他锁的函数(如
http.Get、数据库查询),否则容易死锁 - 用
defer mu.Unlock()是好习惯,但注意它只在函数返回时触发,不能替代及时释放
优先用 sync/atomic 替代锁做简单数值操作
对 int32、int64、uint32、uintptr、指针等类型做增减、交换、比较并交换(CAS)时,sync/atomic 比 Mutex 更轻量、无锁、性能高,且天然避免死锁风险。
例如计数器场景:
立即学习“go语言免费学习笔记(深入)”;
var counter int64// ✅ 推荐:原子操作,无锁,线程安全 func incCounter() { atomic.AddInt64(&counter, 1) }
// ✅ 可用于条件更新(如仅当当前值为 0 才设为 1) func trySetOnce() bool { return atomic.CompareAndSwapInt64(&counter, 0, 1) }
-
atomic不支持浮点数和结构体(除非用unsafe.Pointer包装,但极不推荐) - 所有原子操作都要求变量地址对齐(Go 编译器通常保证,但手动
unsafe操作时需留意) - 别试图用
atomic.LoadInt64+ 普通赋值模拟 CAS —— 中间存在竞态窗口
用 sync.Once 确保初始化只执行一次,而非靠锁+标志位
常见模式是写个全局变量 + sync.Mutex + if !initialized { ...; initialized = true },这不仅啰嗦,还容易因判断和赋值非原子而出问题。Go 提供了更简洁可靠的方案:sync.Once。
var once sync.Once var config *Configfunc GetConfig() *Config { once.Do(func() { config = loadConfigFromDisk() // 这个函数只会被执行一次,即使并发调用 }) return config }
-
once.Do内部已做完整同步,无需额外锁或检查 - 如果
loadConfigFromDisk()panic,once.Do会记录失败,后续调用仍会 panic —— 这是设计行为,不是 bug - 不要把
sync.Once用在需要重试或动态重载的场景(比如配置热更新),它只保证“一生一次”
用 go run -race 主动暴露竞争,而不是等线上报警
Go 的竞态检测器(race detector)是目前最实用的动态分析工具。它不能预防竞争,但能以极低代价(约 2–5 倍运行时开销)在测试阶段揪出绝大多数问题。
- 本地开发时,统一用
go run -race main.go或go test -race ./...跑逻辑 - CI 流水线中加入
-race构建步骤,让竞争问题无法合入主干 - 注意:开启 race 检测后,
sync.Pool、time.Ticker等组件行为会有差异,不要在生产环境启用 - 报错示例:
Read at 0x00c00001a080 by goroutine 7+Previous write at 0x00c00001a080 by goroutine 6—— 直接定位到两处代码行,比日志查半天强得多
真正难调试的从来不是“有没有竞争”,而是“为什么这里没竞争却出错了”。所以别省那几秒 -race 时间,它省下的可能是半夜三点的线上回滚。










