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

做即时通讯系统,绕不开一个核心问题:成千上万个 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
}

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

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

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

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

我最终的做法是:所有对 clientsgroups 的读写,只在一个 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 是这个模型很典型的落地:clientsgroups 这两块状态只有 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 反而更直接。选哪个,取决于状态是被"频繁并发变更"还是"频繁并发读取"。

相关推荐
吴佳浩1 天前
Go史上最大“打脸”现场来了:泛型方法终于实现了
后端·go
明月_清风1 天前
深入 Go 并发编程:从 Goroutine 到 Channel 的系统性避坑指南
后端·go
用户34232323763172 天前
开源!Go+Wails+Vue3 手搓一个 PLC 实时监控桌面工具
go
止语Lab2 天前
为什么你的 Go TCP server P99 延迟这么高
go
Andy Dennis2 天前
nsq学习记录
消息队列·go·nsq
韦胖漫谈IT2 天前
选语言不是站队,是选适合问题的工具
java·python·ai·rust·go·技术落地
喵个咪3 天前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
夜悊3 天前
Go网络编程的学习代码示例:客户端/服务端(C/S)模型
go
审判长烧鸡3 天前
【AI问答】GO代码循环返值
go