本节摘要:把前三节的模式、竞态与性能经验压缩成八条设计守则与一份评审清单。守则解决"写的时候怎么少犯错",清单解决"别人的代码怎么快速判断"。本节偏方针与判断,配合一个完整评审案例落地——读完它,你评审并发代码时手里就有了一张可逐条打勾的表。
原则类内容容易写成空话,所以本节的每条守则都注明出处:它是前面哪个事故或案例反推出来的。这些守则按"写、传、收、审"的顺序排列,覆盖并发代码的完整生命周期。历史上各家团队的并发规范大同小异,差别只在血泪的多少——本节的版本,就是替你把别人踩过的坑折算成条文。
一、共享面最小化。 能用局部变量就不用共享变量,能按 goroutine 划分就不集中管理;必须共享的,把数据与锁封装进同一个结构体,对外只暴露方法。出处:4.2 的丢失更新——共享面每小一寸,竞态的可能性就少一分。
二、所有权随值转移。 数据交给另一个 goroutine 后,原持有者不再读写它;发送即移交,这是无锁结构的根基。出处:3.1 的 CSP 立场与 4.1 的工作池——数据单向流动时,正确性写在结构里而非纪律里。
三、每条并发路径都有出口。 任何 goroutine 的生命周期都要能被优雅终止:for-select 里放退出分支,后台任务听 context 铃声。没有出口的路径迟早变成泄露。出处:3.2 的退出规则与 4.5 的泄露手册。
四、超时预算逐层递减。 入口给整条链路一百毫秒,内层操作的预算必须小于它减去已完成部分的余量;层层超时加总不得超过入口预算。出处:3.6 的取消树——预算不递减,上层超时就形同虚设。
五、并发度显式受控。 不允许"每请求一个 goroutine、每任务一个 goroutine"的无限增长写法;并发度要么是池大小,要么是信号量配额,要么由下游容量反推。出处:4.1 工作池——无限并发不是性能问题是事故问题。
六、错误是值,随数据流动。 并发路径上的失败用 error 表达并汇入结果流,不在 goroutine 里直接打日志完事,更不允许吞掉;panic 留给不可恢复的编程错误。出处:1.5 的错误规矩与 4.1 的批量案例。
七、全局可变状态趋零。 全局变量是隐式的共享,是并发系统的暗管;配置用显式注入,统计用带锁的专用结构。出处:4.2 投票案例的版本 A——并发 map 写入直接崩,而全局 map 恰是最容易被并发踩中的那种。
八、合入前必过仪器。 竞态检测、基准测试、压测三件套在 CI 里跑完才许合入。出处:4.2 与 4.3——人眼推演纳秒级交错是幻觉,仪器才是事实。
守则要落到判断力,最有效的练习是评审。下面这段代码按"能跑"标准写完,请先自己找问题,再对照评审记录。
var ( cache = map[string]string{} hit int ) func lookup(key string) (string, error) { if v, ok := cache[key]; ok { hit++ return v, nil } v, err := slowQuery(key) // 可能耗时数秒 cache[key] = v return v, err } func main() { for _, k := range keys { go lookup(k) // 每个键一个员工,结果没人收 } time.Sleep(3 * time.Second) fmt.Println("命中:", hit) }
评审记录,逐条对照守则:
结果:五处问题,四条守则直接命中。这段代码的典型之处在于它通过了功能测试——单线程跑、少量键、网络正常,五处问题一个都不发作,直到上线的那个深夜。
解读:评审的顺序也有讲究:先扫状态归属(守则一、七),再看生命周期(三、四、五),最后看错误路径(六)。状态问题最致命,因为它崩溃的是整个进程;生命周期问题次之,泄露是慢性病;错误路径最后,它决定故障时的可诊断性。按这个顺序扫,三分钟内可以给出一份结构化的评审意见。
重写后的骨架长这样,与原版逐行对照,能看清每条守则落在哪一行:
type lookupCache struct { // 守则一/七:共享状态封装,锁随数据 mu sync.RWMutex m map[string]string hit atomic.Int64 } func (c *lookupCache) lookup(ctx context.Context, key string) (string, error) { c.mu.RLock() if v, ok := c.m[key]; ok { // 读路径无阻塞 c.mu.RUnlock() c.hit.Add(1) return v, nil } c.mu.RUnlock() v, err := slowQuery(ctx, key) // 守则三/四:ctx 贯穿,超时受预算约束 if err != nil { return "", fmt.Errorf("回源失败: %w", err) // 守则六:错误随返回值流动 } c.mu.Lock() c.m[key] = v c.mu.Unlock() return v, nil } // 调用侧:固定池领任务、WaitGroup 收口,goroutine 数量有界(守则五)
变式:把这段代码按评审意见重写——缓存封装成带 RWMutex 的结构体、并发度收敛为工作池、slowQuery 包超时模板、结果经窗口汇总、错误随结果流动。重写后的骨架你会发现与 4.1 批量压缩案例几乎重合:守则的终点就是那些成熟的模式。
八条守则偶尔会互相顶牛,提前定好裁决顺序能省掉评审桌上的争论。最常见的冲突是"路径有出口"与"并发有界"遇上传播性需求:任务需要派生子任务,无限派生违反有界,硬性限流又可能让出口等待过久。裁决口径是把两者拆到不同层面——并发度在池的层面统一受控,出口在单个任务的层面独立保证;池满时新任务排队,这个"等"是有界的等(队列有容量、任务有超时),不违反任何一条。
第二类冲突是"共享面最小化"与性能:完全消除共享往往意味着更多复制与更多窗口往返。裁决依据是 4.3 的测量——冲突场景先按最小共享实现,基准显示复制确实成为热点后,再把高频路径改成受锁保护的共享结构,并在注释里写明违反哪条守则、为何豁免。守则被显式豁免是工程决策,被无意违反是事故隐患,两者的区别就是有没有那行注释。
第三类冲突出现在错误流动与延迟之间:每个错误都上报可能让结果流膨胀。裁决是分层过滤——员工侧只上报可聚合的错误摘要,全量细节落本地日志并带同一关联号,汇总端按需回查。原则不变(错误不丢),表达分级。
八行清单扫完大约三分钟,换来的是把 4.2 与 4.5 的事故挡在合入之前——这是并发工程里回报率最高的三分钟。
下一节把本章全部事故编成一本按症状索引的手册——凌晨三点,你需要的不是教程,是速查表。