查看: 430|回复: 3

Python 3.13无GIL字典线程安全源码拆解与分片并发实践

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在带 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 汇总各分片元素数。
  1. import threading
  2. import time
  3. from typing import Any
  4. class ShardedConcurrentDict:
  5.     '''
  6.     专为 Python 3.13 nogil 优化的超高并发分片哈希字典。
  7.     通过将 Key 路由到多个独立隔离的子字典,降低单表自旋锁争用。
  8.     '''
  9.     def __init__(self, num_shards: int = 64):
  10.         # 分片数一般取 CPU 核心数的 2 到 4 倍,且为 2 的幂次方以加速位运算
  11.         self.num_shards = num_shards
  12.         self.mask = num_shards - 1
  13.         # 每个分片维护完全独立的字典与独立的细粒度锁(用于复合事务)
  14.         self.shards = [{} for _ in range(num_shards)]
  15.         self.shard_locks = [threading.Lock() for _ in range(num_shards)]
  16.     def _get_shard_index(self, key: Any) -> int:
  17.         '''快速哈希路由,计算分片索引'''
  18.         h = hash(key)
  19.         return (h ^ (h >> 16)) & self.mask
  20.     def get(self, key: Any, default: Any = None) -> Any:
  21.         '''
  22.         无锁极速读取:直接穿透到 nogil 底层无锁读实现,不申请 Python 互斥锁
  23.         '''
  24.         shard_idx = self._get_shard_index(key)
  25.         return self.shards[shard_idx].get(key, default)
  26.     def put(self, key: Any, value: Any):
  27.         '''
  28.         分片写入:仅在局部独立分片上竞争,各分片并发写入互不干扰
  29.         '''
  30.         shard_idx = self._get_shard_index(key)
  31.         # 利用分片隔离,锁冲突概率被降低为原本的 1/num_shards
  32.         self.shards[shard_idx][key] = value
  33.     def compute_if_absent(self, key: Any, mapping_func) -> Any:
  34.         '''
  35.         复合原子事务:保证 mapping_func 仅被执行一次
  36.         '''
  37.         shard_idx = self._get_shard_index(key)
  38.         with self.shard_locks[shard_idx]:
  39.             shard = self.shards[shard_idx]
  40.             if key in shard:
  41.                 return shard[key]
  42.             val = mapping_func(key)
  43.             shard[key] = val
  44.             return val
  45.     def size(self) -> int:
  46.         '''获取所有分片元素总和'''
  47.         return sum(len(s) for s in self.shards)
  48. def benchmark_worker(cdict: ShardedConcurrentDict, thread_id: int, ops_per_thread: int):
  49.     # 模拟写密集型操作:每个线程写入属于自己的键前缀
  50.     prefix = f'worker_{thread_id}_'
  51.     for i in range(ops_per_thread):
  52.         k = f'{prefix}{i}'
  53.         cdict.put(k, i)
  54. if __name__ == '__main__':
  55.     NUM_THREADS = 16
  56.     OPS_PER_THREAD = 100_000
  57.     print('=== 初始化 Python 3.13 自由线程分片并发容器 ===')
  58.     sharded_dict = ShardedConcurrentDict(num_shards=64)
  59.     threads = []
  60.     t0 = time.perf_counter()
  61.     for tid in range(NUM_THREADS):
  62.         t = threading.Thread(
  63.             target=benchmark_worker,
  64.             args=(sharded_dict, tid, OPS_PER_THREAD)
  65.         )
  66.         threads.append(t)
  67.         t.start()
  68.     for t in threads:
  69.         t.join()
  70.     total_elapsed = time.perf_counter() - t0
  71.     total_ops = NUM_THREADS * OPS_PER_THREAD
  72.     throughput = total_ops / total_elapsed
  73.     print(f'并发写入总操作数: {total_ops:,} 次')
  74.     print(f'总计物理耗时: {total_elapsed:.4f} 秒')
  75.     print(f'实测高并发写吞吐量: {throughput:,.0f} Ops/sec')
  76.     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”,而是通过原子加载、分段自旋锁、扩容写屏障和延迟安全释放维持底层内存安全;读密集场景收益最大,写密集场景仍要用分片隔离和复合操作锁来控制竞争。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python 3.13无GIL字典线程安全源码拆解与分片并发实践

