RuntimeError: dictionary changed size during iteration——多线程 Pydantic cached_property 竞态条件

编号:10 | 报错类型:RuntimeError | 专栏:Python 生产环境报错速查 | 难度:中级

TL;DR

多线程环境下,一个线程正在通过 self.__dict__.items() 遍历对象属性做序列化,另一个线程触发 cached_property 写入新字段到 __dict__------CPython 解释器检测到 ma_used 计数变化,在 C 层直接抛出 RuntimeError: dictionary changed size during iteration。这不是 Python 逻辑 bug,是 CPython 内置的 dict 迭代保护机制在 GIL 下的低级竞态暴露。

根因在 CPython Objects/dictobject.c 第 5050 行dictiterobject 在迭代开始时将 ma_used 保存到 di_used,每次 __next__ 调用都会比较二者是否一致。多线程无锁共享同一对象时,另一个线程的任何 __dict__ 写入操作都会让这个检查失败。


1. 报错原文

完整 traceback 来自 langchain-ai/langchain#34887(2026-01,Python 3.13.7,langchain-core 0.3.79):

python 复制代码
File "/path/to/site-packages/langchain_core/load/dump.py", line 85, in dumpd
    return json.loads(dumps(obj))
File "/path/to/site-packages/langchain_core/load/dump.py", line 64, in dumps
    return json.dumps(obj, default=default, **kwargs)
File "/Library/Frameworks/Python.framework/Versions/3.13/lib/python3.13/json/encoder.py", line 261, in iterencode
    return _iterencode(o, 0)
File "/path/to/site-packages/langchain_core/load/dump.py", line 23, in default
    return obj.to_json()
File "/path/to/site-packages/langchain_core/load/serializable.py", line 207, in to_json
    for k, v in self:
