做即时通讯系统,绕不开一个核心问题:成千上万个 WebSocket 连接同时收发消息,服务端要维护一张「在线用户表」才能知道目前有谁在线,消息应该发给谁。
如果我们选择使用大量 goroutine 并发读写这张表,为了保证并发安全只能给他加一把锁。但我在自己的 IM 项目里没有用锁------整个 Hub 没有一个 sync.Mutex,却是并发安全的。这篇文章讲清楚为什么,以及这个设计背后的 Go 并发哲学。
问题:map并发不安全
先看 Hub 持有的核心状态:
go
type Hub struct {
clients map[int64]*Client // 在线用户表:userID -> 连接
groups map[int64]map[int64]bool // 群成员表:群ID -> 用户ID -> 在否在线
// ...
}
每个用户连上来,要往 clients 写一条;断开,要删一条;每来一条消息,要读 clients 找接收方。在线用户成百上千时,这些操作来自不同的 goroutine,同时发生。
Go 的 map 不是并发安全的。两个 goroutine 同时写同一个 map,运行时会直接 panic:fatal error: concurrent map writes。所以这张表必须做并发保护。
方案一:加锁(我没有选择)
最容易想到的是 sync.RWMutex:
go
type Hub struct {
mu sync.RWMutex
clients map[int64]*Client
}
func (h *Hub) addClient(c *Client) {
h.mu.Lock()
h.clients[c.userID] = c
h.mu.Unlock()
}
func (h *Hub) getClient(id int64) (*Client, bool) {
h.mu.RLock()
c, ok := h.clients[id]
h.mu.RUnlock()
return c, ok
}
能用,但有几个让我不舒服的地方:
- 锁要散落在每一处访问点 。
clients和groups两张表,注册、注销、加群、退群、单聊路由、群聊路由,每个地方都要记得正确地加锁解锁,漏一个就是 data race,多一个就是死锁。 - 锁的粒度很难拿捏。群聊广播时要遍历群成员、逐个查在线表,这段持锁时间一长,整个 Hub 的吞吐就被拖住。
- 心智负担重。每写一个新方法都要先想"这里要不要加锁、加读锁还是写锁"。
锁本身没错,但它把并发安全这件事「摊」到了代码的每个角落。我想要的是把它收拢到一个地方。
方案二:用 channel 把并发收敛到单 goroutine
我最终的做法是:所有对 clients、groups 的读写,只在一个 goroutine 里进行。其它 goroutine 想改这两张表,不直接动手,而是把"请求"通过 channel 发进来。
go
type Hub struct {
clients map[int64]*Client
groups map[int64]map[int64]bool
register chan *Client // 注册请求
unregister chan *Client // 注销请求
joinGroup chan *GroupAction // 加群请求
leaveGroup chan *GroupAction // 退群请求
broadcast chan *domain.WSMessage // 待广播的消息
localRoute chan *domain.Message // 来自 Redis、只做本地投递的消息
}
核心是这个 Run 方法,它在一个独立 goroutine 里跑一个 for + select 循环:
go
// Run 是 Hub 的核心调度循环,所有对 clients/groups 的读写都在这一个 goroutine 里完成
// 用 channel 传递操作请求,避免并发读写 map 导致的 data race
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client.userID] = client
case client := <-h.unregister:
delete(h.clients, client.userID)
case action := <-h.joinGroup:
if h.groups[action.groupID] == nil {
h.groups[action.groupID] = make(map[int64]bool)
}
h.groups[action.groupID][action.userID] = true
case action := <-h.leaveGroup:
delete(h.groups[action.groupID], action.userID)
case msg := <-h.broadcast:
// 存库 + 发 Redis(见后文)
case msg := <-h.localRoute:
// 本地投递(见后文)
}
}
}
select 的语义是:同一时刻只处理一个 case。哪个 channel 来数据就处理哪个,处理完才回到循环顶部等下一个。这意味着------对 map 的读写在物理上被串行化了,永远只有一个 goroutine 在碰它。既然只有一个 goroutine 访问,自然就没有并发问题,锁也就不需要了。
外部 goroutine 想注册自己,不直接写 map,而是:
go
func (c *Client) Register() {
c.hub.register <- c // 把自己塞进 channel,剩下的交给 Run
}
channel 的发送是线程安全的,多个 goroutine 同时往 register 里塞 Client 不会出问题,它们会排队,由 Run 逐个取出处理。
这背后正是 Go 的并发哲学
这个设计不是我自己突然想到的,它对应 Go 官方反复强调的一句话:
Don't communicate by sharing memory; share memory by communicating. 不要通过共享内存来通信,而要通过通信来共享内存。
两种思路的区别:
- 共享内存 + 锁:大家都能碰这块内存,用锁来排队,保证不打架。安全是"约束所有人的行为"。
- 通信(channel):这块内存只属于一个 goroutine,别人想动它,就发消息请它代劳。安全是"不让别人碰"。
后者在学术上叫 CSP 模型(Communicating Sequential Processes) ,Go 的 goroutine + channel 就是它的实现。我的 Hub 是这个模型很典型的落地:clients、groups 这两块状态只有 Run 这个 goroutine 能碰,所有变更都通过 channel 发请求,被天然串行化。
一个延伸:跨实例消息怎么办
单机版到这就闭环了。但部署多个实例时会出现新问题:用户 A 连在实例 1,用户 B 连在实例 2,A 发给 B 的消息,实例 1 的 clients 表里根本找不到 B。
我的解法是 Redis Pub/Sub。消息进来后,broadcast 这个 case 先存库、再发布到 Redis 频道,而不是自己直接投递:
go
case msg := <-h.broadcast:
record := &domain.Message{ /* ... */ Status: domain.MsgStatusUnread }
if err := h.msgRepo.Save(context.Background(), record); err != nil {
zap.L().Error("消息存库失败", zap.Error(err))
}
data, _ := json.Marshal(record)
h.rdb.Publish(context.Background(), msgChannel, data)
每个实例都订阅这个频道,收到后扔进 localRoute channel:
go
func (h *Hub) SubscribeRedis(ctx context.Context) {
sub := h.rdb.Subscribe(ctx, msgChannel)
ch := sub.Channel()
for {
select {
case redisMsg := <-ch:
var msg domain.Message
json.Unmarshal([]byte(redisMsg.Payload), &msg)
h.localRoute <- &msg // 不直接路由,交给 Run
case <-ctx.Done():
return
}
}
}
注意这里有个容易踩的坑:SubscribeRedis 是一个独立 goroutine,它绝不能 直接去读 h.clients 投递消息------那又变成两个 goroutine 同时碰 map 了,data race 立刻回来。所以它只是把消息放进 localRoute,真正的投递仍然回到 Run 那一个 goroutine 里完成:
go
case msg := <-h.localRoute:
if msg.TargetType == domain.TargetTypeUser {
if target, ok := h.clients[msg.TargetID]; ok {
data, _ := json.Marshal(msg)
target.send <- data
}
} else if msg.TargetType == domain.TargetTypeGroup {
data, _ := json.Marshal(msg)
for uid := range h.groups[msg.TargetID] {
if target, ok := h.clients[uid]; ok {
target.send <- data
}
}
}
这就是用 channel 管理状态的好处:哪怕新增了 Redis 订阅这条来源,只要坚持"所有 map 访问都收敛到 Run"这条铁律,并发安全就一直成立,我不需要为新增的 goroutine 重新设计锁。
小结
回到标题的问题------为什么用 channel 而不是 mutex:
1)锁把并发安全摊到每个访问点,channel 把它收敛到一个 goroutine,心智负担天差地别。
2)map 的访问被 select 串行化,从根上消除了 data race,而不是靠纪律去回避它。
3)新增消息来源(Redis 订阅)时,依旧守住"不直接碰 map",安全性自动延续。
当然 channel 不是银弹。它适合:"状态由单一 owner 管理、通过消息驱动变更"的场景,Hub 正好是。如果是读多写极少的纯缓存,sync.RWMutex 反而更直接。选哪个,取决于状态是被"频繁并发变更"还是"频繁并发读取"。