GO 运行时内部原理
Go map 内部原理
Go 1.24 把内置 map 的实现整个换掉了:告别用了十几年的"桶 + 溢出链",改成了业界成熟的 Swiss Table 设计。这篇文章配了一个可以逐步播放的插入/查找/扩容模拟器,以及若干条直接引用自 Go 运行时源码的片段。
01 · 为什么要换掉用了十几年的实现
从桶 + 溢出链到 Swiss Table
老版本的 Go map(runtime/map.go 里的 hmap/bmap)用的是经典设计:一个桶装 8 个键值对,桶满了就挂一条"溢出桶"链表继续装。这个设计简单可靠,但有两个老毛病:溢出链一长,查找就退化成链表遍历;而且桶数组一旦扩容,旧数据要靠"渐进式搬迁"(每次写操作顺手搬一点)慢慢挪到新桶里,map 内部同时存在新旧两套桶,逻辑复杂,还占内存。
Go 1.24 把内置 map 换成了 Abseil Swiss Table 那一套思路的本土实现,搬进了新目录 internal/runtime/maps。核心变化是三个:
- 每组(group)8 个槽位配一个 8 字节的控制字,状态查询可以对整个控制字做一次位运算,不用逐个槽位比较;
- 哈希值拆成 H1(决定去哪个分组探测)和 H2(存进控制字节当"指纹",大概率不用真的比较 key 就能排除错误候选);
- 不再有"渐进式搬迁"这种半新半旧的中间状态——map 满了就把这一个 table 干净利落地一分为二,配合一个可以整体翻倍的目录(有点像数据库里的可扩展哈希),搬迁范围永远只有"这一个 table",不会牵连整个 map。
02 · 三个核心概念
分组 / 哈希拆分 / 目录
8 个键值槽位 + 1 个控制字节数组。每个控制字节是三种状态之一:空(1000 0000)、已删除(1111 1110)、占用(0 + 7 位 H2)。查找时把控制字整体和 H2 做匹配,一次搞定 8 个槽位的初筛。
H1 是哈希值去掉低 7 位后的部分,决定从哪个分组开始探测;H2 是低 7 位,存进控制字节当"指纹"。只有指纹对上了,才会真的去比较 key 本身——多数不匹配的槽位在这一步就被过滤掉了。
特别小的 map(≤7 个元素)甚至没有目录,直接是一个裸分组。长大之后,目录数组的每一项指向一个 table;table 满了就分裂成两个,必要时目录整体翻倍——这就是"可扩展哈希"。
// Each slot in the hash table has a control byte which can have one of three
// states: empty, deleted, and full. They have the following bit patterns:
//
// empty: 1 0 0 0 0 0 0 0
// deleted: 1 1 1 1 1 1 1 0
// full: 0 h h h h h h h // h represents the H2 hash bits
//
// TODO(prattmic): Consider inverting the top bit so that the zero value is empty.
type ctrl uint8
type groupReference struct {
// data points to the group, which is described by typ.Group and has
// layout depending on GOEXPERIMENT=mapsplitgroup:
//
// With mapsplitgroup (split arrays):
// type group struct {
// ctrls ctrlGroup
// keys [abi.MapGroupSlots]typ.Key
// elems [abi.MapGroupSlots]typ.Elem
// }
//
// Without (interleaved slots):
// type group struct {
// ctrls ctrlGroup
// slots [abi.MapGroupSlots]struct {
// key typ.Key
// elem typ.Elem
// }
// }
//
// In both cases, key(i) and elem(i) use the same formula via
// typ.KeysOff/KeyStride and typ.ElemsOff/ElemStride.
data unsafe.Pointer // data *typ.Group
}
控制字节和键值槽位是紧挨着存的同一块内存(ctrls + keys + elems),不是三个分开分配的数组——这样一个分组只需要一次内存访问就能把控制字节全部读进 CPU 缓存行,做位运算判断哪些槽位"看起来像"要找的 key。
// Extracts the H1 portion of a hash: the 57 upper bits.
// TODO(prattmic): what about 32-bit systems?
func h1(h uintptr) uintptr {
return h >> 7
}
// Extracts the H2 portion of a hash: the 7 bits not used for h1.
//
// These are used as an occupied control byte.
func h2(h uintptr) uintptr {
return h & 0x7f
}
type Map struct {
// The number of filled slots (i.e. the number of elements in all
// tables). Excludes deleted slots.
// Must be first (known by the compiler, for len() builtin).
used uint64
// seed is the hash seed, computed as a unique random number per map.
seed uintptr
// The directory of tables.
//
// Normally dirPtr points to an array of table pointers
//
// dirPtr *[dirLen]*table
//
// The length (dirLen) of this array is `1 << globalDepth`. Multiple
// entries may point to the same table. See top-level comment for more
// details.
//
// Small map optimization: if the map always contained
// abi.MapGroupSlots or fewer entries, it fits entirely in a
// single group. In that case dirPtr points directly to a single group.
//
// dirPtr *group
//
// In this case, dirLen is 0. used counts the number of used slots in
// the group. Note that small maps never have deleted slots (as there
// is no probe sequence to maintain).
dirPtr unsafe.Pointer
dirLen int
// The number of bits to use in table directory lookups.
globalDepth uint8
// The number of bits to shift out of the hash for directory lookups.
// On 64-bit systems, this is 64 - globalDepth.
globalShift uint8
// writing is a flag that is toggled (XOR 1) while the map is being
// written. Normally it is set to 1 when writing, but if there are
// multiple concurrent writers, then toggling increases the probability
// that both sides will detect the race.
writing uint8
// tombstonePossible is false if we know that no table in this map
// contains a tombstone.
tombstonePossible bool
// clearSeq is a sequence counter of calls to Clear. It is used to
// detect map clears during iteration.
clearSeq uint64
}
注意 dirPtr 那段注释里的 small map optimization:如果一个 map 从头到尾都不超过 8 个元素,dirLen 永远是 0,dirPtr 直接指向唯一的那个分组,连目录这层间接寻址都省了。这也是为什么下面的模拟器一开始不会画出"目录"这一行——真的没有。
03 · 现场直播
插入 15 个 key,亲眼看着它长大
下面是一段真实模拟并逐条校验过的轨迹:依次插入 15 个编程语言名字作为 key。规则严格照抄上面(以及后面会引用)的源码:分组、H1/H2、探测序列、小 map 优化、目录翻倍、墓碑删除全部真实实现,我写了一个独立脚本,验证了插入结束后每个 key 都能通过模拟的 Get 正确查到,并且和一个朴素的 Python dict 逐项比对完全一致。唯一不真实的地方是哈希函数本身——用的是一个固定种子的 FNV-1a,不是 Go 运行时真正用的、每个进程随机加盐的硬件加速哈希,细节见文末说明。
04 · 一次查找是怎么发生的
探测序列(probe sequence)
模拟器里几乎每一步都在做同一件事:算出 H1,从它对应的分组开始,沿着一条固定的"探测序列"逐组检查,直到找到 key 或者碰到一个真正的空槽位为止。这条序列不是随便走的,是二次探测(quadratic probing),保证只要分组数是 2 的幂,就一定会不重不漏地走遍每一个分组:
// probeSeq maintains the state for a probe sequence that iterates through the
// groups in a table. The sequence is a triangular progression of the form
// hash, hash + 1, hash + 1 + 2, hash + 1 + 2 + 3, ..., modulo mask + 1.
// The i-th term of the sequence is
//
// p(i) := hash + (i^2 + i)/2 (mod mask+1)
//
// The sequence effectively outputs the indexes of *groups*. The group
// machinery allows us to check an entire group with minimal branching.
//
// It turns out that this probe sequence visits every group exactly once if
// the number of groups is a power of two, since (i^2+i)/2 is a bijection in
// Z/(2^m). See https://en.wikipedia.org/wiki/Quadratic_probing
type probeSeq struct {
mask uint64
offset uint64
index uint64
}
func makeProbeSeq(hash uintptr, mask uint64) probeSeq {
return probeSeq{
mask: mask,
offset: uint64(hash) & mask,
index: 0,
}
}
func (s probeSeq) next() probeSeq {
s.index++
s.offset = (s.offset + s.index) & s.mask
return s
}
func (t *table) getWithKey(typ *abi.MapType, hash uintptr, key unsafe.Pointer) (unsafe.Pointer, unsafe.Pointer, bool) {
// To find the location of a key in the table, we compute hash(key). From
// h1(hash(key)) and the capacity, we construct a probeSeq that visits
// every group of slots in some interesting order. See [probeSeq].
//
// We walk through these indices. At each index, we select the entire
// group starting with that index and extract potential candidates:
// occupied slots with a control byte equal to h2(hash(key)). The key
// at candidate slot i is compared with key; if key == g.slot(i).key
// we are done and return the slot; if there is an empty slot in the
// group, we stop and return an error; otherwise we continue to the
// next probe index. Tombstones (ctrlDeleted) effectively behave like
// full slots that never match the value we're looking for.
//
// The h2 bits ensure when we compare a key we are likely to have
// actually found the object. That is, the chance is low that keys
// compare false. Thus, when we search for an object, we are unlikely
// to call Equal many times. This likelihood can be analyzed as follows
// (assuming that h2 is a random enough hash function).
//
// Let's assume that there are k "wrong" objects that must be examined
// in a probe sequence. For example, when doing a find on an object
// that is in the table, k is the number of objects between the start
// of the probe sequence and the final found object (not including the
// final found object). The expected number of objects with an h2 match
// is then k/128. Measurements and analysis indicate that even at high
// load factors, k is less than 32, meaning that the number of false
// positive comparisons we must perform is less than 1/8 per find.
seq := makeProbeSeq(h1(hash), t.groups.lengthMask)
h2Hash := h2(hash)
for ; ; seq = seq.next() {
g := t.groups.group(typ, seq.offset)
match := g.ctrls().matchH2(h2Hash)
for match != 0 {
i := match.first()
slotKey := g.key(typ, i)
if typ.IndirectKey() {
slotKey = *((*unsafe.Pointer)(slotKey))
}
if typ.Key.Equal(key, slotKey) {
slotElem := g.elem(typ, i)
if typ.IndirectElem() {
slotElem = *((*unsafe.Pointer)(slotElem))
}
return slotKey, slotElem, true
}
match = match.removeFirst()
}
match = g.ctrls().matchEmpty()
if match != 0 {
// Finding an empty slot means we've reached the end of
// the probe sequence.
return nil, nil, false
}
}
这段注释里的概率分析很有意思:假设一次查找路上要跳过 k 个"不是它"的槽位,由于 H2 只有 7 位(128 种取值),期望只有 k/128 个槽位会因为指纹碰巧相同而被误判成"像是它",需要真的调用 Equal 比较一次。也就是说,H2 指纹几乎把"逐个比较 key"这件事完全挡在了外面。
05 · 从裸分组到 table
小 map 优化,以及并发写检测
模拟器插入到第 8 个 key(kotlin)时,单独的裸分组已经放满了 7 个(留 1 个空槽位保证探测能终止),这次插入直接触发"升级":新建一个有目录的正式 table,把原来 7 个 key 重新哈希搬进去,再放入第 8 个。这条路径,连同 Go map 一个经典的运行时保护——并发写检测——都在 PutSlot 里:
func (m *Map) PutSlot(typ *abi.MapType, key unsafe.Pointer) unsafe.Pointer {
if m.writing != 0 {
fatal("concurrent map writes")
}
hash := typ.Hasher(key, m.seed)
// Set writing after calling Hasher, since Hasher may panic, in which
// case we have not actually done a write.
m.writing ^= 1 // toggle, see comment on writing
if m.dirPtr == nil {
m.growToSmall(typ)
}
if m.dirLen == 0 {
elem := m.putSlotSmall(typ, hash, key)
if elem == nil {
// Can't fit another entry, grow to full size map.
tab := m.growToTable(typ)
elem = tab.uncheckedPutSlotForAssign(typ, hash, key)
m.used++
tab.checkInvariants(typ, m)
}
if m.writing == 0 {
fatal("concurrent map writes")
}
m.writing ^= 1
return elem
}
for {
idx := m.directoryIndex(hash)
elem, ok := m.directoryAt(idx).PutSlot(typ, m, hash, key)
if !ok {
continue
}
if m.writing == 0 {
fatal("concurrent map writes")
}
m.writing ^= 1
return elem
}
}
m.writing ^= 1 这个异或翻转,就是 fatal("concurrent map writes") panic 的来源:写入开始时翻成 1,写入结束时再翻回 0;如果两个 goroutine 同时写同一个 map,大概率会有一次在对方还没翻回去之前就闯进来,检测到"应该是 0 却发现已经是 1(或者反过来)",直接 fatal 崩溃整个进程——这是故意设计成不可恢复的崩溃而不是 panic/recover,因为一旦发生数据竞争,map 内部状态已经不可信了。
06 · table 满了怎么办
分裂,而不是整体搬家
继续插入到第 15 个 key(zig)时,table 的 growthLeft 归零,触发分裂:按"比当前多用一位"的哈希位,把原 table 里的所有条目分流成两个新 table,原来指向旧 table 的目录项也跟着更新。如果目录已经没有多余的位可用(localDepth == globalDepth),就把整个目录翻倍,给新长出来的 table 腾位置:
func (m *Map) installTableSplit(old, left, right *table) {
if old.localDepth == m.globalDepth {
// No room for another level in the directory. Grow the
// directory.
newDir := make([]*table, m.dirLen*2)
for i := range m.dirLen {
t := m.directoryAt(uintptr(i))
newDir[2*i] = t
newDir[2*i+1] = t
// t may already exist in multiple indices. We should
// only update t.index once. Since the index must
// increase, seeing the original index means this must
// be the first time we've encountered this table.
if t.index == i {
t.index = 2 * i
}
}
m.globalDepth++
m.globalShift--
//m.directory = newDir
m.dirPtr = unsafe.Pointer(&newDir[0])
m.dirLen = len(newDir)
}
// N.B. left and right may still consume multiple indices if the
// directory has grown multiple times since old was last split.
left.index = old.index
m.replaceTable(left)
entries := 1 << (m.globalDepth - left.localDepth)
right.index = left.index + entries
m.replaceTable(right)
}
关键在于:分裂只发生在那一个装满的 table 身上,不会波及目录里其它已经安顿好的 table。这就是"可扩展哈希"这个名字的由来——目录会长大,但已经存在的数据不会被无谓地重新搬动。
07 · 删除为什么要留个"墓碑"
tombstone
模拟器里删除一个 key 时,如果它所在的分组还有空槽位,会直接把控制字节标成"空";但如果这个分组当时已经全满,直接标成空会切断经过它的探测链——后面的某个查找可能正是靠"这个分组没有空位,继续往下一个分组找"才能找到真正的目标,一旦提前看到空位就会误判成"没找到"。所以这种情况下会留一个特殊状态:墓碑(deleted),它在探测时表现得像"占用"(继续往下找),但在插入时可以被覆盖复用:
// Only a full group can appear in the middle
// of a probe sequence (a group with at least
// one empty slot terminates probing). Once a
// group becomes full, it stays full until
// rehashing/resizing. So if the group isn't
// full now, we can simply remove the element.
// Otherwise, we create a tombstone to mark the
// slot as deleted.
var tombstone bool
if g.ctrls().matchEmpty() != 0 {
g.ctrls().set(i, ctrlEmpty)
t.growthLeft++
} else {
g.ctrls().set(i, ctrlDeleted)
tombstone = true
}
t.checkInvariants(typ, m)
return tombstone
这次模拟里没有真的踩到墓碑分支:删除的 key 所在分组当时还有空位,所以直接标成了空,你在模拟器里会看到 tombstone: false。上面这段源码就是那个"分组已满"分支的真实逻辑——原理相同,只是这次插入的 15 个 key 不巧没有把某个分组正好填满就删除。
08 · 三个经典问题
顺手回答几个 FAQ
- 为什么不能对 map 的元素取地址(
&m[k]编译不过)? 因为一次分裂或者从小 map 升级到 table,都会把键值对重新哈希搬到新内存位置——如果允许你拿着旧地址,分裂一发生这个指针就悬空了。禁止取地址是编译期就杜绝这类隐患。 - 为什么每次遍历 map,输出顺序都不一样? 不是错觉,是故意的:迭代器从一个随机槽位偏移开始扫描,不从头开始:
// Randomize iteration order by starting iteration at a random slot
// offset. The offset into the directory uses a separate offset, as it
// must adjust when the directory grows.
entryOffset uint64
dirOffset uint64
- 早年的 Go 甚至没有明说这是故意的,直到开发者发现有代码依赖"遍历顺序恰好稳定"这种未定义行为——于是干脆用随机起点把这种依赖直接打破,逼你不要依赖它。
- 为什么并发读写 map 会直接崩溃退出,而不是 panic 后能 recover? 见上面第 05 节的
writing标志位——数据竞争发生后 map 内部状态已经不可信,继续运行风险更大,所以用不可恢复的fatal直接终止进程,而不是可以被recover掉的普通 panic。
09 · 参考与说明
引用与这个模拟器做了哪些简化
- 本文所有源码引用均来自 golang/go,commit
72aa6db7943024b48c4d41c1fbc32b57b9fa036e,路径src/internal/runtime/maps/与src/runtime/map.go,遵循 Go 项目的 BSD-3-Clause 协议(© The Go Authors)。每段引用都标了具体文件、行号,并附了指向该 commit 的永久链接。 - Go 官方博客:Faster Go maps with Swiss Tables——Go 1.24 这次切换的官方说明。
- Abseil:Swiss Tables design notes——这套设计最初的出处。
- 模拟器用固定种子的 FNV-1a 代替了 Go 运行时真正的、每进程随机加盐且用了 AES 指令集加速的哈希函数——算法结构(H1/H2 拆分、探测、分组)完全照抄真实实现,只有"哈希值具体是多少"这件事是假的。
- 真实的
table.split会按更复杂的规则选择新 table 的容量(maxTableCapacity相关逻辑),这里为了演示简化成"新 table 和原 table 容量相同"。 - 模拟器把"探测一个分组"当作一步来展示,没有进一步拆到"控制字里 8 个字节逐个比较"这一级——真实硬件上这是一条 SIMD 指令,拆到那一级对理解算法没有额外帮助。