这篇拆解思路挺清晰的,把乐观无锁读、条目级分段锁、扩容独占写屏障这三层分开讲,读起来不费劲。实测数据也很有说服力:纯读 22.8x 基本贴近 32 物理核的上限,说明无锁遍历确实避开了缓存一致性总线上的颠簸;纯写只有 2.5x 也符合预期,ma_used 计数器和 Resize 这两处热点绕不开,频繁扩容时的暂态尖刺值得单独压测一下延迟分布,而不只是看吞吐。 分片字典这个方向没问题,不过有几个细节可以再补一补。第一,_get_shard_index 直接拿哈希值和高位异或后取低位,如果 key 的 __hash__ 分布不均匀,比如只返回少数几个值,热点会集中到单个分片,可以在路由前再做一次混合,或者用高位参与索引。第二,分片数取 2 的幂来加速位运算没问题,但 __init__ 最好校验一下 num_shards 是正数且为 2 的幂,否则 mask 路由会算错。第三,size 汇总各分片元素数只是个瞬时快照,并发增删下不具备强一致语义,文档里最好明确写清楚,免得使用者拿它做并发判断。第四,compute_if_absent 用分片锁保证 mapping_func 只执行一次,但要注意 threading.Lock 不可重入,如果 mapping_func 内部又回头操作同一个分片,会直接把线程锁死,最好约定 mapping_func 不要碰本实例,或者改用可重入锁并加超时保护。
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python 3.13无GIL字典线程安全源码拆解与分片并发实践

楼主这篇拆解挺扎实的,把 nogil 下 dict 的读路径和写路径分开讲,比单纯说“加锁”清楚多了。几点想法交流一下。 纯读那组数据我比较信,32 核跑到 4 亿多 QPS,折合每核一千多万次查询,和单核 Python dict.get 的量级对得上,说明无锁读确实把 GIL 串行化的那部分释放出来了。但读多写少 17.4x、读写对半 6.5x 更能说明问题:写操作一上来,分段锁和 ma_used 计数器就成了瓶颈,纯写 2.5x 也印证了这点。所以分片思路在写竞争场景下是必要的,不是可有可无的优化。 分片字典那段代码思路是对的,不过实现上有几个点可以再抠一下。size 如果每次都遍历所有分片求和,高频调用时会变成新的热点,最好每个分片维护一个计数,size 时再汇总,或者用原子变量。compute_if_absent 在分片锁里执行 mapping_func,要提醒使用者函数内部不要再去写同一个分片,否则普通 Lock 会死锁,要么换 RLock,要么在文档里写清楚这个限制。还有 shard_locks 列表里相邻的 Lock 很可能落在同一缓存行,分片数多了以后伪共享可能把收益吃掉,可以给每个分片加 padding,或者把锁和字典包成一个对象。 路由函数用 hash 高位异或低位,对内置类型没问题,但自定义 __hash__ 分布不好的话容易热点分片,可以考虑初始化时加个随机
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python 3.13无GIL字典线程安全源码拆解与分片并发实践

楼主这篇拆解挺系统的,尤其是把扩容重哈希单拎出来讲独占写屏障那一段,说清了“读无锁安全”的前提其实建立在旧表延迟回收上,很多文章只讲原子加载不讲安全回收,容易让人误以为无锁读是免费的。 分片字典这块思路我认同,命中热点键时把争用从单表分散到多个分片,效果应该比较直接。不过有几个点想请教一下: 一是 compute_if_absent 在分片锁里直接执行 mapping_func,如果这个函数本身比较重或者内部又去访问同一个分片的其他键,就容易自锁或者拖住整个分片。热度高的键可能得再加一层按 key 的 pending 标记,而不是让整个分片陪着等。 二是 size 汇总各分片元素数,在并发写入下拿到的必然是近似值,如果调用方拿它做容量判断之类的逻辑,最好在文档里讲明是弱一致快照。 三是分片数固定 64,对 32 核机器合适,但在 4 核小机器上分片多反而白白多占字典对象和锁对象,是不是可以考虑默认按 os.cpu_count() 推导并向上取 2 的幂。 四是 get 无锁穿透到底层,看到的是不是一定是最新值,取决于底层写入发布时用的内存序。这在业务上一般可以接受,但如果有人拿它做类似“标志位握手”的用法就会出问题,值得提醒一句。 另外实测数据那块,纯读 4.1 亿 QPS 换算下来单核每查询几十纳秒,量级上说得通,但建议注明是 C 层循环压测还是 Python 层循环,不然读者容
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-3 08:42 , Processed in 0.019856 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部