引用计数 (即时回收) + 分代 GC (兜底循环引用) + pymalloc (小对象池) — 以及"内存只涨不降"的真相
Python 的内存管理是"引用计数为主 + 分代 GC兜底"的双层结构: 引用计数让对象在失去最后一个引用的瞬间被回收(快、确定), 分代 GC 只负责引用计数搞不定的循环引用。排查内存问题时记住三问: 谁在涨(tracemalloc)? 是泄漏还是池不还(RSS vs 对象数)? 缓存有没有上限?
del a 只是删除名字绑定并 DECREF, 对象是否回收取决于计数是否归零; 还有别处引用就还活着。
import sys a = [1, 2, 3]; b = a del a # 关键: 只删名字 + DECREF, b 还指着 → 对象活着 sys.getrefcount(b) # → 2 (b + getrefcount 形参) del b # 计数归零 → 此刻立即回收(确定性)
a.child=b; b.parent=a 后 del a,b, 计数各剩 1, 只有分代 GC 从根不可达推导才能回收。含 __del__ 的环(旧版本)要两轮才收掉。
import gc a, b = Node(), Node(); a.child, b.parent = b, a # 互指成环 del a, b # 关键: 两者 refcnt 各剩 1, 引用计数收不掉 gc.collect() # → 2 — 分代 GC 从根不可达推导, 回收环
import weakref class Node: ... r = weakref.ref(Node()) # 关键: 不增 refcnt r() # → None — 临时对象已死, 引用自动失效
gc.set_threshold(700,10,10): gen0 净分配超 700 触发 gen0 回收; 每 10 次 gen0 回收触发 1 次 gen1, 依此类推。高频创建长寿对象的服务可调大/配合 gc.freeze()。
import gc gc.get_threshold() # → (700, 10, 10) — gen0 净分配超 700 触发扫描 gc.set_threshold(50_000, 50, 50) # 关键: 长寿命高频分配服务调大, 少扫 gc.freeze() # 常驻对象移出扫描集 (预热后调用)
del big_list; gc.collect() # 对象已回收, RSS 往往纹丝不动 # 关键: arena 须整块空闲才还 OS → RSS 常驻高位 ≠ 泄漏 len(gc.get_objects()) # 判泄漏看对象数/堆统计是否无界增长
__dict__(可动态加属性, 费内存)。声明 __slots__ 后按布局存储, 百万实例场景省 40%+ 内存, 代价是不能再随意加属性。
class Slots: __slots__ = ('x', 'y') # 关键: 实例不挂 __dict__, 百万级省 40%+ s = Slots(); s.x = 1 # 按固定 slot 布局存取 s.z = 2 # → AttributeError: 不能再动态加属性
import sys sys.getsizeof([]) # → 56 — 空 list 头 (64 位) lst = [0] * 1000 # 关键: >512B 的大块直接走系统 malloc sys.getsizeof(lst) # → 8056 — append 扩容还有 ≈1/8 余量
现象: Python 服务 RSS 每天涨 200MB。用 tracemalloc 两个时间点快照对比, 精确到代码行:
import tracemalloc tracemalloc.start() snap1 = tracemalloc.take_snapshot() # …… 跑一段时间 / 处理一批请求后 snap2 = tracemalloc.take_snapshot() for stat in snap2.compare_to(snap1, 'lineno')[:10]: print(stat) # 输出: 文件:行号 增量字节数 增量块数
常见凶手: 模块级 list/dict 当缓存只进不出、异常对象带整条 traceback 被存进日志集合、闭包捕获了大 response。
风控特征服务加载 300 万个实体对象, 默认 dict 布局 ~1.9GB, 三板斧降到 ~0.9GB:
class Feature: __slots__ = ('uid', 'score', 'tags') # 1. 去掉实例 dict features = [Feature(*row) for row in cursor] # 2. 惰性分页加载, 别一次全进内存 tags = tuple(t for t in raw_tags) # 3. tuple 代替 list (省 header + 不可变可共享)
无上缓存的缓存 = 定时炸弹。函数级用带 maxsize 的 lru_cache, 服务级用 cachetools:
from functools import lru_cache @lru_cache(maxsize=100_000) # 有上限! 不写 maxsize=无界 def get_region(ip): ... from cachetools import TTLCache cache = TTLCache(maxsize=50_000, ttl=300) # 容量 + 过期双保险
import 即占内存: 一个 Flask 应用顶层 import pandas 就白吃 80MB。重依赖延迟到真正使用的函数内:
def export_endpoint(): import pandas as pd # 首次调用才加载: 不用该功能的部署省 80MB+ df = pd.read_sql(...) return df.to_csv() # 验证基线: python -X importtime app.py 2>&1 | sort -t'|' -k2 -rn | head
网关聚合接口返回几 MB JSON, 换 orjson(Rust 实现)吞吐 5-10 倍、峰值更低, 还省二次编码:
import orjson from fastapi import Response @app.get("/aggregate") def aggregate(): data = build_big_payload() # dict/list 结构 return Response(orjson.dumps(data), # 直接产 bytes media_type="application/json")
分析任务别一次 read_csv 整个文件: chunksize 迭代 + 只读需要的列, 内存从 O(文件) 降到 O(块):
total = 0 for chunk in pd.read_csv("events.csv", chunksize=100_000, usecols=["user_id", "amount"]): # 只读两列 total += chunk.groupby("user_id")["amount"].sum() result = total.groupby(level=0).sum() # 分块部分和再合并
几 GB 的模型/索引文件不全读进内存, 按页惰性加载, 多进程共享同一份物理页:
import mmap, struct with open("index.bin", "rb") as f: mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) def read_record(i): off = i * 16 # 访问哪页, OS 才载哪页 return struct.unpack_from("<qd", mm, off) # (id, score) # fork 出的 worker 共享同一物理页 → 内存 ≈ 单份
预热完成后大部分对象永远活在 gen2, 每轮全代扫描白费 CPU — 冻结常驻对象 + 调大阈值:
def on_warmup_complete(): # 预热请求打完后调用一次 gc.collect() # 先收一轮干净 gc.freeze()() # 常驻对象移出扫描集 (PEP 570+) gc.set_threshold(50_000, 50, 50) # gen0 阈值 700 → 50k: 扫描少 70 倍
泄漏大头是"忘关": 文件、连接、游标全部上下文管理器化, 旧 API 用 closing 包:
from contextlib import closing with closing(make_legacy_client()) as client: # 老库没有上下文协议也能保证 close client.send(payload) # review 清单: 每个 acquire 必须配 with / try-finally, 无裸 open/connect
8 个 worker 各载 2GB 模型 = 16GB; fork 前(或启动早期)加载, COW 共享物理页:
# master.py — 先加载再 fork worker (gunicorn preload 等价) MODEL = load_2g_model() # 顶层加载 def handle(req): return MODEL.predict(req) # 只读不写 → COW 页不复制 → 8 worker 仍 ≈ 2GB # gunicorn --preload app:app 即此模式; 写操作会触发页复制, 保持模型只读
RESULTS = [] 模块级列表每个请求 append。正解: deque(maxlen=N) / 有界缓存 / 定期清理。
RESULTS = [] # 错: 模块级 list 只进不出, RSS 无界涨 RESULTS.append(row) RESULTS = deque(maxlen=10_000) # 对: 有界集合, 满了自动挤掉最旧
def f(acc=[]) 的 list 跨调用共享且常驻。正解: 默认 None, 函数体内 acc = acc or []。
def f(acc=[]): # 错: list 跨调用共享且常驻 acc.append(1); return acc # f(); f() → [1], [1, 1] def f(acc=None): # 对: 默认 None, 函数体内新建 acc = acc or []; ...
except Exception as e 后把 e 存起来: traceback 持有整条栈帧(所有局部变量)。正解: 存 repr(e) 或 traceback.format_exc() 字符串。
except Exception as e: errs.append(e) # 错: e 带整条 traceback, 锁住栈帧全部局部变量 except Exception as e: errs.append(repr(e)) # 对: 存 repr(e) / format_exc() 字符串
@lru_cache 无参 = maxsize 128(还行), 但自己写 dict 当缓存无界。正解: 一切缓存必须有容量/TTL, 并监控 hit rate 决定大小。
cache = {} # 错: 自写 dict 缓存无界, 键集合只涨不跌
@lru_cache(maxsize=100_000) # 对: 一切缓存必须有容量/TTL 上限
def get_region(ip): ...# 错: RSS 不降就断定泄漏重启 — pymalloc/glibc 本来就少还内存 del big; gc.collect() # 对: 先看对象数/堆统计是否无界增长 snap2.compare_to(snap1, 'lineno')[:10] # tracemalloc 定位真正增长点
__del__ 里的清理逻辑可能不执行(gc 需两轮)。正解: 用 weakref.finalize 或上下文管理器, 不依赖 __del__。
class Res: # 错: 清理押在 __del__ — 环要 gc 两轮, 异常还会被吞 def __del__(self): self.close() weakref.finalize(res, cleanup) # 对: 注册收尾钩子, 对象回收时保证调用
a is 256 恰好为 True(CPython 缓存 -5~256 与驻留字符串), 换个解释器/更长字符串就翻车。正解: 永远用 == 比值, is 只用于 None/单例哨兵。
a = 256; b = 256 a is b # → True: CPython 缓存 -5~256, 纯实现巧合 a = 257; b = 257; a is b # 错: 257 起不保证同对象 — is 结果不可依赖 a == b # 对: 比值永远用 ==, is 只给 None/单例
grid = [[0]*3]*3 三行是同一个 list, 改一处三处全变。正解: [[0]*3 for _ in range(3)]; 所有"乘法造容器"都要检查元素是否可变。
grid = [[0] * 3] * 3 # 错: 三行是同一个 list grid[0][0] = 9 # → [[9,0,0],[9,0,0],[9,0,0]] 三行全变 grid = [[0] * 3 for _ in range(3)] # 对: 每行独立新建
copy = data[:] 只复制外层, 嵌套的 list/dict 仍共享子对象, 一边 clear 另一边数据消失。正解: copy.deepcopy, 或改造为不可变结构(tuple/冻结模型)。
copy = data[:] # 错: 只复制外层, 嵌套子对象仍共享 copy[0].clear() # data[0] 也跟着空了 copy = copy.deepcopy(data) # 对: 深拷贝, 或改造为不可变结构
t[0] += [1] 抛 TypeError 却修改已生效(先改再报错), 事务性混乱。正解: 元组里不放可变元素; 需要修改就用 list 套 list。
t = ([1], 2) t[0] += [9] # 错: → TypeError 抛出, 但 t 已变成 ([1, 9], 2) t[0].append(9) # 对: 元组里不放可变元素; 要改就 list 套 list
for x in lst: lst.remove(x) 跳元素且 O(n²)。正解: 推导式重建 lst = [x for x in lst if keep(x)], 或倒序索引删除。
for x in lst: lst.remove(x) # 错: 跳元素 + O(n²) lst = [x for x in lst if keep(x)] # 对: 推导式重建; 或倒序索引删
d = {[1, 2]: 'v'} # 错: → TypeError: unhashable type: 'list'
d = {(1, 2): 'v'} # 对: 标识用不可变 tuple, 载荷才用 listweakref.ref 或 weakref.WeakSet。
child.parent = parent # 错: 与 parent.children 互指, 整棵树靠 gc 才能收 child.parent = weakref.ref(parent) # 对: 反向引用用 weakref.ref / WeakSet
engine.dispose() 后重连), 或 spawn 模式天然规避。
# 错: 子进程复制父进程的 DB 连接/socket, 同一 fd 两个进程写 → 协议错乱 engine.dispose(close=False) # 对: worker 初始化钩子里重连 set_start_method('spawn') # 或: spawn 不继承连接, 天然规避
gc.enable(); gc.collect() 收口。
gc.disable() # 错: 常驻服务禁完不恢复, 循环引用垃圾持续堆积 # 对: 短命批任务可用, 任务结束必须收口 gc.enable(); gc.collect()
df[df.a>1].b = 2 改的是临时副本, 警告都没看清就上线了。正解: df.loc[mask, 'b'] = 2; 理解 copy-on-write 语义, 大 df 还省一份内存。
df[df.a > 1].b = 2 # 错: 改的是临时副本, 原 df 没变 (还带警告) df.loc[df.a > 1, 'b'] = 2 # 对: loc 直接定位原 df, 语义明确
f.readlines() 一口气造完整列表, GB 级文件直接 OOM。正解: for line in f 惰性迭代(文件对象就是迭代器), 需要随机访问再考虑 mmap。
lines = f.readlines() # 错: GB 级文件一口气全列表 → OOM for line in f: ... # 对: 文件对象天生是惰性迭代器, 逐行流过
pickle.loads(blob) 需要源 bytes + 展开后的对象图同时驻留, 峰值 ≈ 2 倍。正解: 超大状态分块 pickle, 或用逐条记录格式(jsonl/parquet)流式恢复。
obj = pickle.loads(blob) # 错: 源 bytes + 展开对象图同时驻留, 峰值 ≈ 2 倍 for rec in read_jsonl(path): ... # 对: 超大状态分块 pickle / 逐条流式恢复
lambda x=x: ... 或 functools.partial。
fns = [lambda: i for i in range(3)] [f() for f in fns] # 错: → [2, 2, 2] — 闭包拿到的是变量本身 fns = [lambda i=i: i for i in range(3)] # 对: 默认参数固化 → [0, 1, 2]
__del__ 时机不确定(循环引用要等 gc 多轮), 异常还会被吞。正解: weakref.finalize(obj, cleanup) 或显式 with/close, __del__ 只做最后兜底。
class Res: # 错: __del__ 时机不确定, 环要等 gc 多轮, 异常被吞 def __del__(self): ... weakref.finalize(res, cleanup) # 对: 显式注册收尾; 或 with/close, __del__ 只兜底