File "/path/to/site-packages/pydantic/main.py", line 1196, in __iter__
    yield from [(k, v) for (k, v) in self.__dict__.items()
                                     ~~~~~~~~~~~~~~~~~~~^^
RuntimeError: dictionary keys changed during iteration

关键链路json.dumps → Serializable.to_json → Pydantic.__iter__ → self.__dict__.items()。这就是竞态窗口------self.__dict__ 是普通的 Python dict,它的迭代器不具备线程安全性。


2. GitHub 真实案例

项目 Issue 场景 状态
langchain-ai/langchain #34887 Runnable .map() 并发执行时序列化 Prompt 崩溃 Open
BerriAI/litellm #20389 Response streaming API 中 Pydantic model_copy(deep=True) 触发 Open
python/cpython #145446 PyDict_Next 在 free-threaded build 中缺少 critical section Open

langchain #34887 的核心诊断 :Issue 提交者 sugawara-miti0722 提供了一个完美的复现脚本------4 个序列化线程 + 4 个 invoke 线程,300,000 次迭代,reset_serialized_each_time=True 提高复现概率。复现 MRE 证明问题来自 cached_property(即 BasePromptTemplate._serialized)在多线程中被触发写入 __dict__,恰逢另一个线程在 dumpd() 中迭代同一 __dict__

反直觉点 :很多人以为 GIL 能保护 dict 迭代。GIL 只保证两个线程不会同时执行 Python 字节码,但不保证线程安全------一个线程在 items() 迭代中释放 GIL 后,另一个线程恰好获得 GIL 并执行 __dict__ 写入,再释放 GIL,第一个线程恢复执行时 ma_used 已经变了。


3. 根因:CPython dictobject.c 的迭代保护机制

3.1 PyDictObject 的内存布局

ini 复制代码
PyDictObject:
  ma_keys     → PyDictKeysObject
  ma_values   → PyDictValues (split table) 或 NULL (combined)
  ma_used     = 5   ← 当前 active 条目数
  ...

PyDictKeysObject:
  dk_refcnt   = 1
  dk_log2_size = 3  → size = 8
  dk_usable   = 3
  dk_nentries = 5
  dk_version  = 0
  dk_indices  → [0, 2, 4, 1, 3, -1, -1, -1]  ← 哈希索引表
  dk_entries  → [{k0,v0}, {k1,v1}, ..., {k4,v4}]  ← 紧凑条目数组

3.2 迭代器结构 dictiterobject

python 复制代码
# 实际上定义在 CPython C 层的 dictobject.c
typedef struct {
    PyObject_HEAD
    PyDictObject *di_dict;   /* 指向被迭代的字典 */
    Py_ssize_t di_used;      /* 迭代开始时 ma_used 的快照 */
    Py_ssize_t di_pos;       /* 当前遍历到 dk_entries 的位置 */
    Py_ssize_t len;          /* 剩余元素计数 */
} dictiterobject;

3.3 C 层的检查逻辑

核心代码在 Objects/dictobject.cdictiter_iternextkey_lock_held() 函数(第 5041-5102 行):

c 复制代码
static PyObject*
dictiter_iternextkey_lock_held(PyDictObject *d, PyObject *self)
{
    dictiterobject *di = (dictiterobject *)self;
    PyObject *key;
    Py_ssize_t i;
    PyDictKeysObject *k;
    assert (PyAnyDict_Check(d));
    ASSERT_DICT_LOCKED(d);

    // ═══════════ 关键检查 ═══════════
    if (di->di_used != d->ma_used) {
        PyErr_SetString(PyExc_RuntimeError,
            "dictionary changed size during iteration");
        di->di_used = -1; /* Make this state sticky */
        return NULL;
    }
    // ════════════════════════════════

    i = di->di_pos;
    k = d->ma_keys;
    assert(i >= 0);

    if (_PyDict_HasSplitTable(d)) {
        // split table: 用 bit vector 记录插入顺序迭代
        if (i >= d->ma_used) goto fail;
        int index = get_index_from_order(d, i);
        key = LOAD_SHARED_KEY(DK_UNICODE_ENTRIES(k)[index].me_key);
    } else {
        // combined table: 顺序遍历 dk_entries[]
        Py_ssize_t n = k->dk_nentries;
        if (DK_IS_UNICODE(k)) {
            PyDictUnicodeEntry *entry_ptr = &DK_UNICODE_ENTRIES(k)[i];
            while (i < n && entry_ptr->me_value == NULL) {
                entry_ptr++; i++;  // 跳过 dummy 槽位
            }
            if (i >= n) goto fail;
            key = entry_ptr->me_key;
        }
        // ... (GENERAL 类型类似)
    }

    // 第二层检查:找到元素但没预期
    if (di->len == 0) {
        PyErr_SetString(PyExc_RuntimeError,
            "dictionary keys changed during iteration");
        goto fail;
    }

    di->di_pos = i + 1;
    di->len--;
    return Py_NewRef(key);

fail:
    di->di_dict = NULL;
    Py_DECREF(d);
    return NULL;
}

两层保护机制

检查层 比较对象 触发条件 报错信息
第一层 di_used vs ma_used 元素数量变化(新增/删除) dictionary changed size during iteration
第二层 di->len == 0 元素数量未变但 keys 结构变化(如 key 被替换) dictionary keys changed during iteration

在第一层,ma_usedPyDictObject 的关键字段,表示当前 active 的 key-value 对数。cached_property 首次访问时写入 self.__dict__ 会触发 dict_setitemma_used++,恰好让另一个线程的迭代器发现 di_used != ma_used

Python 3.13 free-threaded 的加剧效应cpython#145446 指出,在 free-threaded 构建中,PyDict_Next 没有 Py_BEGIN_CRITICAL_SECTION 保护,多线程并发迭代 dict 时 ma_used 检查彻底失去保证。即使是带 GIL 的构建,线程调度的不确定性也让这个竞态窗口足够宽。

3.4 为什么 GIL 保护不了 dict 迭代

这是本文最反直觉的点。很多 Python 开发者背过一句话------"GIL 保证同一时刻只有一个线程执行 Python 字节码"。由此推导:既然 dict 迭代和 dict 写入都是 Python 操作,它们不会同时发生,所以应该安全。但这个推导错了三层:

第一层:GIL 只保护字节码原子性,不保证操作原子性。 for k, v in d.items() 在 CPython 内部被编译为多次字节码------每次 __next__() 调用都是一个独立的字节码段,段与段之间 GIL 可以被释放。假设线程 A 在 items() 迭代中已经产生了 3 个元素,此时 GIL 被释放,线程 B 获得 GIL 并执行 obj.new_attr = value(一个 setattr 字节码)。线程 B 释放 GIL 后,线程 A 恢复执行第 4 次 __next__()------di_used(初始快照)与 ma_used(已被线程 B 修改)不同 → RuntimeError。

第二层:GIL 释放点比你想象的更多。 Python 的 GIL 在 Python 3.2+ 中默认每 5ms 强制释放一次(sys.setswitchinterval),但此外还有大量隐式释放点:I/O 操作、time.sleep()、C 扩展调用、甚至某些内置函数的内部实现。在多线程密集场景下,5ms 内足够让两个线程交替执行数十万次字节码。

第三层:cached_property 的写入不是"看起来"的读操作。 你调用 obj.some_cached_prop,看起来像是纯读取------实际上第一次调用会触发计算 + self.__dict__["some_cached_prop"] = result,这是一个写入操作。而写入 __dict__ 的内部路径是 dict_setitemma_used++。在多线程环境中,"它在读"的假设不成立------它读着读着就写了。

总结:GIL 保证的是"两个线程不会同时执行 Python 字节码",而不是"你的 dict 迭代不会被另一个线程打断"。当你用 for ... in d.items() 遍历 100 个元素时,这 100 次 __next__() 之间 GIL 可能被释放 100 次,每一次都可能让另一个线程插入一个 ma_used++


4. 五种生产级触发场景

下面五个场景从不同角度触及同一个根因------每次都是某个线程在迭代 __dict__,另一个线程通过 cached_property / setattr / pop 修改了它。区别在于触发源、共享方式、以及修复策略的取舍。

场景 1:多线程序列化 + cached_property 竞争(LangChain)

❌ 错误代码

python 复制代码
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.load import dumpd
from concurrent.futures import ThreadPoolExecutor

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful assistant."),
    ("human", "{q}"),
])

