WebSocket 连接中心的两种并发模型:一把锁 vs 一个 goroutine
两个项目都要维护一张「uid → 连接」的表,但并发模型完全不同。放在一起看,取舍就很清楚了。
| 单 goroutine + channel(go-websocket) | sync.RWMutex(mini-im) |
|
|---|---|---|
| 谁来改 map | 只有 run() 一个 goroutine |
任意 goroutine,靠锁 |
| map 加锁 | 不需要 | 读写都走锁 |
| 一个 uid 几条连接 | 一条(新连接顶掉旧的) | 多条(多端在线) |
| 慢客户端 | 队列满 → 断开并回告发送方 | 队列满 → 转离线队列 |
| 心跳 | ping/pong/write 全部可配,0 表示关闭 | 常量写死,54s / 60s |
单 goroutine:map 完全无锁
所有会改动映射的操作都走 channel,因此 clients 这张表只有一个 goroutine 碰它:
type Hub struct {
clients map[string]*Client
register chan *Client
unregister chan *Client
deliver chan common.Message
online chan chan []string // 「请求-应答」式取在线列表
done chan struct{}
}
func NewHub() *Hub {
h := &Hub{ /* ... */ deliver: make(chan common.Message, 256) }
go h.run()
return h
}
deliver 带 256 缓冲是有意的:读循环与 Hub 之间只隔这一层,缓冲能吸收突发消息,
不至于让一次消息高峰把读循环堵住。
查询类操作用 chan chan []string 做同步应答,全链路 select 监听 done,
Hub 停机时不会把查询方挂住。
这个模型的前提是「一个 uid 一条连接」。重复连接时直接顶号:
case c := <-h.register:
// 不顶号会出现「消息发给一个已经没人用的连接」,前端表现为消息发出去没人收到
if old, ok := h.clients[c.uid]; ok && old != c {
delete(h.clients, c.uid); old.close()
}
h.clients[c.uid] = c
case c := <-h.unregister:
if cur, ok := h.clients[c.uid]; ok && cur == c { // 幂等:只删「当前」那条
delete(h.clients, c.uid); c.close()
}
unregister 那个 cur == c 判断不是多余的:顶号之后,旧连接的 unregister 会晚一步到达,
此时 map 里已经是新连接了,不加这个判断就会把新连接误删。
一把锁:为了多端在线
IM 要支持手机和电脑同时在线,映射的值就变成了连接集合,而且在线状态要按「首连/末断」判定:
type Hub struct {
mu sync.RWMutex
clients map[int64]map[*Client]struct{} // uid → 连接集合(多端)
}
func (h *Hub) add(c *Client) {
// ...
first := len(set) == 1
if first { service.SetOnline(c.uid) } // 首个连接才标在线
}
func (h *Hub) remove(c *Client) {
// ...
last := len(set) == 0
if last { service.SetOffline(c.uid) } // 最后一个断开才标离线
}
用锁的好处是这段逻辑写在调用方、读起来是顺序的;代价是每个 SendTo 都要取一次读锁,
并且要自己保证不出现「读了又改」的竞态。
共同点:每连接只有一个写方
这点与并发模型无关,是 gorilla/websocket 的硬约束:
同一个连接不支持并发写。两个项目都把写收敛到 writePump 一个 goroutine,
其他人只往队列里投:
func (c *Client) push(data []byte) bool {
select { case <-c.done: return false; default: }
select { case c.send <- data: return true; default: return false } // 非阻塞
}
非阻塞是关键:慢客户端不能拖住 Hub —— Hub 一旦卡住,所有人的消息都投不出去。
返回 false 之后怎么处理,就是两个项目的分歧点:
- 有持久化的 IM:转离线队列,对方上线再补发;
- 纯转发的演示服务:直接断开,并给发送方回一条「对方队列已满,连接已断开」。
没有对错,取决于有没有地方存这条消息。