
go 的 map 本身不支持并发读写,虽允许多个 goroutine 安全并发读取,但一旦存在写操作(如 delete 或 insert),就必须通过互斥锁或通道机制协调访问,否则将触发 panic。
go 的 map 本身不支持并发读写,虽允许多个 goroutine 安全并发读取,但一旦存在写操作(如 delete 或 insert),就必须通过互斥锁或通道机制协调访问,否则将触发 panic。
在 Go 中,map 不是并发安全的——这是官方明确强调的设计约束(参见 Go FAQ: atomic_maps)。虽然自 Go 1.9 起,多个 goroutine 并发只读访问同一 map 是安全的(因底层哈希表结构在无写入时不会被修改),但只要存在任何写操作(如 delete、赋值新增键值对),就必须确保读写互斥,否则程序可能崩溃(panic: "concurrent map read and map write")。
回到你的 notifyAll 场景:
- 主循环中 range self.connections 是读操作;
- 但子 goroutine 中调用 self.removeConn(k) 会触发 delete(self.connections, k) —— 这是写操作;
- 同时,其他 goroutine 可能正执行 addConn(同样写操作);
✅ 因此,仅加 mu.RLock() 是不够的:它能保护遍历过程不被写操作破坏,但无法防止子 goroutine 在 removeConn 中执行 delete 时与当前 range 发生竞态——因为 RLock 在 for 循环结束后即释放,而子 goroutine 可能在之后任意时刻执行删除。
正确做法一:全程读锁 + 延迟删除(推荐用于简单场景)
func (s *Server) notifyAll(event *Event) {
s.mu.RLock()
// 快照当前 keys(避免遍历时 map 被修改影响迭代)
keys := make([]int, 0, len(s.connections))
for k := range s.connections {
keys = append(keys, k)
}
s.mu.RUnlock()
ch := make(chan struct{}, 64)
for _, k := range keys {
conn, ok := s.connections[k] // 再次读取,需保证线程安全 → 加 RLock
if !ok {
continue
}
ch <- struct{}{}
go func(k int, conn *Conn) {
defer func() { <-ch }()
err := conn.sendMessage(event)
if err != nil {
s.removeConn(k) // 安全:removeConn 内部已加 mu.Lock
}
}(k, conn)
}
}⚠️ 注意:range 本身不保证原子性,且 Go map 迭代顺序随机,故应先获取 key 切片快照,再逐个查值,避免 range 过程中 map 被修改导致 panic 或漏遍历。
正确做法二:Channel 驱动模型(更符合 Go 习惯,彻底解耦并发控制)
正如答案中所示,使用 channel 将所有状态变更(增、删、广播)序列化到一个 agent goroutine 中,完全规避锁竞争:
func (s *Server) agent() {
for {
select {
case c := <-s._addConn:
s.connections[c] = struct{}{}
case c := <-s._removeConn:
delete(s.connections, c)
case e := <-s._notifyAll:
// 此时 connections 状态稳定,可安全遍历
for conn := range s.connections {
closure := conn
go func() {
if err := closure.sendMessage(e); err != nil {
s._removeConn <- closure // 异步通知删除
}
}()
}
}
}
}✅ 优势:无锁、逻辑清晰、天然避免竞态;
✅ 所有 map 操作均发生在单 goroutine 内,100% 并发安全;
✅ 易扩展超时、限流、日志等运维能力。
总结与建议
- ❌ 不要裸奔 range map —— 即使只读,若其他 goroutine 可能写入,就必须同步;
- ✅ 若读多写少、逻辑简单,用 sync.RWMutex + key 快照 + 延迟写是合理选择;
- ✅ 若服务长期运行、连接生命周期复杂、或追求高可维护性,优先采用 channel 驱动的 actor 模式;
- ? 切勿在 range 循环体内直接调用 delete 或 mu.Lock() —— 这会导致死锁或未定义行为;
- ? 使用 go run -race 检测竞态条件,是开发阶段的必备实践。
最终,安全不是“是否加锁”的二选一,而是“如何设计数据访问契约”。Go 鼓励你用 channel 显式传递所有权,而非用锁隐式争夺共享内存——这才是地道的并发之道。

















