上一篇的结尾#
第一篇里,最小版本跑通了:两个进程能互相发现、握手、加密聊天。但我在结尾留了两个问题:
keyReady、sharedSecret的并发读写是噩梦 —— 需要锁- 只能和指定的一个人握手 —— 需要 map
这篇就是来解决它们的。而且解决的过程中,我对 Go 的并发模型有了完全不一样的理解。
问题:密钥从一个变成一堆#
“只能和一个人聊"意味着什么?意味着程序里只有一个 sharedSecret,来一个人就覆盖一次,前一个人直接聊不了。
要支持多人,密钥必须变成一堆,按人存:
var peers = map[string][]byte{} // key: "127.0.0.1:9002", value: 共享密钥这是最自然的想法。但问题立刻出现:现在有三个 goroutine 在读写这张 map:
- stdin goroutine:发消息前要读 key →
peers[addr] - ticker goroutine:重发握手前要查"这个人的 key 有了没”
- UDP 收包循环:收到握手包要写 key,收到消息要读 key
Go 的 map 有个"特性":多个 goroutine 同时写同一个 map,直接 panic(runtime 检测到并发写 map 会抛 fatal error: concurrent map writes)。这不是数据错乱的问题,是程序直接炸。
就算不炸,交错读写也会读到一半的状态。所以必须同步。
第一次方案:加锁#
我的第一反应是加锁——把共享变量保护起来:
var (
mu sync.RWMutex
keyReady = false
sharedSecret []byte
)
// 读:RLock
mu.RLock()
ready := keyReady
mu.RUnlock()
// 写:Lock
mu.Lock()
sharedSecret = ...
keyReady = true
mu.Unlock()RWMutex 比 Mutex 好一点:读可以并发(RLock 之间不互斥),写是独占的。听起来挺美。
然后噩梦就开始了。
踩坑实录#
1. continue 忘 Unlock → 直接死锁#
收包循环里,收到握手包要处理;但如果已经握过手了,就跳过:
case 0x00:
mu.Lock()
if keyReady {
// ⚠️ continue 之前忘了 Unlock
continue
}
sharedSecret = ...
keyReady = true
mu.Unlock()continue 直接跳走了,锁没还。 从此任何 goroutine 想拿这把锁,永远等不到——程序卡死。
这个 bug 最坑的地方:它不是每次都触发。只有"收到重复握手包"才走 continue 分支。程序可能正常跑几分钟,你正聊得开心,突然整个卡住,没有任何报错。
2. 用 Sleep 掩盖竞态#
还有一次,我发现"刚 Set 完 keyReady,对方又发来握手包"会导致重复处理。我的"修复"是:
time.Sleep(1 * time.Second) // 等对方处理完再继续?现在回头看,这是典型的用 Sleep 掩盖竞态:Sleep 不是修逻辑,只是把触发概率降低。程序"看起来正常"了,但竞态还在那里,只是不常踩到。而且每次握手慢一秒——为了掩盖 bug 牺牲性能,方向完全错了。
3. race detector 才是真相#
排查竞态,靠肉眼盯代码效率太低。Go 自带一个神器:
go build -race -o p2p .
./p2p-race 编译出来的程序会在运行时检测数据竞争,一旦发现,立刻打印冲突报告:哪两个 goroutine、在哪个文件的哪一行、读写哪个变量:
==================
WARNING: DATA RACE
Read at 0x00c0000b4000 by goroutine 7:
main.main.func2()
/path/peer.go:87
Previous write at 0x00c0000b4000 by goroutine 5:
main.main.func1()
/path/peer.go:45
==================有了它,“我的锁够不够"不再是猜的——跑一遍,全给你指出来。
4. 程序卡死怎么查:SIGQUIT#
死锁的时候程序没反应,也没有报错。这时候可以:
# 卡住时按 Ctrl-\ (或 kill -QUIT <pid>)Go 运行时会打印每一个 goroutine 的完整调用栈,直接看到谁卡在哪一行:
goroutine 5 [chan receive]:
main.(*peerStore).Get(...)
/path/peer.go:67比到处加 print 快得多。
转折:锁的问题在"靠纪律”#
锁版本能跑,但我越写越难受。每加一处对 peers 的访问,都要想三件事:
- 该用 Lock 还是 RLock?
- 这个分支会不会提前 return/continue 导致忘解锁?
- 另一个 goroutine 会不会在我 Lock 的时候也来 Lock?
正确性完全靠我记住每一对 Lock/Unlock。漏一个,就是死锁或竞态,而且 bug 要特定时序才触发,debug 成本极高。
然后我想起 Go 那句名言:
Do not communicate by sharing memory; instead, share memory by communicating.
中文大概是:别靠共享内存来通信,靠通信来共享内存。
我当时理解不了这句话。共享内存怎么了?加锁不就完了?直到多 peer 的 map 让我痛不欲生,我才慢慢品出味道:
与其让一堆 goroutine 抢同一份数据(然后加锁防止它们抢坏),不如让数据只属于一个 goroutine,别人想要,就"写信"问它要。
重构:单 goroutine 独占 + Channel#
于是有了 peerStore:
type peerStore struct {
setChan chan SetReq // 写入请求:某个 peer 的密钥算出来了
getChan chan GetReq // 读取请求:给我某个 peer 的密钥
listChan chan ListReq // 列表请求:现在有哪些 peer
}
// 一个 goroutine 独占 map,通过 select 处理所有请求
func (p *peerStore) run() {
peerList := make(map[string]Peer) // map 是 run() 的局部变量!
for {
select {
case set := <-p.setChan:
peerList[set.Address] = set.Peer
case get := <-p.getChan:
peer, exists := peerList[get.Address]
get.Reply <- GetResp{Peer: peer, Exists: exists}
case list := <-p.listChan:
list.Reply <- snapshot
}
}
}核心变化:map 不再是全局变量,它成了 run() 里的局部变量。全程序只有这一个 goroutine 能碰它。 其他 goroutine 想读写,只能把请求塞进 channel,等回复:
// 调用方:想读某个 peer
func (p *peerStore) Get(address string) (Peer, bool) {
replyChan := make(chan GetResp, 1) // 每次调用现造一个"回信地址"
p.getChan <- GetReq{address, replyChan}
resp := <-replyChan // 等 owner 回信
return resp.Peer, resp.Exists
}请求在 channel 里排队,owner 一个一个处理——天然串行,没有竞争,不需要任何锁。
为什么这个模式让我舒服了#
| 锁版本 | Channel 版本 | |
|---|---|---|
| 正确性靠什么 | 靠我记住每个 Lock 配对 Unlock | 靠结构:数据只有 owner 能碰,错都错不起来 |
| 死锁风险 | 漏一个解锁就死锁 | 只有一种死法:忘了回信(见下) |
| 加新字段 | 要检查所有访问点 | 只改 run() 一处 |
| 心智负担 | 每次访问先想"锁没锁" | 不用想,数据不在我这 |
锁的正确性是靠纪律维持的——你在跟自己的记忆力搏斗;Channel 的正确性是结构保证的——它不给你犯错的机会。
Channel 模式也有坑:忘了回信#
不是说 channel 就没死锁了。它的死锁长这样——owner 处理请求时忘了回信:
case get := <-p.getChan:
// 忘了写:get.Reply <- ...调用方 <-replyChan 永远等不到,卡死。而且 Go 不会报错(除非所有 goroutine 都睡着了),症状就是"程序没反应,也没报错"。
所以上面那个 SIGQUIT 看调用栈的方法,在 channel 时代反而更重要了——你会直接看到 goroutine 停在 chan receive 的哪一行,是谁没回信,一目了然。
最后:这套模式不是银弹#
单 goroutine 独占 + channel 适合状态被多方读写、操作简单的场景(我的 map 就是这样)。
什么时候锁反而更好?
- 状态极简单(一个 bool、一个计数器),
atomic就够了,上 channel 是杀鸡用牛刀 - 读极多、写极少,锁的读并发比 channel 串行高效
- 状态操作复杂到"用消息描述不清",直接方法调用更直观
但对我的项目来说,channel 版本最大的收益不是性能,是心智自由:我不再需要时刻警惕"哪里漏了锁",可以把全部注意力放回协议本身——而后面几篇的广播、状态机、重传,都是在 peerStore 这个稳定地基上盖的。
小结#
这一篇的演进是:
全局变量(单 peer 能跑,但来两个人就打架)
↓ 需要 map 存多把密钥,并发问题暴露
加锁 sync.RWMutex(能跑,但 continue 忘解锁就死锁)
↓ race detector 揭示真相,Sleep 掩盖失败
单 goroutine 独占 map + Channel 通信(零锁,结构上不可能竞态)下一篇我会讲 UDP 广播发现——让 peer 不需要提前知道对方地址,在局域网里自动找到彼此。
本文对应的代码在 M1ngdaXie/mini-P2P。