python 進程內存增長問題, 解決方法和工具

原文鏈接:http://drmingdrmer.github.io/tech/programming/2017/05/06/python-mem.html

表現

運行環境:

# 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進程, 來查看它的運行狀態:

# gdb python 11122

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) generate-core-file

這條命令將當前gdb attach的程序dump到它的運行目錄, 名字爲core.<pid>, 然後再用gdb 加載這個core文件, 進行打印堆棧, 查看變量等分析, 無需attach到正在運行的程序:

# gdb python core.<pid>

其他命令

其他命令可以在gdb輸入py<TAB><TAB> 看到, 和gdb的命令對應, 例如:

(gdb) py
py-bt               py-list             py-print            python
py-down             py-locals           py-up               python-interactive
  • py-uppy-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
...

連接到有問題的程序上, 開始收集信息:

pyrasite-shell <pid>
>>>

接下來就可以在<pid>的進程裏調用任意的python代碼, 來查看進程的狀態.

下面是幾個小公舉(特麼的輸入法我是說工具..)可以用來在進程內查看內存狀態的:

psutil 查看python進程狀態

pip install psutil

首先看下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個例子來演示如何生成不能回收的對象:

不可回收對象的例子 🌰

uncollectible.py

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章