摘要.
06 May 2017 on tech programming
运行环境:
# uname -a
Linux ** 3.10.0-327.el7.x86_64 #1 SMP Thu Nov 19 22:10:57 UTC 2015 x86_64 x86_64 x86_64 GNU/Linux
# python2 --version
Python 2.7.5
# cat /etc/*-release
CentOS Linux release 7.2.1511 (Core)
python 程序在长时间 (较大负载) 运行一段时间后, python 进程的系统占用内存持续升高:
# ps aux | grep python2
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 124910 10.2 0.8 5232084 290952 ? Sl Mar17 220:37 python2 offline.py restart
# ~~~~~~
# 290M 内存占用
这里的 python 进程在经历大量请求处理过程中, 内存持续升高, 但最终负载压力下降之后, 内存个并没有下降.
为了节省读者时间, 这里先给出结论, 后面再记录详细的排查步骤.
我们分几个步骤逐步定位到问题所在:
- 首先确定当时程序在做什么, 是否有异常行为.
- 排除行为异常之后, 查看 python 的内存使用情况, 是否所有该回收的对象都回收了.
- 排除垃圾回收等 python 内部的内存泄漏问题后, 定位到时 libc 的 malloc 实现的问题.
而最后的解决方法也很简单, 直接替换 malloc 模块为 tcmalloc:
LD_PRELOAD="/usr/lib64/libtcmalloc.so" python x.py
# gdb-python: 搞清楚 python 程序在做什么
首先要确定 python 在做什么, 是不是有正常的大内存消耗任务在运行, 死锁等异常行为.
这方面可以用 gdb 来帮忙, 从 gdb-7 开始, gdb 支持用 python 来实现 gdb 的扩展. 我们可以像调试 c 程序那样, 用 gdb 对 python 程序检查线程, 调用栈等.
而且可以将 python 代码和内部的 c 代码的调用栈同时打印出来.
这样对不确定是 python 代码问题还是其底层 c 代码的问题的时候, 很有帮助.
以下步骤的详细信息可以参考 debug-with-gdb.
# 准备 gdb
首先安装 python 的 debuginfo:
# debuginfo-install python-2.7.5-39.el7_2.x86_64
如果缺少 debuginfo, 运行后面的步骤 gdb 会提示 blabla, 按照提示安装完继续就好:
Missing separate debuginfos, use: debuginfo-install python-2.7.5-39.el7_2.x86_64
# 接入 gdb
然后我们可以直接用 gdb attach 到 1 个 python 进程, 来查看它的运行状态:
attach 之后进入了 gdb, 能做的事情就多了. 几个基本的检查步骤:
# 查看线程
(gdb) info threads
Id Target Id Frame
206 Thread 0x7febdbfe3700 (LWP 124916) "python2" 0x00007febe9b75413 in select () at ../sysdeps/unix/syscall-template.S:81
205 Thread 0x7febdb7e2700 (LWP 124917) "python2" 0x00007febe9b75413 in select () at ../sysdeps/unix/syscall-template.S:81
204 Thread 0x7febdafe1700 (LWP 124918) "python2" 0x00007febe9b75413 in select () at ../sysdeps/unix/syscall-template.S:81
203 Thread 0x7febda7e0700 (LWP 124919) "python2" 0x00007febe9b7369d in poll () at ../sysdeps/unix/syscall-template.S:81
一般加锁死锁差不多可以在这里看到, 会有线程卡在 xx_wait 之类的函数上.
之前用这个方法定位了 1 个 python-logging 模块引起的, 在多线程的进程中运行 fork, 导致 logging 的锁被锁住后 fork 到新的进程, 但解锁线程没有 fork 到新进程而造成的死锁问题.
# 查看调用栈
如果发现某个线程有问题, 切换到那个线程上, 查看调用栈确定具体的执行步骤, 使用bt
命令:
(gdb) bt
#16 0x00007febea8500bd in PyEval_EvalCodeEx (co=<optimized out>, globals=<optimized out>, locals=locals@entry=0x0, args=<optimized out>,
argcount=argcount@entry=1, kws=0x38aa668, kwcount=2, defs=0x3282a88, defcount=2, closure=closure@entry=0x0)
at /usr/src/debug/Python-2.7.5/Python/ceval.c:3330
...
#19 PyEval_EvalFrameEx (
f=f@entry=Frame 0x38aa4d0, for file t.py, line 647, in run (part_num=2, consumer=<...
bt
命令不仅可以看到 c 的调用栈, 还会显示出 python 源码的调用栈, 想上面 frame-16 是 c 的, frame-19 显示出在 python 的源代码对应哪 1 行.
如果只查看 python 的代码的调用栈, 使用py-bt
命令:
(gdb) py-bt
#1 <built-in method poll of select.epoll object at remote 0x7febeacc5930>
#3 Frame 0x3952450, for file /usr/lib64/python2.7/site-packages/twisted/internet/epollreactor.py, line 379, in doPoll (self=<...
l = self._poller.poll(timeout, len(self._selectables))
#7 Frame 0x39502a0, for file /usr/lib64/python2.7/site-packages/twisted/internet/base.py, line 1204, in mainLoop (self=<...
py-bt
显示出 python 源码的调用栈, 调用参数, 以及所在行的代码.
# coredump
如果要进行比较长时间的跟踪, 最好将 python 程序的进程信息全部 coredump 出来, 之后对 core 文件进行分析, 避免影响正在运行的程序.
这条命令将当前 gdb attach 的程序 dump 到它的运行目录, 名字为core.<pid>
, 然后再用 gdb 加载这个 core 文件, 进行打印堆栈, 查看变量等分析, 无需 attach 到正在运行的程序:
# 其他命令
其他命令可以在 gdb 输入py<TAB><TAB>
看到, 和 gdb 的命令对应, 例如:
(gdb) py
py-bt py-list py-print python
py-down py-locals py-up python-interactive
py-up
,py-down
可以用来移动到 python 调用站的上一个或下一个 frame.py-locals
用来打印局部变量
等等等等. gdb 里也可以用help
命令查看帮助:
(gdb) help py-print
Look up the given python variable name, and print it
在这次追踪过程中, 用 gdb-python 排除了程序逻辑问题. 然后继续追踪内存泄漏问题:
# pyrasite: 连接进入 python 程序
pyrasite 是 1 个可以直接连上一个正在运行的 python 程序, 打开一个类似 ipython 的交互终端来运行命令来检查程序状态.
这给我们的调试提供了非常大的方便. 简直神器.
安装:
# pip install pyrasite
...
# pip show pyrasite
Name: pyrasite
Version: 2.0
Summary: Inject code into a running Python process
Home-page: http://pyrasite.com
Author: Luke Macken
...
连接到有问题的程序上, 开始收集信息:
接下来就可以在<pid>
的进程里调用任意的 python 代码, 来查看进程的状态.
下面是几个小公举 (特么的输入法我是说工具..) 可以用来在进程内查看内存状态的:
# psutil 查看 python 进程状态
首先看下 python 进程占用的系统内存 RSS:
pyrasite-shell 11122
>>> import psutil, os
>>> psutil.Process(os.getpid()).memory_info().rss
29095232
基本和 ps 命令显示的结果一致
rss the real memory (resident set) size of the process (in 1024 byte units).
# guppy 取得内存使用的各种对象占用情况
guppy 可以用来打印出各种对象各占用多少空间, 如果 python 进程中有没有释放的对象, 造成内存占用升高, 通过 guppy 可以查看出来:
同样, 以下步骤是在通过 pyrasite-shell, attach 到目标进程后操作的.
# pip install guppy from guppy import hpy
h = hpy()
h.heap()
# Partition of a set of 48477 objects. Total size = 3265516 bytes.
# Index Count % Size % Cumulative % Kind (class / dict of class)
# 0 25773 53 1612820 49 1612820 49 str
# 1 11699 24 483960 15 2096780 64 tuple
# 2 174 0 241584 7 2338364 72 dict of module
# 3 3478 7 222592 7 2560956 78 types.CodeType
# 4 3296 7 184576 6 2745532 84 function
# 5 401 1 175112 5 2920644 89 dict of class
# 6 108 0 81888 3 3002532 92 dict (no owner)
# 7 114 0 79632 2 3082164 94 dict of type
# 8 117 0 51336 2 3133500 96 type
# 9 667 1 24012 1 3157512 97 __builtin__.wrapper_descriptor
# <76 more rows. Type e.g. '_.more' to view.>
h.iso(1,[],{})
# Partition of a set of 3 objects. Total size = 176 bytes.
# Index Count % Size % Cumulative % Kind (class / dict of class)
# 0 1 33 136 77 136 77 dict (no owner)
# 1 1 33 28 16 164 93 list
# 2 1 33 12 7 176 100 int
通过以上步骤, 可以看出并没有很多 python 对象占用更大内存.
# 无法回收的对象
python 本身是有垃圾回收的, 但 python 程序中有种情况是对象无法被垃圾回收掉 (uncollectable object), 满足 2 个条件:
- 循环引用
- 循环引用的链上某个对象定义了
__del__
方法.
官方的说法是, 循环引用的一组对象被 gc 模块识别为可回收的, 但需要先调用每个对象上的__del__
方法, 才能回收. 但用户自定义了__del__
的对象, gc 系统不知道应该先调用环上的哪个__del__
. 因此无法回收这类对象.
不能回收的 python 对象会持续占据内存, 当问题查到这里时我们怀疑有不能被回收的对象导致内存持续升高.
于是我们尝试列出所有不能回收的对象.
后来确定不是这种问题引起的内存不释放. 不能回收任然可以通过
gc.get_objects()
列出来, 并会在gc.collect()
调用后被加入到gc.garbage
的 list 里. 但我们没有发现这类对象的存在.
查找 uncollectable 的对象:
pyrasite-shell 11122
>>> import gc
>>> gc.collect() # first run gc, find out uncollectable object and put them in gc.garbage
# output number of object collected
>>> gc.garbage # print all uncollectable objects
[] # empty
如果在上面最后一步打印出了任何不能回收的对象, 则需要进一步查找循环引用链上在哪个对象上包含__del__
方法.
下面是 1 个例子来演示如何生成不能回收的对象:
# 不可回收对象的例子 🌰
from __future__ import print_function
import gc
'''
This snippet shows how to create a uncollectible object:
It is an object in a cycle reference chain, in which there is an object
with __del__ defined.
The simpliest is an object that refers to itself and with a __del__ defined.
> python uncollectible.py
======= collectible object =======
*** init, nr of referrers: 4
garbage: []
created: collectible: <__main__.One object at 0x102c01090>
nr of referrers: 5
delete:
*** __del__ called
*** after gc, nr of referrers: 4
garbage: []
======= uncollectible object =======
*** init, nr of referrers: 4
garbage: []
created: uncollectible: <__main__.One object at 0x102c01110>
nr of referrers: 5
delete:
*** after gc, nr of referrers: 5
garbage: [<__main__.One object at 0x102c01110>]
'''
def dd(*msg):
for m in msg:
print(m, end='')
print()
class One(object):
def __init__(self, collectible):
if collectible:
self.typ = 'collectible'
else:
self.typ = 'uncollectible'
# Make a reference to it self, to form a reference cycle.
# A reference cycle with __del__, makes it uncollectible.
self.me = self
def __del__(self):
dd('*** __del__ called')
def test_it(collectible):
dd()
dd('======= ', ('collectible' if collectible else 'uncollectible'), ' object =======')
dd()
gc.collect()
dd('*** init, nr of referrers: ', len(gc.get_referrers(One)))
dd(' garbage: ', gc.garbage)
one = One(collectible)
dd(' created: ', one.typ, ': ', one)
dd(' nr of referrers: ', len(gc.get_referrers(One)))
dd(' delete:')
del one
gc.collect()
dd('*** after gc, nr of referrers: ', len(gc.get_referrers(One)))
dd(' garbage: ', gc.garbage)
if __name__ == "__main__":
test_it(collectible=True)
test_it(collectible=False)
上面这段代码创建了 2 个对象, 1 个可以回收, 1 个不能回收, 他们 2 个都定义了__del__
方法, 唯一区别就是是否引用了自己 (从而构成了引用环).
如果在这个步骤发现了循环引用, 就要进一步查处哪些引用关系造成了循环引用, 进而破坏掉循环引用, 让对象变成可以回收的.
# objgraph 查找循环引用
# pip install objgraph
pyrasite-shell 11122
>>> import objgraph
>>> objgraph.show_refs([an_object], filename='sample-graph.png')
上面的例子中, 将在本地生成一个图片, 描述由可以由 an_object 引用到的关系图:
具体参考: objgraph
在这一步我们也没有找到不能回收的对象, 最后我们怀疑到时 glibc 的 malloc 的问题, 用 tcmalloc 替代 glibc 默认的 malloc 后问题得到修复.
# Archive
18 Oct 2020 后分布式时代: 多数派读写的'少数派'实现
20 Dec 2019 Art of Pull Requests(翻译)
21 Nov 2019 掐指算算: 你的 CDN 多花了几百万?
19 Nov 2019 一年的素描练习
30 Oct 2019 互联网中对象访问频率的 91 分布
09 Jan 2019 哄好面试官系列 - 1: 比较 2 个 python dict(多级) 是否相同
04 Nov 2018 存储中的文件合并策略优化
27 Sep 2018 软件工程是个面包机
26 Aug 2018 程序员必须知道的事情, 一般人我不告诉他
16 Aug 2018 cgexec 无法继承 LD_PRELOAD 环境变量
04 Aug 2018 mysql group replication 实践记录: 步骤, 问题和注意事项
13 Feb 2018 枚举所有整勾股数
03 Feb 2018 ansible 中的 include, include_tasks 和 import_tasks 的差别
20 Nov 2017 python 并发 subprocess.Popen 的坑
05 Aug 2017 程序员必读: 摸清 hash 表的脾性
06 May 2017 python 进程内存增长问题, 解决方法和工具
01 Feb 2017 xp 的分布式系统系列教程之: Erasure-Code: 工作原理, 数学解释, 实践和分析.
01 Feb 2017 xp 的分布式系统系列教程之: Erasure-Code: 工作原理, 数学解释, 实践和分析.
11 Nov 2015 可靠分布式系统基础 Paxos 的直观解释
28 Jul 2015 socket 关闭: close() 和 shutdown() 的差异
17 May 2015 随手改变世界之 git-auto-squash
17 Feb 2015 Numbers Programmers Should Know About Hash
11 Feb 2015 Vim-tabbar: Simple, stupid and fast tab-bar for VIM
24 Jul 2014 1% 慢请求优化
31 Jan 2014 Some useful resources
31 Jan 2014 jobq.py -- Queue processing engine
https://drmingdrmer.github.io/tech/programming/2017/05/06/python-mem.html#psutil-%E6%9F%A5%E7%9C%8Bpython%E8%BF%9B%E7%A8%8B%E7%8A%B6%E6%80%81 https://drmingdrmer.github.io/tech/programming/2017/05/06/python-mem.html#psutil-%E6%9F%A5%E7%9C%8Bpython%E8%BF%9B%E7%A8%8B%E7%8A%B6%E6%80%81