def serialize():
    for _ in range(100000):
        dumpd(prompt)  # → to_json() → __dict__.items()

def invoke():
    for i in range(100000):
        prompt.__dict__.pop("_serialized", None)  # 清缓存
        prompt.invoke({"q": f"hi {i}"})  # 触发 cached_property 写入

with ThreadPoolExecutor(max_workers=8) as ex:
    for _ in range(4): ex.submit(serialize)
    for _ in range(4): ex.submit(invoke)
# → RuntimeError: dictionary changed size during iteration

✅ 正确代码------方案 A(推荐):在序列化入口加锁

python 复制代码
import threading

_ser_lock = threading.Lock()

def serialize_safe(prompt):
    with _ser_lock:
        return dumpd(prompt)

✅ 正确代码------方案 B:预初始 cached_property

python 复制代码
prompt = ChatPromptTemplate.from_messages([...])
_ = prompt._serialized  # 提前触发,后续 invoke 不再写 __dict__

# 预热后再并发就不会再写 __dict__
with ThreadPoolExecutor(max_workers=8) as ex:
    ...

分析_serializedBasePromptTemplate 上的 cached_property。首次访问时计算值并 setattr(self, "_serialized", value),这触发 ma_used++。预热之后所有线程只是读取现有值,不再写入。

这个场景是最典型的------一个线程负责"干活"(invoke),另一个线程负责"观测"(序列化)。干活线程不经意间触发了 cached_property 写入,观测线程的迭代器恰好在那一次 __next__() 中检测到 ma_used 发生了变化。修复思路也最清晰:锁住序列化入口,或者提前把 cached 值算好。


场景 2:cached_property 本身的线程安全性缺陷

❌ 错误代码

python 复制代码
from functools import cached_property
import threading

class Config:
    @cached_property
    def expensive(self):
        import time; time.sleep(0.1)  # 模拟计算
        return {"key": "value" * 1000}

cfg = Config()

def read_config():
    for _ in range(10000):
        _ = cfg.expensive  # 内部可能触发 __dict__ 写入

def serialize_config():
    for _ in range(10000):
        # 遍历 __dict__ 做日志记录
        for k, v in cfg.__dict__.items():
            pass  # 可能遇到 RuntimeError

