进程一退出,top 里的 RSS 掉没了。 这时候还纠结 del obj、gc.collect() 有没有跑完,其实方向有点偏。 我一般先分清两件事:Python 有没有优雅清理对象,和 操作系统有没有回收这个进程占过的内存。
这俩不是一回事。
看一段小脚本:
# mem_exit_probe.py
import os
import time
bags = []
defrss_hint():
with open(f"/proc/{os.getpid()}/status") as f:
for line in f:
if line.startswith("VmRSS"):
return line.strip()
for i in range(10):
bags.append(bytearray(40 * 1024 * 1024))
print(f"round={i + 1}, {rss_hint()}")
time.sleep(0.5)
print("pid =", os.getpid())
print("press enter to exit")
input()
另开一个窗口看:
python3 mem_exit_probe.py
watch -n 1 "ps -o pid,rss,cmd -p <pid>"
你会看到进程活着的时候,RSS 一路往上涨。按回车退出,进程没了,RSS 也没了。
所以第一层答案很直接:Python 程序退出后,这个进程的虚拟地址空间会被操作系统回收,普通堆内存、栈内存、mmap 映射,大多数都不会继续挂在系统里。
但这里别急着下结论,说“所有内存都释放了”。这个说法太粗。
Python 退出时,解释器会做一轮收尾,比如跑 atexit,释放一部分模块、对象、缓冲区。正常退出大概是这样:
# graceful_exit.py
import atexit
import os
classTmpHolder:
def__init__(self):
self.buf = bytearray(100 * 1024 * 1024)
def__del__(self):
print("__del__ called")
holder = TmpHolder()
@atexit.register
defclose_probe():
print("atexit called, pid =", os.getpid())
print("normal exit")
执行后大概率能看到:
normal exit
atexit called, pid = 7312
__del__ called
但这地方我不太建议把 __del__ 当成线上资源释放的主力。解释器关闭阶段,模块全局变量可能已经被清掉了,对象之间如果有循环引用,析构顺序也不一定让你舒服。
更狠一点,换成这个:
# hard_exit.py
import atexit
import os
buf = bytearray(200 * 1024 * 1024)
@atexit.register
defbye():
print("you will not see me")
print("pid =", os.getpid())
os._exit(7)
os._exit() 是直接让进程退出,不走 Python 那套清理流程。atexit 不跑,finally 不跑,很多对象析构也不跑。
但是,进程没了,操作系统仍然会把这个进程的地址空间收掉。 这就是区别:Python 没收尾,不代表内存还归这个进程占着。进程都没了。
再看一个更容易误判的现场。
有时候线上服务内存涨了,代码里明明 del 了,gc.collect() 也打了,RSS 就是不降。然后有人说 Python 内存泄漏。
不一定。
# rss_not_drop.py
import gc
import os
import time
defrss():
with open(f"/proc/{os.getpid()}/status") as f:
return [x for x in f if x.startswith("VmRSS")][0].strip()
rows = []
for i in range(500_000):
rows.append({
"uid": i,
"name": f"user_{i}",
"tags": ["vip", "paid", "active"]
})
print("after alloc:", rss())
del rows
gc.collect()
print("after del:", rss())
time.sleep(30)
这里经常会看到一个现象:对象确实没了,但 RSS 没明显下降。
这地方第一眼别骂 GC。CPython 自己有内存分配器,小对象会走一套池化管理。释放对象后,那些内存块可能先留在解释器里复用,不一定马上还给操作系统。再往下还有 glibc 的 malloc 行为,也不保证你释放一块内存,RSS 马上漂亮地掉下去。
所以判断 Python 内存问题,不能只盯 RSS。 我一般会同时看这几个东西:
import gc
import tracemalloc
tracemalloc.start()
# 跑一段业务逻辑
# ...
snapshot = tracemalloc.take_snapshot()
for item in snapshot.statistics("lineno")[:10]:
print(item)
print("gc objects:", len(gc.get_objects()))
tracemalloc 能看到 Python 层对象主要分配在哪里。 如果 tracemalloc 没涨,RSS 涨得很凶,那我会开始怀疑:是不是 native 扩展、numpy、pandas、图片库、压缩库、驱动层在吃内存。
比如这种就不是普通 Python 对象:
# native_alloc_linux.py
import ctypes
import os
import time
libc = ctypes.CDLL(None)
libc.malloc.restype = ctypes.c_void_p
libc.memset.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_size_t]
ptrs = []
for i in range(8):
p = libc.malloc(50 * 1024 * 1024)
ifnot p:
raise MemoryError("malloc failed")
libc.memset(p, 1, 50 * 1024 * 1024)
ptrs.append(p)
print("native block:", i + 1, "pid:", os.getpid())
time.sleep(1)
print("not calling free, press enter to exit")
input()
这段代码故意不 free。进程活着的时候,这就是泄漏。 但进程退出后,操作系统还是会把这部分内存收掉。
这也是为什么很多批处理脚本偷懒能活得挺好:跑一次,吃一坨内存,跑完进程退出,系统擦屁股。 但 Web 服务、消费者、定时常驻进程不行。它们不退出,泄漏就一直攒,最后 OOM。
还有一类别混进去:进程退出后,不是所有“资源”都会消失。
普通内存会回收,但这些东西要单独看:
# file_left.py
from pathlib import Path
p = Path("/tmp/import_task.lock")
p.write_text("running")
raise RuntimeError("boom")
程序崩了,/tmp/import_task.lock 还在。 这不是内存,但线上事故里经常被一起算成“资源没释放”。
类似的还有临时文件、数据库里没提交完的业务状态、外部服务里的会话、消息队列里未 ack 的消息、你手动创建的共享内存名字。操作系统能回收进程自己的内存,不代表能替你把业务现场也擦干净。
我平时写这类脚本,会把关键资源放进上下文管理器里,别赌退出时析构:
from pathlib import Path
import tempfile
defexport_user_snapshot(user_ids):
tmp_dir = Path(tempfile.mkdtemp(prefix="user_export_"))
marker = tmp_dir / "RUNNING"
marker.write_text("1")
try:
out = tmp_dir / "users.csv"
with out.open("w", encoding="utf-8") as f:
f.write("uid,status\n")
for uid in user_ids:
f.write(f"{uid},ok\n")
marker.rename(tmp_dir / "DONE")
return out
except Exception:
marker.rename(tmp_dir / "FAILED")
raise
这里不是为了“释放内存”,而是为了让程序异常退出时,现场能看懂。线上排障时,一个 FAILED 文件比一堆猜测管用。
回到问题本身。
Python 程序退出时,操作系统会回收这个进程占用的内存。正常退出时,Python 解释器还会尽量做一轮对象清理。可如果是 os._exit()、SIGKILL、容器被强杀,Python 层的清理逻辑可能根本没机会执行。
所以写代码时别把“进程退出会释放内存”当成免死金牌。
短命脚本,可以接受退出兜底。 常驻服务,内存涨了就要查引用、查缓存、查 native 扩展。 外部资源,该 close 就 close,该 with 就 with,该落状态就落状态。
内存最后会被系统收走。 但线上留下的烂摊子,不一定。