defaultdict 不是线程安全的,因其底层 dict 的读写及默认工厂调用均非原子操作,多线程下易导致重复创建对象、副作用重复执行或数据丢失;安全做法需保证“查+设”原子性,如用 setdefault 或加锁。
defaultdict 是 dict 的子类,底层共享 Python 字典的实现。CPython 中普通 dict 的读写操作(包括 __getitem__、__setitem__、setdefault)都不是原子操作;当触发默认工厂函数(如 list 或 int)并插入新键时,实际包含“检查键是否存在 → 调用工厂 → 插入键值对”多个步骤,中间可能被其他线程打断。
常见错误现象:
- 多个线程同时访问一个不存在的键,导致工厂函数被调用多次,产生多个独立对象(比如多个空 list),但只有其中一个被最终写入字典;
- 更隐蔽的是,如果工厂函数有副作用(如发请求、改全局状态),会被意外重复执行。
即使只做 dd[k] += 1 或 dd[k].append(x),也不安全——因为 dd[k] 这一步可能触发默认工厂,而 += 或 .append() 是分开执行的:
dd[k] 触发 int() 得到 0,但还没来得及赋值回字典,另一线程也进来,又得到一个 0
0 + 1,都试图写回 1,结果丢失一次计数dd[k].append(x),更糟:两次 dd[k] 可能分别创建两个不同 list 对象,append 到不同对象上,只有一个留在字典里不需要全局锁,但必须保证“查 + 设”原子性:
dd.setdefault(k, factory()) 替代直接访问 dd[k] —— 它在 C 层做了原子插入,但注意:工厂函数仍会在每次调用时执行,只是返回值可能被丢弃;所以工厂函数必须无副作用threading.local() 配合局部 defaultdict,最后再合并;避免竞争threading.Lock 包裹整个读-改-写过程;粒度可细化到每个键(用 collections.defaultdict(threading.Lock) 管理键级锁),但要注意死锁和内存增长we
akref.WeakKeyDictionary 配合锁,减少长生命周期锁对象残留竞态条件往往在高并发、低延迟或特定调度下才暴露。仅跑几次单元测试几乎肯定通过,但生产环境可能几小时才出现一次数据错乱。真正可靠的判断依据是:代码逻辑中是否存在「非原子的多步字典操作」,而不是有没有复现过错误。
最容易被忽略的一点:很多人以为“我只读不写就安全”,但 defaultdict 的读操作(__getitem__)一旦命中缺失键,就会写——它本质是读写混合操作。