MemoryError 与 RecursionError:内存耗尽和递归超限速查
更新时间:2026-10-24。FAQ 速查页,面向脚本跑着跑着被 MemoryError 或 RecursionError 打死的同学。这两个报错是同一族——资源耗尽:MemoryError 是内存耗尽(Python 向系统要内存被拒绝了),RecursionError 是调用栈耗尽(递归超过解释器设的 1000 层上限)。字面上报错只有一行,真因集中在六根轴:大文件一次性
read()全吞进内存、pandas 全量加载大 CSV、该用生成器的地方用了列表(中间结果全驻留)、对象循环引用叠加__del__导致垃圾回收收不动、递归深度真超了(树太深或忘了写基准情形)、以及把该迭代的逻辑硬写成递归。配套定位工具:内存侧用 tracemalloc 抓分配热点,递归侧用 traceback 里的「同上 N 行」认死循环。注意区分:进程被系统 OOM Killer 直接杀掉(日志只有 Killed、没有 traceback)不是 MemoryError,那是另一篇的事。
本文要回答的问题
- 文件才 2GB、机器内存 8GB,为什么还会 MemoryError?
- 列表推导式和生成器表达式内存差多少?
- 对象del 了为什么内存不降下来?
- RecursionError 调大 setrecursionlimit 能解决吗?
- 怎么找到是哪一行代码在不停分配内存?
一、报错原文与典型现象
两个报错的真身(工程路径以 /proj/demo 占位):
$ python3 /proj/demo/load_data.py
Traceback (most recent call last):
File "/proj/demo/load_data.py", line 5, in <module>
data = f.read()
MemoryError
# 12GB 的日志文件被一次性 read——内存装不下$ python3 /proj/demo/parse_tree.py
Traceback (most recent call last):
File "/proj/demo/parse_tree.py", line 8, in walk
walk(node.left)
File "/proj/demo/parse_tree.py", line 8, in walk
walk(node.left)
[Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded
# 「同上 996 次」= 递归没刹住车,基准情形没命中$ python3 /proj/demo/load_csv.py
Killed
$ dmesg | tail -1
Out of memory: Killed process 30182 (python3)
# 注意:这是系统 OOM Killer 杀的,没有 MemoryError——内存是真被耗光先分清三种死法:MemoryError(Python 自己抛的,有 traceback)、RecursionError(栈超限,有 traceback)、Killed(系统 OOM Killer 杀的,啥都没有只有 dmesg 一行)。前两个归本篇,Killed 的处置思路相同但要先确认是谁杀的。
二、病因处置速查表
| 报错现象 / 场景 | 可能原因 | 快速验证 | 处置 |
|---|---|---|---|
| read() 大文件时 MemoryError | 一次性全量读入,内存装不下 | 看报错行是否是 read/readlines | 改逐行迭代 for line in f |
| pandas 读大 CSV 炸 | 全量加载,DataFrame 开销是文件 3-5 倍 | 文件大小对比机器内存 | chunksize 分块读 / 只读需要的列 |
| 中间列表越攒越大 | 列表推导/mappend 全驻留,该流式没流式 | tracemalloc 看分配热点 | 生成器表达式替代列表推导 |
| 内存只涨不回落 | 循环引用 + __del__,GC 收不动 | gc.collect() 后看是否回落 | 拆环(weakref)/ 去掉 __del__ |
| 「同上 996 次」的 traceback | 递归基准情形没命中或深度真超 | 看递归函数基准分支 | 修基准情形 / 改迭代写法 |
| 深度稍超、逻辑正确 | 树/链表天然深,1000 层不够 | 估算真实深度 | sys.setrecursionlimit 适度调大 |
三、排查决策图
这张图是资源耗尽类报错的标准动线,颜色按角色区分:红是报错入口与最终定罪的根因,蓝是排查动作,绿是验证通过分支,橙是修复动作,紫是防复发沉淀。
读这张图抓住第一个分叉:两种报错走两条路。MemoryError 侧的核心动作是 tracemalloc 抓「谁在分配」,RecursionError 侧的核心动作是数 traceback 里的重复次数、回头审基准情形。两条路的终点殊途同归:把「全量驻留」改成「流式/迭代」,让资源占用和数据总量脱钩。
四、逐条排查展开
第一病因:大文件一次性 read()。 f.read() 和 f.readlines() 会把整个文件塞进一个字符串/列表——12GB 文件配 8GB 内存,必炸。而逐行迭代时 Python 的文件对象本身就是迭代器,内存里永远只有一行:
# 错误:data = f.read()
with open(path) as f:
for line in f: # 逐行流式,内存 O(1)
process(line)这是 Python 文件处理的第一定律:文本文件默认逐行迭代,read() 只在小文件场景用。CSV 用 csv.reader 迭代,JSON Lines 逐行 json.loads,都是同一生成器思想。
第二病因:pandas 全量加载大 CSV。 DataFrame 的内存开销通常是 CSV 文件体积的 3-5 倍(对象头、索引、类型列),2GB 的 CSV 读进来可能占 8GB。处置按力度排序:只读需要的列(usecols)、指定更省的 dtype(dtype={"id": "int32"})、分块处理(chunksize=100000,逐块聚合)、终极方案换 Polars/DuckDB 这类流式引擎。分块示例:
import pandas as pd
total = 0
for chunk in pd.read_csv(path, chunksize=100_000, usecols=["amount"]):
total += chunk["amount"].sum()第三病因:该流式的地方用了列表。 result = [transform(x) for x in huge_iter] 会把全部中间结果驻留内存;改成生成器表达式 (transform(x) for x in huge_iter) 后按需产出,内存 O(1)。同理:",".join() 收生成器没问题,但先 list() 再 join 就把内存翻回去了;sorted() 必须全量是真需求,但 max()/sum()/any() 都可以直接收生成器。审查口径:看到对大数据集调 list()、列表推导、append 进无限累积的列表,都问一句「能不能流式」。
第四病因:循环引用叠加 __del__,GC 收不动。 Python 靠引用计数为主、周期 GC 为辅回收内存。A 引用 B、B 引用 A 构成环,引用计数归不了零,要靠 GC;而环上只要有对象定义了 __del__,旧版本 Python 的 GC 直接放弃整个环(3.4 起能收但仍有不建议依赖的语义)。表现为内存只涨不回落,del 了也没用。确认:
import gc
gc.collect() # 手动跑一轮 GC,看内存是否回落回落明显说明是环的问题。处置:拆环(一方改 weakref.ref)、去掉不必要的 __del__(资源释放改用上下文管理器)。缓存无限增长(functools.lru_cache(maxsize=None)、模块级 dict 只加不删)是同族问题,给缓存设上限。
内存定位工具:tracemalloc。 标准库自带,不用装任何东西:
import tracemalloc
tracemalloc.start()
# ……跑你的逻辑……
snapshot = tracemalloc.take_snapshot()
for stat in snapshot.statistics("lineno")[:10]:
print(stat) # 分配最多的 10 行,按行号定罪报告直接告诉你哪一行分配了多少内存、多少次,热点一目了然。怀疑「慢慢漏」时,跑前跑一次 take_snapshot、跑后跑一次,snapshot.compare_to(old, "lineno") 看增量。
第五病因:递归基准情形没命中。 traceback 里 [Previous line repeated 996 more times] 就是递归函数在自旋。最常见原因是基准情形(base case)条件写错:if n <= 0: return 写成 if n == 0,传入负数就永远递归下去;或者树有环(A 的子节点指回了祖先),walk 永远走不完。处置:审基准条件的边界(用 <= 不用 ==),树结构遍历前做环检测(访问集合记录已见节点 id)。
第六病因:深度真超了,该改迭代。 数据天然深(上千层的嵌套 JSON、长链表、深目录树),逻辑没错但 1000 层默认上限不够。两个处置:应急调大 sys.setrecursionlimit(10000)——注意这是把 Python 栈往 C 栈推,调太大可能直接段错误,属于创可贴不是根治;根治改迭代写法,用显式栈替代调用栈:
def walk(root):
stack = [root]
while stack:
node = stack.pop()
visit(node)
stack.extend(node.children)迭代版深度无上限(只受内存约束),还省掉了每次调用的栈帧开销。任何「深度可能随数据规模增长」的递归,都该优先写成迭代。
五、规范做法与预防
- 文件 IO 默认流式:逐行迭代、
csv.reader、chunksize写进团队代码模板;CR 见大文件read()直接打回。 - 数据处理先估内存:数据量 × 3-5 倍对比机器内存,超了先设计分块方案再写代码。
- 中间结果用生成器串起来:pipeline 各环节惰性求值,只有终点物化。
- 缓存必设上限:lru_cache 给 maxsize,模块级 dict 配淘汰策略。
- 递归函数必审基准情形:深度随数据增长的一律改迭代写法。
- 压测里挂 tracemalloc 快照:内存增长曲线进回归报告,漏内存在上限前暴露。
这一篇在知识体系里的位置
本篇是 Python 排错速查簇的资源耗尽篇,定位「内存和调用栈被耗光」:编码炸了看 UnicodeDecodeError 编码报错速查;脚本卡住不动看 Python 脚本卡死不退出速查;大文件路径都找不到看 FileNotFoundError 文件不存在速查;读写没权限看 PermissionError 权限被拒速查;运行环境版本问题看 Python 版本不兼容报错速查;未定义符号类崩溃看 undefined symbol 速查。「流式替代全量」这条心法对一切数据处理语言通用。
一句话总结
MemoryError 与 RecursionError 是同族资源耗尽:内存侧先用 tracemalloc 抓分配热点,根因逃不出大文件一次性 read(改逐行迭代)、pandas 全量加载(chunksize 分块 + usecols)、列表驻留中间结果(改生成器)、循环引用叠加 __del__ 收不动(weakref 拆环);递归侧看 traceback「同上 N 次」,基准情形没命中就修边界(<= 不用 ==),深度真超了先用 sys.setrecursionlimit 应急、根治改显式栈迭代。防复发主线一条:流式替代全量,资源占用与数据总量脱钩。