本文永久链接 – https://tonybai.com/2026/11/09/go-maps-grow-go-1-28

大家好,我是Tony Bai。

【导读】

Go 语言的 map 使用起来足够简单,但在性能敏感的场景中,map 的动态扩容仍然可能带来不必要的开销。

过去,我们可以在创建 map 时预估容量,却无法对一个已经存在的 map 主动预留空间。尤其是在向第三方持有的 map、HTTP Header 等对象批量添加数据时,开发者往往只能接受运行时逐步扩容,或者自己重新分配、复制数据。

近日,Go 团队正式接受了 maps.Grow 提案。它将为 maps 标准库增加一个新 API,并进一步优化 maps.Copy。这项改进预计随 Go 1.28 发布。

一个看似很小的 API,背后其实涉及 Go map 的扩容策略、内存分配以及 API 设计哲学。

【文章要点】

  1. 问题从哪里来:make 的容量提示只能在创建 map 时使用,已有 map 缺少主动预扩容能力。
  2. 新 API:maps.Grow(m, n),为已有 map 预留至少容纳 n 个新增元素的空间。
  3. 实际应用:HTTP Header、ReverseProxy、CGI 等场景可以减少 map 扩容开销。
  4. 性能收益:在大 map 批量复制场景中,基准测试显示耗时最多降低约 57%,内存和分配次数也显著下降。
  5. 不只是一个 API:maps.Copy 也将自动预扩容,开发者无需手动调用 Grow。
  6. 设计边界:Grow 是尽力而为的容量提示,不暴露 map 的真实容量,也不保证后续绝不扩容。


Go 语言的 map 虽然使用简单,但在高性能场景下,容量管理一直存在一个不太容易被注意到的痛点。

我们可以在创建 map 时,通过 make(map[K]V, hint) 提前告诉 Go:这个 map 大概需要存储多少个元素。

但问题是:如果 map 已经创建好了,而且我们知道接下来还要往里面批量添加几千个元素,却不能替换这个 map,该怎么办?

过去,Go 没有提供直接的解决方案。

近日,这个问题终于迎来了官方答案。

在 Go 团队的 Proposal Review 流程中,编号为 #81698 的提案正式获得接受。11 月 7 日,该 Issue 又被标记为 completed,对应实现已经提交。

这意味着,Go 1.28 将迎来一个新的 map 性能优化 API:maps.Grow。

它看起来只是一个小小的标准库函数,却补上了 Go map 容量管理的一块重要拼图。

一个被忽略的 map 性能问题

先看一段我们非常熟悉的代码:

m := make(map[string]int, 1000)  for i := 0; i < 1000; i++ {  m[strconv.Itoa(i)] = i } 

这里,make 的第二个参数 1000 就是容量提示(size hint)。

它告诉 Go Runtime:请尽可能提前准备好能够容纳这些元素的存储空间。

这样,当后续插入元素时,就能减少 map 扩容带来的额外开销。

但现实中的代码往往没有这么简单。

考虑下面这个场景:

func merge(dst, src map[string]int) {  for k, v := range src {  dst[k] = v  } } 

假设 dst 是由调用方创建并持有的 map,而 src 中有几万个元素。

我们知道即将发生一次大规模写入,却无法在 merge 中重新创建 dst,因为调用方可能还持有这个 map 的其他引用。

更重要的是,重新创建 map 再复制旧数据,不仅需要额外分配内存,还需要重新遍历、复制已有元素。

而直接逐个插入,则可能触发多次渐进式扩容。

这正是 Go 团队希望解决的问题。

提案作者在 Issue 中列举了几个真实场景:

  • net/http 中的 TimeoutHandler,需要将 Header 复制到另一个 ResponseWriter 持有的 map 中。
  • net/http/httputil 中的 ReverseProxy,需要将上游响应 Header 合并到目标 Header。
  • net/http/cgi,需要将 CGI 响应头写入已有 Header map。
  • Honeycomb 的 libhoney-go,曾经不得不通过重新创建 map、复制全部元素的方式来规避扩容开销。

这些场景有一个共同点:目标 map 已经存在,而且调用方希望在原地修改它。

make 和 maps.Clone 都无法直接解决这个问题。

maps.Grow 登场

Go 1.28 将在标准库 maps 包中增加一个新函数:

func Grow[M ~map[K]V, K comparable, V any](m M, n int) 

它的使用非常简单:

package main  import (  "maps" )  func merge(dst, src map[string]int) {  // 预留足够的空间  maps.Grow(dst, len(src))   // 再执行批量写入  maps.Copy(dst, src) } 

原来需要逐个插入、让 Runtime 自己处理扩容的代码,现在可以先给 map 一个明确的容量提示。

这里有几个值得注意的设计细节。

第一,n 表示新增元素数量,而不是 map 的目标总容量。

例如:

m := map[string]int{  "Go": 1,  "Rust": 2, }  maps.Grow(m, 1000) 

此时 map 已经有两个元素,Grow 的目标相当于:

make(map[string]int, len(m)+1000) 

也就是为已有元素之外的 1000 个新增元素预留空间。

这一语义与 Rust 的 HashMap::reserve(additional) 非常接近。

第二,Grow 不会缩小 map。

maps.Grow(m, 1000) maps.Grow(m, 10) 

第二次调用不会因为参数更小而缩小 map。

第三,它是尽力而为,而不是绝对保证。

Go 官方文档注释明确指出:Grow 会尽可能保证后续新增元素不再触发扩容,但这并不是一个严格保证。

第四,存在明确的异常边界。

maps.Grow(m, -1) // panic 

如果 n 为负数,会触发 panic。

如果 m 是 nil map 且 n > 0,同样会 panic。这与向 nil map 写入元素的行为保持一致。

而 Grow(nilMap, 0) 不会因为 nil map 而 panic。

值得注意的是,Grow 没有返回值。

这是因为 Go map 是引用语义的数据结构,容量调整直接作用于原 map,不需要像 slices.Grow 那样返回一个可能发生变化的新 slice。

不只是 Grow:maps.Copy 也变聪明了

这次改进还有一个容易被忽略的部分。

Go 团队不仅增加了公开的 maps.Grow API,还对已有的 maps.Copy 进行了优化。

以前,maps.Copy 的实现逻辑基本上就是遍历源 map,然后逐个写入目标 map。

现在,Go 团队为它增加了自动预扩容逻辑。

当源 map 的元素数量大于目标 map 时,maps.Copy 会先尝试为目标 map 扩容,然后再执行复制。

其核心逻辑可以简化理解为:

func Copy[M1 ~map[K]V, M2 ~map[K]V,  K comparable, V any](dst M1, src M2) {   if n := len(src) - len(dst); n > 0 {  grow(dst, n)  }   for k, v := range src {  dst[k] = v  } } 

这里的 grow 是 Go 标准库内部使用的 Runtime 扩容入口,并不是最终公开 API 的调用形式。

为什么这里使用 len(src) - len(dst),而不是直接使用 len(src)?

因为 maps.Copy 有一个重要特性:当源 map 与目标 map 存在相同 Key 时,目标 map 中原来的 Value 会被覆盖。

因此,复制完成后,目标 map 至少会有 len(src) 个元素。

提前按照这个数量进行预扩容,不会因为 Key 重叠而产生不必要的过度分配。

另外,对于很小的 map,Go 团队还做了优化:如果源 map 的元素数量没有超过一个 map Group 的容量,就跳过预扩容操作。

这意味着,普通的小 map 复制不会因为这次优化而平白增加额外开销。

性能究竟提升多少?

这次优化最值得关注的,当然还是性能数据。

在相关实现 CL 中,Go 开发者分别在 AMD Ryzen 5 5600X 和 Ampere Altra(Neoverse N1)两种处理器上进行了基准测试。

其中,AMD Ryzen 5 5600X 上的部分结果如下:

测试场景 优化前 优化后 耗时变化
空 map,复制 64 个元素 4.899 μs 2.101 μs -57.12%
小 map,复制 1024 个不重叠元素 74.20 μs 32.58 μs -56.09%
50% Key 重叠,复制 1024 个元素 56.30 μs 44.30 μs -21.31%
空 map,复制 65536 个元素 5.280 ms 2.568 ms -51.36%

数据来源:Go CL 839065,AMD Ryzen 5 5600X,10 轮交错测试。

耗时最高降低约 57%。

这组数据说明,在源 map 明显大于目标 map 的情况下,提前扩容可以带来非常可观的收益。

更有意思的是,内存分配数据也发生了明显变化。

在需要预扩容的场景中,优化后的内存使用量和分配次数大约减少了 50%。

而在两种不需要预扩容的场景中:

  • 两个 map 的 Key 完全相同。
  • 两个 map 大小相同,但 Key 完全不同。

优化前后的性能基本没有变化。

这说明 Go 团队并没有简单地对所有 map Copy 都进行一次容量调整,而是针对真正存在扩容需求的场景进行了优化。

一个小 API 背后的设计哲学

其实,Go map 是否应该支持主动扩容,并不是一个新问题。

早在 2022 年,Go 社区就有相关讨论,Issue #56182 曾经提出,希望 Runtime 能够支持一次性扩大 map 容量,而不是只能渐进式扩容。

这次的 maps.Grow 正是从那项讨论中拆分出来的独立提案。

这里还有一个有意思的设计细节。

过去,社区曾经讨论过是否应该为 map 增加类似 Slice 的 cap 能力,让开发者能够直接查询 map 的容量。

相关提案 #52157 最终没有被接受。

原因之一是:map 的容量并不能保证在后续写入中一定不会发生扩容。

这次的 maps.Grow 选择了另一条路线:

不向开发者暴露 map 的容量,只提供一个主动表达扩容意图的 API。

开发者告诉 Runtime:我预计还要增加多少元素,请提前做好准备。

至于具体分配多少内存、如何组织底层存储,以及是否需要进一步扩容,仍然交给 Runtime 决定。

这实际上延续了 Go 一贯的设计思路:提供必要的控制能力,但尽可能隐藏底层实现细节。

另外,maps.Grow 并不是用来替代 make 的。

如果 map 尚未创建,依然应该优先使用:

m := make(map[string]int, 1000) 

如果 map 已经存在,并且需要原地批量写入,才是 maps.Grow 的用武之地。

小结

Go 语言的标准库演进,有时候并不是增加一个多么炫酷的新特性,而是把开发者在真实项目中反复遇到的问题,变成一个简单、可靠、官方支持的 API。

maps.Grow 就是这样一个例子。

它不会改变 Go map 的使用方式,也不会让所有 map 操作突然变快。

但对于 HTTP Header 合并、批量数据导入、map 合并,以及其他已知新增元素数量的场景,它提供了一种过去缺失的优化手段。

更重要的是,maps.Copy 也已经获得了自动预扩容能力。

按照目前 Go 团队的实现进度,这项改进将大概率随 Go 1.28 正式发布。

下次面对一个已经存在、即将迎来大量写入的 map 时,我们终于不必再纠结于重新创建、复制数据,或者接受多次渐进式扩容了。

一行 maps.Grow(m, n),就能把这个意图明确地告诉 Go Runtime。

参考资料:

  1. Go Proposal #81698:maps: add Grow - https://github.com/golang/go/issues/81698
  2. Go CL 819620:maps: add Grow - https://go-review.googlesource.com/c/go/+/819620
  3. Go CL 839065:maps: presize dst in Copy when src is larger - https://go-review.googlesource.com/c/go/+/839065
  4. Go Proposal #56182:runtime: expand capacity of map by more than 1 at a time? - https://github.com/golang/go/issues/56182
  5. Go Proposal #52157:language: allow getting capacity of map? - https://github.com/golang/go/issues/52157

还在为写 Agent 框架频频死循环、上下文爆炸而束手无策?我的新专栏 《从0 开始构建 Agent Harness》 将带你:

  • 抛弃臃肿框架,回归“驾驭工程 (Harness Engineering)”的第一性原理
  • 用 Go 语言手写 ReAct 循环、并发拦截与上下文压缩引擎等,复刻极简OpenClaw
  • 构建坚不可摧的 Safety Middleware 与飞书人工审批防线
  • 在底层实现 Token 成本审计、链路追踪与自动化跑分评估
  • 从“调包侠”进化为掌控大模型边界的“AI 操作系统架构师”

扫描下方二维码,开启从 0 开始构建Agent Harness 的实战之旅。


你的Go技能,是否也卡在了“熟练”到“精通”的瓶颈期?

  • 想写出更地道、更健壮的Go代码,却总在细节上踩坑?
  • 渴望提升软件设计能力,驾驭复杂Go项目却缺乏章法?
  • 想打造生产级的Go服务,却在工程化实践中屡屡受挫?

继《Go语言第一课》后,我的《Go语言进阶课》终于在极客时间与大家见面了!

我的全新极客时间专栏 《Tony Bai·Go语言进阶课》就是为这样的你量身打造!30+讲硬核内容,带你夯实语法认知,提升设计思维,锻造工程实践能力,更有实战项目串讲。

目标只有一个:助你完成从“Go熟练工”到“Go专家”的蜕变! 现在就加入,让你的Go技能再上一个新台阶!


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。