先做一个关键判断:MemoryError 的本质并非“内存彻底耗尽”,而是 Python 在申请一段连续的内存空间时,操作系统无法为当前进程分配足够大的连续区域。这种错误通常出现在数据加载、中间计算或对象复制的过程中,而不是最终结果本身——因此,优化策略必须聚焦于“处理过程”,而非等到报错后再盲目扩充物理内存。

当 MemoryError 被抛出时,它并非模糊的“内存不够用”提示,而是 Python 明确告知你:当前操作试图分配的连续内存块,已经超过了操作系统能为该进程提供的上限。该错误往往发生在数据加载、中间计算或对象复制的阶段,而非最终结果本身——这意味着优化方向必须落在“过程”上,而不是在报错后才考虑扩容。
pd.read_csv(chunksize=) 为什么必须搭配 dtype 和 iterator=True
许多开发者设置了 chunksize 就认为万无一失,但 pandas 默认在每块中仍会进行全量类型推断,字符串列会自动转为 object 类型,导致内存占用翻倍。常见写法是:
pd.read_csv('big.csv', chunksize=50000)
正确的做法是显式地约束类型:
dtype参数必须传入字典,例如{'user_id': 'category', 'score': 'float32'},从而避免object类型的高内存开销;iterator=True虽是隐式要求,不写也能运行,但明确加上能让逻辑更清晰,防止误用为一次性读取;- 跳过无用列时使用
usecols,比如usecols=['id', 'value'],直接由磁盘过滤数据,不进入内存; - 若源文件存在重复索引或冗余表头,可添加
skiprows或header=None,避免额外的解析开销。
生成器 yield 不只是语法糖,更是内存控制的关键开关
把 return [x*2 for x in range(10**7)] 改为 yield,并非为了“看起来高级”,而是让 Python 彻底跳过构建完整列表的过程。核心要点在于:
- 函数体内只要出现
yield,该函数就变成生成器函数,调用后返回generator对象,不执行任何实际逻辑; - 使用
for item in gen_func()才会真正触发单次计算,并且上一轮的局部变量(包括循环变量)在 yield 后立即被释放; - 生成器不能直接使用
len()、list()或索引访问,否则会立刻转换为内存驻留结构,导致惰性失效; - 配合
itertools.islice(gen, start, stop)可安全截取片段,同时保持惰性求值特性。
del + gc.collect() 在哪些场景下真正有效
手动触发垃圾回收并非万能方案,但在明确的大对象生命周期边界处,它能防止引用残留拖慢回收节奏。典型有效场景包括:
- 处理完一个
pd.DataFrame后立即执行del df,再调用gc.collect(),尤其当后续需要加载另一个同等量级的数据时; - 多层嵌套字典或自定义类实例中包含大量子对象,且确认无外部引用时;
- 使用
multiprocessing时,子进程退出后主进程中残留的shared_memory或Queue对象,需要主动close()并del; - 注意:
gc.collect()的返回值是回收的不可达对象数量,若长期为 0,说明并未发生内存泄漏,无需强行调用。
NumPy 数组的 .memmap() 不是备选方案,而是默认选项
当数组尺寸接近或超过可用内存的 30% 时,就应默认考虑 np.memmap。它的意义并非只是“把数组存到硬盘”,而是让操作系统来管理页交换,Python 层面仍可像普通数组一样进行切片和索引:
- 创建时指定
mode='r'(只读)或'c'(copy-on-write),防止意外写入污染原文件; - 路径必须是真实文件路径,不能使用临时
BytesIO; - 形状和 dtype 必须提前确定,无法动态调整大小(resize);
- 对
.memmap执行.sum()、.mean()等聚合操作时,底层仍然采用分块读取,不会一次性全部载入——这一点与普通 ndarray 有本质区别。
真正卡住开发者的,从来不是“要不要分块”,而是从哪一步开始分、每块分多大、分完后又如何衔接结果。这些决策点没有通用公式,必须根据具体操作的中间内存峰值来判断——例如 df.groupby().apply() 的内存峰值可能出现在 apply 函数内部,而非 groupby 本身。因此,最省事的起点永远是先用 memory_profiler 跑一遍,重点关注 Line # 列的尖峰值,再针对性地进行切分。