threads = [threading.Thread(target=read_config) for _ in range(5)] + \
          [threading.Thread(target=serialize_config) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()

竞争窗口cached_property 的标准实现(functools.cached_property)在写入 __dict__ 前不持有任何锁。5 个线程同时尝试触发同一个 cached_property,只有一个成功写入 __dict__------但这恰好发生在另一个线程的 __dict__.items() 迭代中

场景 2 与场景 1 的核心区别:场景 1 是"读写分离"------一个线程写、另一个线程读。场景 2 是"读写重叠"------多个线程同时尝试触发同一个 cached_property,写操作本身就存在竞争,而读操作(.items())恰好也在其中。这意味着即使你锁住了序列化入口,cached_property 本身的非原子性仍然可能在其他路径(如日志、调试)中暴露问题。

✅ 正确代码

python 复制代码
from functools import cached_property
import threading

class SafeConfig:
    def __init__(self):
        self._lock = threading.Lock()
        self._expensive_cache = None

    def get_expensive(self):
        if self._expensive_cache is not None:
            return self._expensive_cache
        with self._lock:
            if self._expensive_cache is not None:
                return self._expensive_cache
            self._expensive_cache = self._compute_expensive()
            return self._expensive_cache

    def items_safe(self):
        """返回 __dict__ 的快照用于迭代"""
        return dict(self.__dict__)  # copy 隔离

场景 3:Streamlit st.cache_data + Altair 图表

❌ 错误代码

python 复制代码
import streamlit as st
import altair as alt
import pandas as pd
from concurrent.futures import ThreadPoolExecutor

@st.cache_data
def build_chart(data: pd.DataFrame):
    chart = alt.Chart(data).mark_bar().encode(
        x='category', y='value'
    )
    # Altair 内部图表对象使用 cached_property 缓存 spec
    return chart

data = pd.DataFrame({'category': ['A','B','C'], 'value': [10,20,30]})

def render():
    chart = build_chart(data)
    chart.to_json()  # 触发生成 JSON spec

def data_update():
    new_data = pd.DataFrame({'category': ['D','E'], 'value': [5,8]})
    build_chart(new_data)  # st.cache_data 内部可能清缓并重建

with ThreadPoolExecutor(max_workers=4) as ex:
    for _ in range(2): ex.submit(render)
    for _ in range(2): ex.submit(data_update)

根因分析 :Altair 的 Chart.to_json() 会遍历对象属性生成 Vega-Lite spec,内部依赖 cached_property 缓存。在 Streamlit 的 st.cache_data 线程池+hash 校验机制下,缓存失效重建时写入新属性,可能与其他线程的 spec 序列化并发。

✅ 正确代码

python 复制代码
import threading
_render_lock = threading.Lock()

@st.cache_data(ttl=3600)
def build_chart_safe(data: pd.DataFrame):
    chart = alt.Chart(data).mark_bar().encode(x='category', y='value')
    # 预热:提前触发所有 cached_property
    _ = chart.to_json()
    return chart

def render_safe(chart):
    with _render_lock:
        return chart.to_json()  # 加锁保护

场景 4:LangChain Runnable.map() 内部并发

❌ 错误代码

python 复制代码
from langchain_core.runnables import RunnableLambda
from langchain_core.prompts import ChatPromptTemplate

chain = (
    RunnableLambda(lambda x: {"q": x})
    | ChatPromptTemplate.from_messages([("human", "{q}")])
)

# Runnable.map() 内部使用线程池并行执行
# 多个输入元素可能共享同一个 prompt 对象
inputs = [f"question {i}" for i in range(100)]
results = chain.batch(inputs, config={"max_concurrency": 10})
# 间歇性: RuntimeError: dictionary changed size during iteration

根因Runnable.batch() / .map() 内部用 ThreadPoolExecutor 并行执行。同一个 ChatPromptTemplate 实例被多个线程共享,invoke() 触发 cached_property_serialized)写入与 to_json()__dict__ 迭代竞争。

✅ 正确代码------方案 A:使用深拷贝

python 复制代码
from copy import deepcopy

def batch_with_isolation(chain, inputs, max_concurrency=4):
    """每个输入使用独立的 prompt 副本"""
    from concurrent.futures import ThreadPoolExecutor
    
    def process(input):
        local_chain = deepcopy(chain)  # 隔离
        return local_chain.invoke(input)
    
    with ThreadPoolExecutor(max_workers=max_concurrency) as ex:
        return list(ex.map(process, inputs))

✅ 正确代码------方案 B:patch Pydantic 序列化

