无锁 Hub:我的 IM 系统为什么用 channel 而不是 mutex 管理在线用户

10 阅读6分钟

做即时通讯系统,绕不开一个核心问题:成千上万个 WebSocket 连接同时收发消息,服务端要维护一张「在线用户表」才能知道目前有谁在线,消息应该发给谁。

如果我们选择使用大量 goroutine 并发读写这张表,为了保证并发安全只能给他加一把锁。但我在自己的 IM 项目里没有用锁——整个 Hub 没有一个 sync.Mutex,却是并发安全的。这篇文章讲清楚为什么,以及这个设计背后的 Go 并发哲学。

问题:map并发不安全

先看 Hub 持有的核心状态:

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

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
}

能用,但有几个让我不舒服的地方:

  • 锁要散落在每一处访问点clientsgroups 两张表,注册、注销、加群、退群、单聊路由、群聊路由,每个地方都要记得正确地加锁解锁,漏一个就是 data race,多一个就是死锁。
  • 锁的粒度很难拿捏。群聊广播时要遍历群成员、逐个查在线表,这段持锁时间一长,整个 Hub 的吞吐就被拖住。
  • 心智负担重。每写一个新方法都要先想"这里要不要加锁、加读锁还是写锁"。

锁本身没错,但它把并发安全这件事「摊」到了代码的每个角落。我想要的是把它收拢到一个地方。

方案二:用 channel 把并发收敛到单 goroutine

我最终的做法是:所有对 clientsgroups 的读写,只在一个 goroutine 里进行。其它 goroutine 想改这两张表,不直接动手,而是把"请求"通过 channel 发进来。

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 循环:

// 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,而是:

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 是这个模型很典型的落地:clientsgroups 这两块状态只有 Run 这个 goroutine 能碰,所有变更都通过 channel 发请求,被天然串行化。

一个延伸:跨实例消息怎么办

单机版到这就闭环了。但部署多个实例时会出现新问题:用户 A 连在实例 1,用户 B 连在实例 2,A 发给 B 的消息,实例 1 的 clients 表里根本找不到 B。

我的解法是 Redis Pub/Sub。消息进来后,broadcast 这个 case 先存库、再发布到 Redis 频道,而不是自己直接投递:

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:

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 里完成:

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 反而更直接。选哪个,取决于状态是被"频繁并发变更"还是"频繁并发读取"。