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

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

相关推荐
xjt_09018 分钟前
基于 Vue 3 构建企业级 Web Components 组件库
前端·javascript·vue.js
我是伪码农20 分钟前
Vue 2.3
前端·javascript·vue.js
夜郎king44 分钟前
HTML5 SVG 实现日出日落动画与实时天气可视化
前端·html5·svg 日出日落
夏幻灵2 小时前
HTML5里最常用的十大标签
前端·html·html5
Mr Xu_2 小时前
Vue 3 中 watch 的使用详解:监听响应式数据变化的利器
前端·javascript·vue.js
未来龙皇小蓝2 小时前
RBAC前端架构-01:项目初始化
前端·架构
程序员agions2 小时前
2026年,微前端终于“死“了
前端·状态模式
万岳科技系统开发2 小时前
食堂采购系统源码库存扣减算法与并发控制实现详解
java·前端·数据库·算法
程序员猫哥_2 小时前
HTML 生成网页工具推荐:从手写代码到 AI 自动生成网页的进化路径
前端·人工智能·html
龙飞052 小时前
Systemd -systemctl - journalctl 速查表:服务管理 + 日志排障
linux·运维·前端·chrome·systemctl·journalctl