python 复制代码
from pydantic import BaseModel
from functools import wraps

_original_iter = BaseModel.__iter__

@wraps(_original_iter)
def _safe_iter(self):
    # 用快照替代直接迭代 __dict__
    items = list(self.__dict__.items())  # 一性次复制,break 竞态窗口
    for k, v in items:
        if not k.startswith('_'):
            yield k, v

BaseModel.__iter__ = _safe_iter

警告:方案 B 是破坏性 monkey-patch,仅推荐作为临时 workaround。LangChain 社区已有 PR 讨论在 Serializable.to_json() 层面用 dict(self.__dict__) 替代直接迭代。


场景 5:FastAPI 并发请求中共享 Pydantic 模型实例

❌ 错误代码

python 复制代码
from fastapi import FastAPI
from pydantic import BaseModel
from functools import cached_property

class AppConfig(BaseModel):
    model_name: str = "gpt-4"
    temperature: float = 0.7

    @cached_property
    def token_count(self):
        # 模拟从文件加载 token 计数
        import time; time.sleep(0.05)
        return {"input": 100, "output": 200}

# 全局共享实例 ← 灾难根源
config = AppConfig()

app = FastAPI()

@app.get("/config")
def get_config():
    # 返回序列化后的配置 → __dict__.items()
    return config.model_dump()  # Pydantic v2

@app.post("/tokens")
def update_tokens():
    # 每次请求都可能触发 cached_property → 写 __dict__
    return config.token_count

# uvicorn main:app --workers 4
# 高并发下间歇性: RuntimeError: dictionary changed size during iteration

竞争窗口uvicorn 的每个 worker 进程内使用线程池处理请求。多个请求同时访问同一个 config 对象------一个请求在 model_dump() 中序列化 __dict__,另一个请求首次访问 token_countcached_property)触发 setattr(self, "token_count", result)ma_used++

✅ 正确代码

python 复制代码
from fastapi import FastAPI
from pydantic import BaseModel, Field
from functools import cached_property, lru_cache
import threading

class SafeAppConfig(BaseModel):
    model_name: str = "gpt-4"
    temperature: float = 0.7
    _token_count: dict | None = None
    _lock: threading.Lock = Field(default_factory=threading.Lock, exclude=True)

    def get_token_count(self):
        if self._token_count is not None:
            return self._token_count
        with self._lock:
            if self._token_count is not None:
                return self._token_count
            import time; time.sleep(0.05)
            self._token_count = {"input": 100, "output": 200}
            return self._token_count

config = SafeAppConfig()
# 预热:在启动时触发所有 cached 属性
_ = config.get_token_count()

app = FastAPI()

@app.get("/config")
def get_config():
    # model_dump 只会序列化 model 字段,不包括 _token_count(exclude=True)
    # 而且 token_count 已在启动时预热,不会有写入操作
    return config.model_dump(exclude={'_lock'})

4a. 最小复现指南

如果你不想依赖 LangChain 或 FastAPI 来复现这个 bug,下面是一个仅用标准库的 30 行脚本。运行 10 次,至少 3-4 次会命中 RuntimeError:

python 复制代码
import threading
from functools import cached_property

class Victim:
    @cached_property
    def cache_me(self):
        import time; time.sleep(0.001)
        return {"data": "x" * 100}

obj = Victim()

def reader():
    for _ in range(50000):
        try:
            list(obj.__dict__.items())  # 迭代 __dict__
        except RuntimeError:
            print("[READER] 💥 RuntimeError caught!")
            break

def writer():
    for _ in range(50000):
        obj.__dict__.pop("cache_me", None)  # 清缓存
        _ = obj.cache_me  # 重新触发 cached_property → 写入 __dict__

threading.Thread(target=reader).start()
threading.Thread(target=writer).start()

把这个脚本保存为 repro.py,连跑 10 次:for i in $(seq 1 10); do echo "=== RUN $i ==="; python3 repro.py; done。你会看到大约 30%-50% 的运行输出 💥 RuntimeError caught!。这就是竞态窗口的宽度------它不是在特定条件下才触发,而是在中高并发下稳定复现。

