在带 GIL 的 CPython 中,dict 常被当作“隐式线程安全”:GIL 保证同一时刻只有一个线程执行字节码,val = d[key] 或 d[key] = val 这类单指令操作不会踩坏底层 C 结构。很多多线程代码因此直接共享全局字典。但 Python 3.13 的自由线程构建(Free-threaded Build,PEP 703 nogil)移除 GIL 后,多个物理核心上的线程同时增删改查同一个 dict 时,若底层仍沿用经典实现,哈希表动态扩容重哈希的瞬间,其他读线程可能访问已释放内存(Use-After-Free),进程会以 SIGSEGV 崩溃。CPython 3.13 nogil 对 PyDictObject 的并发架构做了重构,目标是在无 GIL 下同时保证内存安全,并避免单线程和读密集场景被全局互斥锁拖慢。
一、源码层并发设计
传统 CPython dict 由紧凑索引数组(Indices)和条目数组(Entries)构成。自由线程模式下,CPython 引入混合并发控制:
1. 乐观无锁读与原子指针加载
读多写少是常见模式,如果每次 d.get(k) 都申请互斥锁,多核扩展性会被破坏。在 Objects/dictobject.c 中,字典条目的键和值读取改为基于 C11 的原子加载 atomic_load_explicit,使用 memory_order_acquire 内存序;读操作不加锁,直接访问底层哈希桶。读期间若发生写操作,只要哈希表未扩容,读线程读到的仍是某个合法时刻的旧值或新值,不会读到半初始化的悬空野指针。配合偏向引用计数(BRC)与延迟安全释放机制(Safe Memory Reclamation / Epoch-based GC),被覆盖或删除的旧对象不会立即物理释放,读线程可以安全完成解引用。
2. 条目级分段锁与写互斥
执行插入 d[k] = v 或删除 del d[k] 时,必须防止多个写入者破坏条目链表。CPython 没有在整个字典头挂一个重型 pthread_mutex_t,而是内嵌轻量自旋锁(Spinlock),或按哈希高位划分分段锁。写线程先原子抢占目标分段自旋锁,在临界区内完成条目槽位抢占、更新键值指针和递增元素计数器,然后释放。临界区只包含少量汇编指令,写锁持有时间被压到纳秒级。
3. 扩容重哈希的独占写屏障
最大难点是 Resize。负载因子超过 2/3 时,字典要重新分配数倍内存桶并搬迁历史元素。nogil 模式下,执行 Resize 的线程必须把字典升级为全局独占状态(Exclusive Write Lock),甚至结合内部安全点(Safe Points)暂停可能持有旧表引用的并发线程。因此超高频并发写并频繁触发扩容时,吞吐会出现暂态尖刺。
二、32 线程实测数据
测试环境为 32 物理核心、64 硬件线程服务器,覆盖四种模式:纯读(32 线程并发 1000 万次 d[key] 查询)、读多写少(95% 读、5% 写)、读写对半(50%/50%)、纯写竞争(32 线程向同一字典插入不相交新键)。原文实测结果如下:
- 纯读:Python 3.12(GIL 单核串行化)1,820 万 QPS;Python 3.13 nogil 原生无锁读字典 41,500 万 QPS;手动加全局 Lock 2,100 万 QPS;相对传统 GIL 加速比 22.8x。
- 读多写少:1,540 万 QPS;26,800 万 QPS;1,850 万 QPS;17.4x。
- 读写对半:1,210 万 QPS;7,850 万 QPS;1,420 万 QPS;6.5x。
- 纯写竞争:980 万 QPS;2,450 万 QPS;1,150 万 QPS;2.5x。
核心发现:纯读场景下无锁读实现接近多核线性扩展,41,500 万 QPS 即超过 4.1 亿 QPS,加速比 22.8 倍,说明基于原子加载和无锁遍历的实现消除了互斥锁在 CPU 缓存一致性总线上的颠簸,释放了 32 个物理核心的算力。纯写场景加速比回落到 2.5 倍,因为即使有分段锁隔离,高频更新 ma_used 计数器和频繁触发扩容重哈希,仍会让核心空转在自旋等待上。
三、分片并发字典实现
写竞争激烈的工业场景,即使运行在 Python 3.13 nogil 下,让所有线程疯狂写同一个全局字典也不是最佳选择。借鉴分布式系统和 Go sync.Map 的分片思想,可以把 Key 路由到多个独立子字典,把单表自旋锁争用打散。下面代码保留原文实现逻辑:分片数取 CPU 核心数 2 到 4 倍且为 2 的幂次方,用 mask 加速位运算;get 直接穿透到底层无锁读;put 只在局部分片竞争;compute_if_absent 用分片锁保证 mapping_func 只执行一次;size 汇总各分片元素数。
- import threading
- import time
- from typing import Any
- class ShardedConcurrentDict:
- '''
- 专为 Python 3.13 nogil 优化的超高并发分片哈希字典。
- 通过将 Key 路由到多个独立隔离的子字典,降低单表自旋锁争用。
- '''
- def __init__(self, num_shards: int = 64):
- # 分片数一般取 CPU 核心数的 2 到 4 倍,且为 2 的幂次方以加速位运算
- self.num_shards = num_shards
- self.mask = num_shards - 1
- # 每个分片维护完全独立的字典与独立的细粒度锁(用于复合事务)
- self.shards = [{} for _ in range(num_shards)]
- self.shard_locks = [threading.Lock() for _ in range(num_shards)]
- def _get_shard_index(self, key: Any) -> int:
- '''快速哈希路由,计算分片索引'''
- h = hash(key)
- return (h ^ (h >> 16)) & self.mask
- def get(self, key: Any, default: Any = None) -> Any:
- '''
- 无锁极速读取:直接穿透到 nogil 底层无锁读实现,不申请 Python 互斥锁
- '''
- shard_idx = self._get_shard_index(key)
- return self.shards[shard_idx].get(key, default)
- def put(self, key: Any, value: Any):
- '''
- 分片写入:仅在局部独立分片上竞争,各分片并发写入互不干扰
- '''
- shard_idx = self._get_shard_index(key)
- # 利用分片隔离,锁冲突概率被降低为原本的 1/num_shards
- self.shards[shard_idx][key] = value
- def compute_if_absent(self, key: Any, mapping_func) -> Any:
- '''
- 复合原子事务:保证 mapping_func 仅被执行一次
- '''
- shard_idx = self._get_shard_index(key)
- with self.shard_locks[shard_idx]:
- shard = self.shards[shard_idx]
- if key in shard:
- return shard[key]
- val = mapping_func(key)
- shard[key] = val
- return val
- def size(self) -> int:
- '''获取所有分片元素总和'''
- return sum(len(s) for s in self.shards)
- def benchmark_worker(cdict: ShardedConcurrentDict, thread_id: int, ops_per_thread: int):
- # 模拟写密集型操作:每个线程写入属于自己的键前缀
- prefix = f'worker_{thread_id}_'
- for i in range(ops_per_thread):
- k = f'{prefix}{i}'
- cdict.put(k, i)
- if __name__ == '__main__':
- NUM_THREADS = 16
- OPS_PER_THREAD = 100_000
- print('=== 初始化 Python 3.13 自由线程分片并发容器 ===')
- sharded_dict = ShardedConcurrentDict(num_shards=64)
- threads = []
- t0 = time.perf_counter()
- for tid in range(NUM_THREADS):
- t = threading.Thread(
- target=benchmark_worker,
- args=(sharded_dict, tid, OPS_PER_THREAD)
- )
- threads.append(t)
- t.start()
- for t in threads:
- t.join()
- total_elapsed = time.perf_counter() - t0
- total_ops = NUM_THREADS * OPS_PER_THREAD
- throughput = total_ops / total_elapsed
- print(f'并发写入总操作数: {total_ops:,} 次')
- print(f'总计物理耗时: {total_elapsed:.4f} 秒')
- print(f'实测高并发写吞吐量: {throughput:,.0f} Ops/sec')
- print(f'验证容器总元素数: {sharded_dict.size():,}')
复制代码
注意:上述 get 和 put 不额外加锁,依赖 Python 3.13 nogil 下单个 dict 操作的内存安全;compute_if_absent 需要分片锁,因为它是读-改-写复合操作。若在普通 GIL 构建或需要业务原子性的场景,不能把“内存安全”误当成“业务竞争条件已解决”。
四、生产开发建议
1. 单项读写不必手动加 Lock。Python 3.13 nogil 下直接执行 d[k] = v、v = d.get(k) 是内存安全的,底层原子指令不会引发 CPython 崩溃。盲目在每次读取前加全局 threading.Lock() 只会退回单线程性能。
2. 复合操作仍需显式同步。d[k] += 1 包含取值、加法、写回三步,没有外部同步时,两个并发线程仍可能状态覆盖导致计数少算。内存安全不等于业务原子性。
3. 超大规模高频写走分片哈希隔离。并发线程数突破 16 核且写操作占比超过 30% 时,单体字典的分段锁碰撞概率明显上升。用 ShardedConcurrentDict 把争用均摊到数十个独立桶,可让写吞吐再次数倍提升。
总结:Python 3.13 nogil 的 dict 不是简单“去掉 GIL”,而是通过原子加载、分段自旋锁、扩容写屏障和延迟安全释放维持底层内存安全;读密集场景收益最大,写密集场景仍要用分片隔离和复合操作锁来控制竞争。 |