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 官方实现大量基于其工作。感谢社区每一位贡献者!

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

相关推荐
蓝莓味的口香糖2 分钟前
【企业微信】VUE项目在企微中自定义转发内容
前端·vue.js·企业微信
IT_陈寒2 分钟前
告别低效!用这5个Python技巧让你的数据处理速度提升300% 🚀
前端·人工智能·后端
—Qeyser4 分钟前
Laravel + UniApp AES加密/解密
前端·uni-app·laravel
C++chaofan7 分钟前
游标查询在对话历史场景下的独特优势
java·前端·javascript·数据库·spring boot
cg.family9 分钟前
Vue3 v-slot 详解与示例
前端·javascript·vue.js
FreeBuf_23 分钟前
新型域名前置攻击利用Google Meet、YouTube、Chrome及GCP构建流量隧道
前端·chrome
c0detrend28 分钟前
技术架构设计:如何打造一个高性能的Chrome截图插件
前端·chrome
幽络源小助理34 分钟前
8、幽络源微服务项目实战:前端登录跨域同源策略处理+axios封装+权限的递归查询增删改+鉴权测试
前端·微服务·架构
API开发39 分钟前
apiSQL+GoView:一个API接口开发数据大屏
前端·后端·api·数据可视化·数据大屏·apisql
运维开发王义杰40 分钟前
nodejs:揭秘 npm 脚本参数 -- 的妙用与规范
前端·npm·node.js