17370845950

在多线程中不加锁使用 defaultdict 是否安全
defaultdict 不是线程安全的,因其底层 dict 的读写及默认工厂调用均非原子操作,多线程下易导致重复创建对象、副作用重复执行或数据丢失;安全做法需保证“查+设”原子性,如用 setdefault 或加锁。

defaultdict 本身不是线程安全的

defaultdictdict 的子类,底层共享 Python 字典的实现。CPython 中普通 dict 的读写操作(包括 __getitem____setitem__setdefault)都不是原子操作;当触发默认工厂函数(如 listint)并插入新键时,实际包含“检查键是否存在 → 调用工厂 → 插入键值对”多个步骤,中间可能被其他线程打断。

常见错误现象:
- 多个线程同时访问一个不存在的键,导致工厂函数被调用多次,产生多个独立对象(比如多个空 list),但只有其中一个被最终写入字典;
- 更隐蔽的是,如果工厂函数有副作用(如发请求、改全局状态),会被意外重复执行。

不加锁时哪些操作看似安全实则危险

即使只做 dd[k] += 1dd[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) 管理键级锁),但要注意死锁和内存增长
  • Python 3.9+ 可考虑 we

    akref.WeakKeyDictionary
    配合锁,减少长生命周期锁对象残留

验证是否出问题不能只靠测试

竞态条件往往在高并发、低延迟或特定调度下才暴露。仅跑几次单元测试几乎肯定通过,但生产环境可能几小时才出现一次数据错乱。真正可靠的判断依据是:代码逻辑中是否存在「非原子的多步字典操作」,而不是有没有复现过错误。

最容易被忽略的一点:很多人以为“我只读不写就安全”,但 defaultdict 的读操作(__getitem__)一旦命中缺失键,就会写——它本质是读写混合操作。