停车场哈希表
主线位置:主线 3 的全部。线程第一次停车 →
ThreadData::new把NUM_THREADS加一 →grow_hashtable:桶数不到线程数的 3 倍,就锁住旧表的全部桶、建新表、逐桶搬家、发布新表、放开旧表的桶锁 → 之后所有用桶的路径都是「取表 → 锁桶 → 确认表没换」。旧表永不释放,线程退出只减计数,表不缩。
flowchart TB
first["线程第一次 park:with_thread_data"] --> tdnew["ThreadData::new:NUM_THREADS += 1"]
tdnew --> grow{"桶数 ≥ 3 × NUM_THREADS?"}
grow -->|"是"| done["直接返回"]
grow -->|"否"| lockall["锁住旧表全部桶,确认 HASHTABLE 仍指向它"]
lockall --> build["新表:(3 × 线程数).next_power_of_two() 个桶"]
build --> rehash["逐桶 rehash_bucket_into"]
rehash --> publish["HASHTABLE.store(新表, Release)"]
publish --> unlockall["放开旧表的桶锁;旧表留在内存里"]
exit["线程退出:ThreadData 析构"] --> dec["NUM_THREADS -= 1,表不缩"]
两个类型
static NUM_THREADS: AtomicUsize = AtomicUsize::new(0);
/// Holds the pointer to the currently active `HashTable`.
///
/// # Safety
///
/// Except for the initial value of null, it must always point to a valid `HashTable` instance.
/// Any `HashTable` this global static has ever pointed to must never be freed.
static HASHTABLE: AtomicPtr<HashTable> = AtomicPtr::new(ptr::null_mut());
// Even with 3x more buckets than threads, the memory overhead per thread is
// still only a few hundred bytes per thread.
const LOAD_FACTOR: usize = 3;
struct HashTable {
// Hash buckets for the table
entries: Box<[Bucket]>,
// Number of bits used for the hash function
hash_bits: u32,
// Previous table. This is only kept to keep leak detectors happy.
_prev: *const HashTable,
}
#[repr(align(64))]
struct Bucket {
// Lock protecting the queue
mutex: WordLock,
// Linked list of threads waiting on this bucket
queue_head: Cell<*const ThreadData>,
queue_tail: Cell<*const ThreadData>,
// Next time at which point be_fair should be set
fair_timeout: UnsafeCell<FairTimeout>,
}
- 全进程一张表,由
HASHTABLE指着。它的安全性注释写了两条不变量:除了初始的 null,永远指向有效的表;它指过的每一张表都永不释放。下面所有「锁后确认」的写法都靠第二条:拿到一个旧表的引用,哪怕它已经不是当前表,读它也不会访问已释放的内存。 _prev把新表和旧表串起来,注释说只是为了「让泄漏检测器安静」——旧表仍然可达,就不会被报成泄漏。- 每个桶:一把
WordLock(下一章)、一条单链表(头尾指针)、一个FairTimeout(eventual fairness)。链表里的线程 key 各不相同,所有查找都是从头线性扫描;表比线程多 3 倍,所以链表平均很短。 #[repr(align(64))]:每个桶独占 64 字节(E7 里 16 个桶的表正好分配 1024 字节)。本机 M2 Max 的缓存行是 128 字节(crossbeam 卷 CachePadded),所以相邻两个桶共用一条缓存行。是否因此产生可测的干扰,见本页最后的 E14。
LOAD_FACTOR = 3 的注释说「即使桶数是线程数的 3 倍,每线程的内存开销也只有几百字节」:3 个桶 × 64 字节 = 192 字节。
初始表与哈希函数
第一张表由 create_hashtable 懒建:HashTable::new(LOAD_FACTOR, null),也就是按 3 个线程建,(3 × 3).next_power_of_two() = 16 个桶。几个线程同时第一次停车时,每个都可能建一张,用 compare_exchange(null → 新表) 决出胜者,输家释放自己那张(这是全模块唯一一处释放表的代码,因为它从未被发布过)。E14 预热时 8 个线程同时第一次停车,计数分配器两次运行分别记到 4 个和 2 个 64 字节对齐的块([1024, 1024, 1024, 2048] 与 [1024, 2048]),多出来的 1024 字节块就是输掉竞争的 16 桶表。
哈希函数(parking_lot.rs:343-353):
#[cfg(target_pointer_width = "64")]
#[inline]
fn hash(key: usize, bits: u32) -> usize {
key.wrapping_mul(0x9E3779B97F4A7C15) >> (64 - bits)
}
乘以 2⁶⁴ 除以黄金比例,取高 hash_bits 位,即斐波那契哈希(32 位平台用 0x9E3779B9)。锁的地址低几位往往相同(对齐),乘法把各位的差异扩散到高位,取高位就能均匀分布。hash_bits 是 log2(桶数),桶数总是 2 的幂。
ThreadData:什么时候创建
每个停过车的线程有一个 ThreadData(parking_lot.rs:151-208),字段是:本线程的 ThreadParker、当前在等的 key(AtomicUsize,因为 requeue 会改它)、链表的 next_in_queue、两个 token、parked_with_timeout,开了死锁检测时还有 deadlock_data。
impl ThreadData {
fn new() -> ThreadData {
// Keep track of the total number of live ThreadData objects and resize
// the hash table accordingly.
let num_threads = NUM_THREADS.fetch_add(1, Ordering::Relaxed) + 1;
grow_hashtable(num_threads);
// ...
}
}
// Invokes the given closure with a reference to the current thread `ThreadData`.
#[inline(always)]
fn with_thread_data<T>(f: impl FnOnce(&ThreadData) -> T) -> T {
// Unlike word_lock::ThreadData, parking_lot::ThreadData is always expensive
// to construct. Try to use a thread-local version if possible. Otherwise just
// create a ThreadData on the stack
let mut thread_data_storage = None;
thread_local!(static THREAD_DATA: ThreadData = ThreadData::new());
let thread_data_ptr = THREAD_DATA
.try_with(|x| x as *const ThreadData)
.unwrap_or_else(|_| thread_data_storage.get_or_insert_with(ThreadData::new));
f(unsafe { &*thread_data_ptr })
}
ThreadData放在线程局部存储里,第一次调with_thread_data时才创建。不开死锁检测时,只有park和死锁检测相关函数会调它,所以「创建ThreadData」等价于「第一次停车」;一个从没在锁上睡过的线程不计入NUM_THREADS,也不会让表变大。开了deadlock_detection,每次加锁的acquire_resource也会调它,于是每个加过锁的线程都计入(E7 两组的差别)。- 线程局部变量已经析构时(线程退出过程中,别的 TLS 析构函数里又加锁),
try_with失败,就在栈上临时建一个,用完即弃。tests/issue_203.rs就是这个场景:TLS 析构函数里对一个RwLock加锁。 Drop只做一件事:NUM_THREADS -= 1(第 210–214 行)。表不缩。
grow_hashtable:锁住所有桶再换表
fn grow_hashtable(num_threads: usize) {
// Lock all buckets in the existing table and get a reference to it
let old_table = loop {
let table = get_hashtable();
// Check if we need to resize the existing table
if table.entries.len() >= LOAD_FACTOR * num_threads {
return;
}
// Lock all buckets in the old table
for bucket in &table.entries[..] {
bucket.mutex.lock();
}
// Now check if our table is still the latest one. Another thread could
// have grown the hash table between us reading HASHTABLE and locking
// the buckets.
if HASHTABLE.load(Ordering::Relaxed) == table as *const _ as *mut _ {
break table;
}
// Unlock buckets and try again
for bucket in &table.entries[..] {
// SAFETY: We hold the lock here, as required
unsafe { bucket.mutex.unlock() };
}
};
// Create the new table
let mut new_table = HashTable::new(num_threads, old_table);
// Move the entries from the old table to the new one
for bucket in &old_table.entries[..] {
// ...
unsafe { rehash_bucket_into(bucket, &mut new_table) };
}
// Publish the new table. No races are possible at this point because
// any other thread trying to grow the hash table is blocked on the bucket
// locks in the old table.
HASHTABLE.store(Box::into_raw(new_table), Ordering::Release);
// Unlock all buckets in the old table
for bucket in &old_table.entries[..] {
// SAFETY: We hold the lock here, as required
unsafe { bucket.mutex.unlock() };
}
}
几个要点:
- 条件是
桶数 ≥ 3 × 线程数就不扩。 新表大小是(线程数 × 3).next_power_of_two()。从 16 个桶开始:第 6 个线程时 18 > 16,扩到 32;第 11 个 33 > 32,扩到 64;第 22 个 66 > 64,扩到 128;第 43 个 129 > 128,扩到 256。 - 锁住旧表的全部桶。 这一刻没有任何线程能在这张表上 park 或 unpark,链表是静止的,可以安全搬家。搬家(
rehash_bucket_into)按新表的hash_bits重新计算每个线程的桶,接到新桶的链表尾,保持原来的相对顺序。新表的桶不需要加锁:在发布之前,别人看不到它。 - 锁完所有桶再确认
HASHTABLE没变。 两个线程可能同时决定扩容;后锁完的那个会发现表已经换了,放锁重来,重来时看到新表已经够大,直接返回。 - 扩容只发生在「一个线程第一次停车」时,每个线程一次。注释说它「不在性能关键路径上」。但它要逐个锁住全部桶,期间所有停车、叫醒都要等它;线程很多、频繁新建线程的程序会反复碰到这一下(推测;本卷没有测扩容本身的耗时)。
三种 lock_bucket:锁后确认表没换
所有用桶的路径都是同一个模式。最简单的 lock_bucket:
fn lock_bucket(key: usize) -> &'static Bucket {
loop {
let hashtable = get_hashtable();
let hash = hash(key, hashtable.hash_bits);
let bucket = &hashtable.entries[hash];
// Lock the bucket
bucket.mutex.lock();
// If no other thread has rehashed the table before we grabbed the lock
// then we are good to go! The lock we grabbed prevents any rehashes.
if HASHTABLE.load(Ordering::Relaxed) == hashtable as *const _ as *mut _ {
return bucket;
}
// Unlock the bucket and try again
// SAFETY: We hold the lock here, as required
unsafe { bucket.mutex.unlock() };
}
}
锁后那次 load 为什么可以是 Relaxed:扩容方先 store(新表, Release),再逐个解锁旧表的桶(WordLock::unlock 是 Release);我们锁桶是 Acquire。如果我们是在扩容方之后锁到这个旧桶的,那么「发布新表」先于「扩容方解锁这个桶」先于「我们锁上这个桶」,我们之后的读一定看得到新指针,于是放锁重来;如果我们先锁到这个桶,扩容方就会卡在这个桶上,在我们解锁之前不可能发布新表。所以不需要更强的内存序,桶锁本身提供了先后关系。开头的 get_hashtable 用 Acquire 读,是为了能安全地读新表里的内容(entries、hash_bits)。
另外两个变体:
| 函数 | 多做了什么 | 谁用 |
|---|---|---|
lock_bucket_checked(&key) | 锁后除了确认表没换,还确认线程的 key 没被改掉:requeue 会在别人的桶锁里改 key,所以要「锁上 key 所在的桶之后 key 仍等于锁之前读的值」才算锁对了桶;注释说「key 在锁住正确的桶之后就不会再变」 | park 的超时路径:醒来时可能已经被 requeue 到别的 key 上 |
lock_bucket_pair(k1, k2) | 两个 key 的桶都锁上,先锁下标小的;只在第一把锁后确认一次表(拿着一把桶锁,表就不可能被换);两个 key 落在同一个桶时只锁一次,解锁用 unlock_bucket_pair 避免解两次 | unpark_requeue(Condvar) |
「先锁下标小的」是固定的加锁顺序:两个同时进行的 requeue(比如 condvar X → mutex Y 和 condvar Z → mutex W 落在交叉的桶上)不会互相等死。grow_hashtable 和死锁检测也都按下标从 0 到 n 的顺序锁全部桶,和这个顺序一致。
旧表永不释放、表不缩
这两条是有意的设计,代价也直接:
- 旧表一直占着内存。 从 16 扩到 256 个桶,16 + 32 + 64 + 128 个桶的旧表(共 240 × 64 = 15 KiB)都留着。总量有上界:每次翻倍,旧表加起来不超过当前表的大小。
- 表只增不减。 一个程序曾经有过 43 个线程同时存在过并停过车,之后即使只剩 2 个线程,表也保持 256 个桶。对查找没有坏处(链表更短),只是占内存。
换来的是:任何线程拿着任何一张表的引用都永远安全,lock_bucket 不需要引用计数或 epoch 回收;对比 crossbeam 卷的 epoch 为了释放旧缓冲区付出的复杂度,这里选择了「不释放」。
实验 E7:扩容点
断言:表的桶数 = (3 × 停车过的线程数).next_power_of_two(),只增不减;不开 deadlock_detection 时只计入停过车的线程,开了则加过锁的线程都计入。
方法(exp-parking_lot/hashtable-grow):自定义全局分配器,记录所有「64 字节对齐、大小是 64 的倍数」的分配(Bucket 是 align(64),程序里只有哈希表会这样分配),也记录这类块的释放次数。主线程持着一把 Mutex,逐个起子线程去加这把锁,每起一个等 5 ms 让它停好车,再看分配记录。全部子线程退出后,原样再跑一轮。功能性实验,不计时。
结果(3 次运行输出一致):
| 配置 | 第一张表 | 32 桶 | 64 桶 | 128 桶 | 256 桶 | 第二轮新分配 | 表的释放次数 |
|---|---|---|---|---|---|---|---|
不开 deadlock_detection | 第 1 个子线程停车时 | 第 6 个 | 第 11 个 | 第 22 个 | 第 43 个 | 0 | 0 |
开 deadlock_detection | 主线程加锁时 | 第 5 个 | 第 10 个 | 第 21 个 | 第 42 个 | 0 | 0 |
deadlock_detection = false
round 1: after 1 child threads: tables (buckets) = [16]
round 1: after 6 child threads: tables (buckets) = [16, 32]
round 1: after 11 child threads: tables (buckets) = [16, 32, 64]
round 1: after 22 child threads: tables (buckets) = [16, 32, 64, 128]
round 1: after 43 child threads: tables (buckets) = [16, 32, 64, 128, 256]
after round 1: tables = [16, 32, 64, 128, 256], table frees = 0
after round 2: tables = [16, 32, 64, 128, 256], table frees = 0
- 不开 feature 时,主线程只加锁、从没停车,不计入;扩容点正好是 6、11、22、43 个线程,与上面推出的条件一致。
- 开了 feature,主线程加锁时就创建了
ThreadData(第一张表在任何子线程之前出现),所以每个扩容点都提前一个子线程:5 + 1、10 + 1、21 + 1、42 + 1。 - 第二轮:子线程全部退出后
NUM_THREADS回落,再起同样多的线程,计数回到同样的值,不需要更大的表,没有任何新分配;表的释放次数始终为 0。
反向验证:子线程只起不加锁(nopark)。不开 feature 时一张表都没有——主线程加锁、解锁都走快路径,从没进过停车场,HASHTABLE 一直是 null;开了 feature 时只有主线程那一张 16 桶的表。这说明分配记录确实只看到了哈希表,而建表的触发条件就是「创建 ThreadData」。
实验 E14:相邻桶共用缓存行(已删)
规划时的推测:相邻两个桶共用一条 128 字节缓存行,两把各自高竞争的锁如果落在相邻的桶上,桶锁的写可能互相干扰。
方法(exp-parking_lot/bucket-sharing):8 个线程预热停一次车,让表固定在 32 个桶;从分配器拿到表的起始地址(这次 base % 128 = 0,所以桶 2k 与 2k+1 共用一行),按 hash 公式离线挑出三种摆放的第二把锁:与第一把同一个桶(干扰的上限对照)、相邻桶且同一缓存行、相距 4 行以上;两把锁本身的地址至少相隔 1 KiB。两组各 4 个线程分别对自己的锁做「加锁、持锁 100 ns、解锁、锁外 100 ns」,每种摆放 1 秒、交替跑 3 轮。2026-11-01,负载 1 分钟 3.39、5 分钟 3.09(由前面的计时实验抬高)。
| 摆放 | 三轮(百万次 / 秒,两把锁合计) | 中位数 |
|---|---|---|
| 同一个桶 | 5.258、5.245、5.398 | 5.258 |
| 相邻桶、同一缓存行 | 5.348、5.204、5.201 | 5.204 |
| 相距很远 | 5.301、5.317、5.319 | 5.317 |
三者相差在 2% 以内,组内波动和组间差别同量级;持锁、锁外都为 0 的一组波动更大(79–93 百万次 / 秒),排序也不一致。关键是上限对照也分不出来:连两把锁共用同一把桶锁、同一条链表都看不出差别,这个方法就不可能看出「只是共用一条缓存行」的影响。原因可以从代码推出来:只有慢路径才碰桶,快路径的 CAS 只碰锁自己的那个字节。按计划删掉这个实验,结论只留事实:桶是 64 字节对齐,本机上两个桶共用一条缓存行;本卷没有测出它带来的干扰。
小结
- 全进程一张哈希表,
Bucket是align(64):一把WordLock、一条单链表、一个公平计时器。第一张表 16 个桶,懒建,多个线程同时建时输家释放自己那张。 - 斐波那契哈希:乘
0x9E3779B97F4A7C15取高位。 ThreadData在第一次停车时创建(开了死锁检测则第一次加锁时),NUM_THREADS只算这些线程;TLS 已析构时退回栈上临时建。grow_hashtable:桶数不到线程数 3 倍时锁住旧表全部桶、搬家、发布;E7 实测扩容点 6、11、22、43。- 所有用桶的路径都是「取表 → 锁桶 → 确认表没换」,确认可以用
Relaxed,因为桶锁本身给出了先后;lock_bucket_pair先锁下标小的桶。 - 旧表永不释放、表不缩,换来「任何表引用永远有效」,不需要回收机制。