这个 MRE 揭示了一个关键事实:你不需要复杂的框架,两个线程、一个带 cached_property 的对象、一个遍历 __dict__ 的操作、一个触发缓存写入的操作------四要素凑齐就必定中招。 你的生产代码里可能同时存在这四个要素,只是它们分布在不同的模块里、被不同的调用链触发,平时各自正常,一旦并发量上去就间歇性炸。

4b. 五个场景的对比

场景 写入源 读取源 共享方式 修复策略
1 LangChain 序列化 cached_property (_serialized) to_json()__dict__.items() 同一 prompt 对象跨线程共享 加锁 / 预热
2 cached_property 自身 多线程同时触发同一 cached_prop 日志/调试中的 __dict__ 遍历 类实例级共享 双重检查锁 / copy 快照
3 Streamlit Altair st.cache_data 清缓存后重建 chart.to_json() 序列化 闭包捕获的共享对象 ttl + app-scope 预热
4 LangChain map Runnable.map() 内部线程池 隐式 dumpd() LCEL 链中共享 prompt 深拷贝隔离 / monkey-patch
5 FastAPI 全局实例 请求触发 cached_property model_dump() 返回响应 模块级全局实例 预热 + exclude 字段

你在自己的项目里排查时,不需要记住这五个场景。只需要问三个问题:① 哪个对象在迭代 __dict__(或者调用了会迭代 __dict__ 的方法如 model_dumpjson.dumps)?② 哪个线程在往这个对象上写属性(setattr、cached_property 首次触发、pop)?③ 它们共享的是同一个对象实例吗?三个都是"是"→ 恭喜,你有一个竞态条件。

5. 排障流程

yaml 复制代码
┌─────────────────────────────────────────┐
│ 遇到 RuntimeError:                       │
│ dictionary changed size during iter     │
├─────────────────────────────────────────┤
│ Step 1: 确认是否多线程环境              │
│   查看 traceback → 是否有 ThreadPool /  │
│   asyncio + run_in_executor 迹象        │
├─────────────────────────────────────────┤
│ Step 2: 定位迭代位置                     │
│   找 traceback 最后一行的 .items() 调用  │
│   → 确认是 __dict__ 还是普通 dict       │
├─────────────────────────────────────────┤
│ Step 3: 寻找写操作                       │
│   搜索代码中的 cached_property /         │
│   setattr / __dict__.update / .pop()    │
├─────────────────────────────────────────┤
│ Step 4: 确认竞态窗口                     │
│   写操作和读迭代是否共享同一对象?        │
│   → 是 → 加锁或 copy 快照               │
├─────────────────────────────────────────┤
│ Step 5: 修复                             │
│   ① 加 threading.Lock                   │
│   ② 迭代前 list(self.__dict__.items())  │
│   ③ 预热 cached_property                │
│   ④ 使用深拷贝隔离对象                   │
└─────────────────────────────────────────┘

诊断命令

bash 复制代码
# 在代码中插入诊断钩子
import threading, traceback

def watch_dict_writes(obj, label):
    """Monkey-patch 对象的 __dict__ 以检测多线程写入"""
    import types
    orig_setattr = type(obj).__setattr__
    def _setattr(self, name, value):
        tid = threading.get_ident()
        tb = ''.join(traceback.format_stack()[-3:-1])
        print(f"[TID={tid}] SETATTR {label}.{name} at:\n{tb}")
        return orig_setattr(self, name, value)
    type(obj).__setattr__ = _setattr
    return obj

# 使用: prompt = watch_dict_writes(prompt, "prompt")

6. 同类家族

报错 触发层 交集关系
RuntimeError: dictionary changed size during iteration dictobject.c:5050 (ma_used 检查) 本文
RuntimeError: dictionary keys changed during iteration dictobject.c:5091 (len==0 检查) 同文件,不同检查路径
RuntimeError: dictionary modified during iteration dictobject.c (version 检查) Python 3.13 free-threaded 构建新增
RuntimeError: Set changed size during iteration setobject.c (类似机制) 同理,set 迭代器也有 used 快照

记忆锚点dict.__iter__ 在 C 层保存 ma_used 快照 → 每次 next 比较 di_used != ma_used → 不等就抛。多线程下,任何 setattr / pop / update 都会动 ma_used。fix 就三招:锁、copy、预热

7. Python 版本差异

