counter++ 一定会出错,因其本质是“读-改-写”三步非原子操作,多 goroutine 并发时导致丢失更新;int64 在32位系统上普通读写也非原子;竞态具概率性,需用 -race 检测。

为什么 counter++ 一定会出错?
因为这不是一条机器指令,而是“读-改-写”三步操作。两个 goroutine 同时执行时,可能都读到旧值、各自加 1、再写回,结果只 +1 而不是 +2。Go 编译器和 CPU 都不保证这种操作的原子性,哪怕变量是 int64 —— 在 32 位系统上,int64 的普通读写本身就是非原子的。
常见错误现象包括:计数器卡在某个小数值、map panic 报 fatal error: concurrent map writes、结构体字段部分更新后被读取(比如状态字段已变但数据字段还是旧的)。
- 别依赖“本地测试没出问题”——竞态是概率性触发,上线高并发时才暴露
- 用
go run -race main.go或go build -race必须加,它能捕获 95% 以上的共享变量读写冲突 - 循环中启动 goroutine 时直接引用
i,最后全打印3(而不是 0/1/2),这也是资源竞争的一种表现
sync.Mutex 锁什么、怎么锁才不拖慢服务?
锁太粗会把并发变成串行;锁太细又漏掉逻辑依赖。关键不是“要不要锁”,而是“锁哪一段、锁多久”。
- 只锁真正需要互斥的临界区,比如
if _, ok := m[k]; !ok { m[k] = v }这种“检查+赋值”必须包在同一个mu.Lock()/mu.Unlock()里,拆开就失效 - 优先锁具体字段或局部结构体,避免锁整个大 struct 或全局变量(比如为每个 map key 单独配一个
sync.Mutex,比锁整个 map 更高效) - 绝不在持有锁时调用
http.Get、time.Sleep或向无缓冲 channel 发送数据——这会让其他 goroutine 等待几十毫秒甚至更久 - 用
defer mu.Unlock(),防止因 return 或 panic 忘记释放
sync/atomic 适合哪些场景?
它只对 int32、int64、uint32、uintptr、unsafe.Pointer 有效,底层靠 CPU 指令实现,零锁开销。但它不是万能的。
- 只用于简单数值更新:连接数统计、开关标记、请求计数器等
- 所有访问必须统一走 atomic 函数:
atomic.LoadInt64(&x)读,atomic.StoreInt64(&x, v)写,混用普通赋值会破坏原子性 - 不能用于浮点数、字符串、结构体——哪怕只是想原子地更新结构体里一个字段,也得自己用
unsafe.Pointer+atomic.CompareAndSwapUintptr手动实现,极易出错 - 注意内存对齐:如果
int64字段前面有未对齐的字段,某些架构下 atomic 操作可能失败
什么时候该放弃共享变量,改用 channel?
当多个 goroutine 的协作本质是“传递数据所有权”而非“反复修改同一块内存”时,channel 是更自然的选择。它本身并发安全,且强制通信模型。
- 典型适用:生产者-消费者、任务分发、状态变更广播(比如配置热更新通知)
- 别为了用 channel 而用:如果只是给一个计数器 +1,用
chan int反而引入 goroutine 调度和内存分配开销,比atomic.AddInt64慢一个数量级 - 注意缓冲区大小:无缓冲 channel 容易阻塞,
make(chan int, 1)和make(chan int, 1000)行为完全不同,别凭感觉设 - 关闭 channel 前确认所有发送方已退出,否则会 panic;接收方要用
v, ok := 判断是否已关闭
atomic 更新一个计数器,却在另一处用普通赋值去读——这个读操作看到的可能是过期值,因为缺乏 happens-before 关系。这类问题不会报错,但会让监控指标失真,排查起来比 panic 更难。











