Go 风格:可读且快速
目标是编写维护者能从头读到尾的代码,其性能源自 API 的形状和数据的布局,而非分散在代码中的技巧。一旦设计正确,可读性和速度极少冲突;当它们冲突时,仅在经剖面分析证实的热路径上放弃可读性,并在注释中说明原因。
本文遵循 Dave Cheney 的“API 设计作为性能原语”:超过一定限度后,性能无法事后添加,因此要前置设计,然后保持其他一切简洁。
来源:Cheney 的演讲 (https://dave.cheney.net/paste/gophercon-uk-2026.html),代码位于 github.com/pkg/json 和 github.com/davecheney/bytereader;John Ousterhout,“将错误定义出存在”。
优先级顺序
- 正确性。 绝不为速度或简洁牺牲它。
- API 形状与大 O。 谁拥有内存、返回什么、每次调用做多少工作。这些比任何后续调优更能决定性能。
- 热路径上的分配。 每次分配成本双倍:一次用于分配,一次用于垃圾回收器。回收器可能很快,“但不分配永远更快”。
- 每单位输入的工作。 每个字节只触摸一次,按令牌或记录调用,而不是按字节调用。
- 微优化(手动内联、位技巧、避免边界检查),仅在剖面分析显示为热路径的路径上,且必须有基准测试证明收益。
如果更改未在更高层级得到证明,就不要进行。
分离热路径和冷路径
确定哪些代码每次事件或请求都运行(热路径),哪些在设置、构建、配置或更新时运行(冷路径)。
- 冷路径: 编写最直接的代码。克隆而非共享,使用 map 和
fmt,自由分配。不要在此引入热路径惯用法;它们牺牲可读性但毫无收获。 - 热路径: 可以使用可复用缓冲区、代计数器、扁平数组等技术。为每种技术赋予一个名称(一个小类型或辅助函数)并附上注释解释其存在原因,这样读者无需逆向工程。
为性能设计 API
API 决定了调用者可能节省多少。“你在对垃圾回收器做基准测试”当 API 强制每个结果在堆上分配时。设计 API 使得快路径是自然路径;2-10 倍的提升通常可用,“如果你愿意接受不同的 API”。
- 不要返回必须逃逸的值。 通过
interface{}/any、新字符串、map 或指向每个项的新结构体的指针返回原始类型,每次调用都会强制分配(encoding/json.Decoder.Token是经典的“垃圾工厂”)。返回具体类型,或返回视图如[]byte,让调用者只转换它需要的部分。 - 返回视图,而非副本, 当调用者只需要读取时。在文档注释中说明视图的有效期(“直到下次调用有效”,如
bufio.Scanner.Bytes)。直接返回输入的原始视图(令牌的字节,其首字节给出类别)比调用者可能忽略的解码值更廉价。 - 让调用者拥有可复用内存。 接受 scratch 或缓冲区参数,或将其保存在调用者持有的结构体中,而不是每次调用都分配。让调用者提供初始缓冲区,使其可存活在栈上或来自对象池:
var buf [8 << 10]byte; d := NewDecoderBuffer(r, buf[:])。 - 将缓冲区管理隐藏在少数方法之后。
io.Reader复制到调用者管理的缓冲区中,并将重新填充留给每个调用者。一个小窗口 API 保持数据原位:Window() []byte用于查看,Release(n)用于消费,Extend() int用于读取更多,并在文档注释中写明“窗口在调用 Release 或 Extend 后失效”。 - 将错误排除在热循环之外。 使用返回 bool 或空结果并在之后报告错误的迭代:
for s.Next() { ... }然后s.Err()。在慢路径上使用哨兵结果(Extend返回 0,然后调用者询问错误),这样内层循环没有错误分支。将io.EOF视为其他任何终止条件。 - 计算每单位输入的工作。 每个字节处理一次,按令牌或记录调用函数而非按字节,并消除中间副本。从逐字节调用变为逐令牌调用通常是消除分配之后的第二大收益。
- 分层设计 API。 提供低层、无分配的基础构件(原始令牌、视图、拥有 scratch 的迭代器),并在其上构建便利的 API。不要让便利且会分配内存的 API 成为唯一的 API。
- 在文档注释中写入所有权和生命周期: 哪些是借用的、哪些是保留的、哪些是并发安全的、零值有何含义。
- 在自然的情况下让零值有用。
- 优先选择不可变的共享值并发布新快照,而非在共享可变状态上加锁,当读者占主导时。
不仅要度量实现,还要度量 API:比较 allocs/op 和吞吐量与您要替换的 API。references/examples.md 并排展示了这些形状。
调优热循环,最后一步
仅在 API 和大 O 都正确,且剖面分析显示循环为热路径之后:
- 对一个状态的
switch编译为一连串比较。通过func字段(d.state = (*Decoder).stateValue)为每个状态选择的小方法,保持每个状态的可读性,并可能消除该调度。 - 将检查移至使用该值的函数中,以便编译器能证明边界检查不必要,并保持入口在内联预算 (80) 内;一次调用大约消耗 52)。使用
go build -gcflags=-m=2检查。 - 合并两个函数以避免一次调用,在每条令牌路径上可能赢得 5-15% 的收益,代价是更深层的嵌套。仅在基准测试证实后这样做,并在注释中说明原因。
正如 Cheney 所说:不要“在没有解决 API 中的大 O 效应之前”就使用这些技巧。“它不会改变现状。”
保持函数小巧且扁平
目标是每个函数做一件事,至多有一到两个分支。作为指导,将圈复杂度保持在 10 以下,认知复杂度保持在 10 以下(gocyclo,gocognit)。一个扁平的 type switch 将每个 case 交给一个调用是允许的,但嵌套则不行。
- 为多条件命名。 将
if len(a)*len(b) > maxN || size(a)*len(b)+size(b)*len(a) > maxM替换为if !fitsProduct(a, b)。名称文档化了意图,而函数可以承载解释。 - 分发,然后委托。 对节点或消息类别的 switch 应为每个 case 调用一个方法,而不是包含每个 case 的逻辑。
- 尽早返回。 先处理保护子句,主路径无缩进。
- 不要重复 case。 如果两个分支仅操作数顺序不同,则编写一个辅助函数并调用两次。
- 拆分混合关注点。 “找到它”、“构建新值”和“整理清理”是三个函数,而非一个。
- 避免布尔标志参数。 如果它是唯一清晰的选择,则赋予它有意义的名称,并在调用点解释。
- 使用查找表(
map[string]Kind,[256]bool)代替字符串或字节比较的链条。
为读者和 CPU 塑造数据
- 将并行切片分组为结构体切片。 由同一个 ID 索引的四个切片就是一个结构体。它读起来更好,并且通常能改善局部性。
- 为重复出现的机制赋予自己的小型类型。 如果相同的计数器回绕、位图或缓冲区逻辑出现两次,则它就是包含两三个方法的类型。
- 优先选择显式字段而非位打包, 除非经过测量后打包确实重要。
[]segment{name, each}优于带有位移运算的uint64标志位。 - 保持少数几种表示。 更少的状态或类别意味着更少重复的 switch。当不产生可衡量的代价时,将特殊情况合并到一般情况中。
证明性能声明
在没有测量数据的情况下,绝不要断言某次变更更快、更慢或零分配。在更改性能敏感代码之前:
- 保存相关基准测试的
benchstat基线。 - 进行更改,然后比较。用数字报告显著差异。
- 将
go build -gcflags=-m输出与基线对比,找出新的堆逃逸。 - 保留或添加
testing.AllocsPerRun测试,用于承诺零分配的路径。 - 在猜测回归原因之前先进行剖面分析。
如果简化代码花费了可衡量的热路径时间,且无法干净地恢复,就停下来并报告该权衡,而不是悄悄增加复杂性或默默接受回归。
阅读 references/verify.md 了解确切的命令,包括如何测量复杂度以及常见的导致回归的陷阱。阅读 references/examples.md 了解展示此风格的重构前后对比。
遵循 Go 惯例
- 必须通过
gofmt、go vet和项目的 linter。修复代码,不要削弱或排除 linter 规则;仅当有命名的规则和原因时,才禁止单一行。 - 短的接收者名称,在类型的所有方法间保持一致;没有理由的话,不要在一个类型上混用指针和值接收者。
- 文档注释以标识符名称开头,并说明约定。
- 注释解释为什么,而不是做了什么。与周围注释密度匹配。
- 优先使用标准库(
slices,maps,strings,math/bits),然后才是手写辅助函数。 - 绝不要手动编辑生成的代码。
审查或重构时
- 在接触任何内容之前,先确定热路径及其基准测试。
- 测量复杂度以定位最差的函数;优先修复这些。
- 以小步骤进行重构,每一步后运行专注的测试。
- 保持行为完全一致:相同的结果、相同的预算和后备、相同的分配保证。仅作为一个单独的、明确声明的决定来改变行为。
- 报告发生了什么变化、前后复杂度与基准测试数据,以及任何未经验证的内容。