这个 RuntimeError 在不同 Python 版本上的行为有重要差异,尤其是你正在升级 Python 版本时要特别注意:

Python 版本 dict 实现 竞态窗口 报错信息
3.10-3.12 compact dict(统一布局) 存在,GIL 释放窗口 × cached_property 写入 dictionary changed size during iteration
3.13 (GIL) 同 3.12,但迭代器逻辑有微调 仍然存在,GIL 行为未变 同上
3.13t (free-threaded) compact dict + PyDict_Next 无 critical section 显著扩大------多线程可真正同时迭代同一 dict 新增 dictionary modified during iteration(基于 dk_version
3.14 (计划) 预期为 free-threaded 添加更多 protection 可能改善,但不会消除 ---

关键变化在 3.13 free-threaded 构建(--disable-gil):GIL 被移除后,两个线程可以真正同时 执行 PyDict_Next。CPython 开发者在 dictobject.c 中新增了基于 dk_version 的检查("dictionary modified during iteration"),但这个版本检查的覆盖率不如 ma_used 检查全面------某些边角修改可能绕过版本号更新。cpython#145446 正是在追踪这一问题。

生产环境建议 :如果你的项目依赖 FastAPI/Uvicorn(内部使用线程池)或 LangChain(Runnable.map 使用线程),并且计划升级到 Python 3.13+ free-threaded 构建------务必在升级前跑一遍本文的 MRE 脚本,确认你的 Pydantic 模型和 cached_property 使用场景不会在去 GIL 后大面积触发此问题。


8. 总结

这个 RuntimeError 的本质是一个 C 层的防御性检查在 Python 多线程语义下的误伤 。CPython 的设计意图是阻止单线程中的"边迭代边修改"------这是一种明确的编程错误。但在多线程环境中,它变成了一个概率性的竞争条件------任何一个线程在任何时刻对 __dict__ 的写入,都会让正在迭代的另一个线程触发这个检查。

三层理解

理解
初级 写了一个 for 循环遍历 dict 在里面 pop 了 key → RuntimeError
中级 多线程共享 Pydantic 对象 → cached_property 触写 __dict__ → 序列化线程的迭代检查失败
CPython dictiterobject.di_used 保存 ma_used 快照 → dictiter_iternextkey_lock_held() 比较 → di_used != d->ma_used → 直接 PyErr_SetString

核心修复公式锁 → copy → 预热 。锁保护共享状态,copy 隔离读写,预热消除运行时写入。三个方案不是互斥的------生产环境中通常组合使用:在模块初始化时预热所有 cached_property(消除 90% 的竞态窗口),在序列化入口加轻量锁(兜底保护),在日志/调试代码中用 dict(self.__dict__) 代替直接迭代(防御性编程)。


本文来自 CSDN 专栏《Python 生产环境报错速查:从崩溃到修复》,作者:含光。

参考:

相关推荐
starrysky8103 小时前
Agent 安全 #06:Agent 灰度发布与回滚 —— 上线新 Prompt 炸了生产,你敢回滚吗?
angular.js
starrysky8103 小时前
CPU 70% idle 但 load average 84?D 状态进程不可中断睡眠的深度排查
angular.js
界面开发小八哥3 天前
界面控件DevExtreme v26.1新版亮点——支持Angular 22
前端·javascript·angular.js·devexpress·ui开发·devextreme
巴勒个啦7 天前
Vue 3.6 Vapor Mode 实战:我把一个 Vue3 项目的渲染性能提升了 4 倍
前端·angular.js
shmily麻瓜小菜鸡10 天前
在 Angular 项目中实现国际化(i18n)和翻译 使用ngx-translate
前端·javascript·angular.js
starrysky81015 天前
一个 JSON 解析错误,搞垮了整个进程池?——requests JSONDecodeError + BrokenProcessPool 排障实录
angular.js
starrysky81015 天前
小模型反而爆显存?Qwen2.5-VL 3B vs 7B 的 vLLM 显存真相——多模态 Profiling 机制深度拆解
angular.js
starrysky81019 天前
THP 透明大页导致数据库延迟从 2ms 飙升到 2000ms——khugepaged 内存压缩风暴排查实录
angular.js
starrysky81022 天前
多Agent通信架构实战:从NATS消息总线到五大编排模式的生产落地——Agent通信协议篇
angular.js