Go 1.24 全面拥抱 Swiss Table:让内置 map 提速 60% 的秘密

本文来自公众号 猩猩程序员 欢迎关注

本文来自 go.dev/blog/swisst...

哈希表是计算机科学的核心数据结构,也是包括 Go 在内的许多语言中 map 类型的底层实现。

1953 年,Hans Peter Luhn 在一份 IBM 内部备忘录中首次提出哈希表概念:通过把元素放进"桶"并用链表处理溢出,来加速查找。今天我们把这种实现称为"链地址法"哈希表。

1954 年,Gene M. Amdahl、Elaine M. McGraw 与 Arthur L. Samuel 在为 IBM 701 编程时首次采用"开放寻址"策略:当一个桶已被占用时,就把新元素放进下一个空桶。1957 年,W. Wesley Peterson 在《Addressing for Random-Access Storage》中正式发表这一思想。今天我们把这种实现称为"线性探测开放寻址"哈希表。

历经七十多年,我们很容易以为这种"老"数据结构已经"定型"。事实并非如此!计算机科学仍在持续改进基础算法,既优化算法复杂度,也更好地利用现代 CPU 硬件。例如,Go 1.19 就把 sort 包从传统快排换成了 2015 年才提出的 Pattern-Defeating Quicksort。

与排序算法类似,哈希表也在演进。2017 年,Google 的 Sam Benzaquen、Alkis Evlogimenos、Matt Kulukundis 与 Roman Perepelitsa 提出了一种新的 C++ 哈希表设计------"Swiss Tables";2018 年,其开源实现随 Abseil C++ 库发布。

Go 1.24 基于 Swiss Table 设计,对内置 map 进行了彻底重写。本文将介绍 Swiss Table 如何优于传统哈希表,以及将其引入 Go map 时遇到的独特挑战。

开放寻址哈希表

Swiss Tables 属于开放寻址哈希表,我们先回顾一下基本实现方式。

在开放寻址哈希表中,所有元素都存放在同一块底层数组里,数组中的每个位置称为一个 slot。每个键所属 slot 由哈希函数 hash(key) 决定。若目标 slot 已被占用(冲突),就按"探测序列"继续查找,直到找到一个空 slot。

示例

假设底层数组有 16 个 slot:

Slot 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Key 56 32 21 78

插入新键时,先计算 hash(key) % 16 得到目标 slot。若 slot 为空,直接插入;否则按线性探测依次尝试后继 slot。

查找过程同理,遇到空 slot 即可停止。

当元素数量超过数组大小时,哈希表会扩容(通常翻倍),并把旧元素全部重新插入到新数组中。为避免平均探测长度过长,哈希表并不会等到数组全满才扩容,而是设定一个最大负载因子(通常 70--90%)。

Swiss Table

Swiss Table 仍是开放寻址,但做了关键改进:

  1. 把底层数组按 8 个 slot 一组划分为"group"(也可更大)。
  2. 每组配一个 64 位"控制字"(control word)。8 个字节分别对应组内 8 个 slot,记录该 slot 是空、已删除还是占用;若占用,则保存该键哈希低 7 位(h2)。

示意图:

Group 0 Group 1
slot 0--7 slot 0--7
Key: 56,32,21,... Key: 78,...
Ctrl: 23,89,50,... Ctrl: 47,...

插入流程:

  1. 计算 hash(key),拆分为高 57 位 h1 与低 7 位 h2。
  2. 用 h1 选起始 group:h1 % group_cnt
  3. 在组内并行比较控制字,筛选 h2 匹配的候选 slot;若命中则更新,否则找空 slot;若无空 slot,则继续探测下一组。
  4. 查找同理,可用 SIMD 一次并行比较 8 个 slot。

借助控制字,我们一次就能完成 8 次探测,大幅减少比较次数。Abseil 与 Go 的实现都因此提高了最大负载因子,降低了平均内存占用。

Go 独有的挑战

Go 的内置 map 有两个特殊需求,给 Swiss Table 落地带来额外复杂性:

  1. 增量扩容

    传统实现触发扩容时,需要一次性复制全部元素,最坏情况下延迟极高。Go map 用于高并发服务器,必须限制单次插入的最坏延迟。

    因此 Go map 采用"增量扩容":把一个大 map 拆成多个最多 1024 条目的独立 Swiss Table。哈希的高位决定落入哪个表。扩容时只需复制 1024 条元素,延迟可控。

  2. 边遍历边修改

    Go 语言规范允许在遍历 map 时增删键值,并给出明确定义:

    • 删除尚未遍历到的键不会被产出;
    • 更新尚未遍历到的键会产出新值;
    • 新增的键可能或可能不被产出。
      传统"顺序扫描数组"方式在扩容时会打乱内存布局,无法满足语义。
      Go 的解决方法是:迭代器持有旧表的引用;若表在遍历期间扩容,仍按旧表顺序产出键值,但在真正返回前查询新表,确保返回最新值或跳过已删除项。这使得遍历成为 Go map 实现中最复杂的部分。

未来工作

在微基准测试中,Go 1.24 的 map 操作相比 1.23 最高提速 60%。

整体应用级基准平均 CPU 时间下降约 1.5%。

后续计划:

  • 提升未命中 CPU cache 时的局部性;
  • 在更多架构上利用 SIMD 指令,把 group 扩大到 16 slot,进一步减少探测次数。

致谢

Swiss Table 版 Go map 历经多年、多人贡献。感谢 YunHao Zhang (@zhangyunhao116)、PJ Malloy (@thepudds)、@andy-wm-arthur 早期实现;Peter Mattis (@petermattis) 结合 Go 需求完成 github.com/cockroachdb/swiss 库;Go 1.24 官方实现大量基于其工作。感谢社区每一位贡献者!

本文来自公众号 猩猩程序员 欢迎关注

相关推荐
IT_陈寒24 分钟前
React性能优化实战:这5个Hooks技巧让我的应用快了40%
前端·人工智能·后端
江天澄40 分钟前
HTML5 中常用的语义化标签及其简要说明
前端·html·html5
知识分享小能手44 分钟前
jQuery 入门学习教程,从入门到精通, jQuery在HTML5中的应用(16)
前端·javascript·学习·ui·jquery·html5·1024程序员节
美摄科技1 小时前
H5短视频SDK,赋能Web端视频创作革命
前端·音视频
黄毛火烧雪下1 小时前
React Native (RN)项目在web、Android和IOS上运行
android·前端·react native
fruge1 小时前
前端正则表达式实战合集:表单验证与字符串处理高频场景
前端·正则表达式
baozj1 小时前
🚀 手动改 500 个文件?不存在的!我用 AST 撸了个 Vue 国际化神器
前端·javascript·vue.js
用户4099322502122 小时前
为什么Vue 3的计算属性能解决模板臃肿、性能优化和双向同步三大痛点?
前端·ai编程·trae
海云前端12 小时前
Vue首屏加速秘籍 组件按需加载真能省一半时间
前端
蛋仔聊测试2 小时前
Playwright 中route 方法模拟测试数据(Mocking)详解
前端·python·测试