uWSGI定時器導致web.py的內存泄露問題

近期開發了一個小型Web應用,使用了uWSGI和web.py,遇到了一個內存泄露問題折騰了好久,記錄一下,希望可以幫助別人少踩坑。

P.S. 公司項目,我不能把完整代碼貼上來,所以大部分是文字說明,以下配置文件中的路徑也是虛構的。

環境說明

  • Ubuntu 13.10

  • uWSGI 1.9.13

  • web.py 0.37

  • sqlite3 3.7.17 2013-05-20

  • nginx 1.4.7

nginx配置作爲Web前端,通過domain socket和uWSGI服務器交互:

server {
    listen xxxx;
    access_log off;

    location / {
        uwsgi_pass unix:///tmp/app/uwsgi.sock;
        include uwsgi_params;
    }
}

uWSGI配置:

[uwsgi]

app_path = /spec/app
log_dir = /log/app
tmp_dir = /tmp/app

master = true
processes = 4
threads = 2

pidfile = %(tmp_dir)/uwsgi.pid
socket = %(tmp_dir)/uwsgi.sock
chdir = %(app_path)
plugin = python
module = index

daemonize = %(log_dir)/uwsgi.log
log-maxsize = 1000000
log-truncate = true
disable-logging = true

reload-on-as = 30
reload-on-rss = 30

問題現象

該應用使用了uWSGI提供的定時器功能來執行定時任務,發現運行一段時間後就會有內存泄露。仔細觀察發現,即使沒有外部請求,也會有內存泄露;有時候外部請求會使得泄露的內存被回收。

問題分析

泄露發生在定時器函數中?

在應用中使用uWSGI的定時器功能的代碼如下:

import uwsgi

# add timers
timer_list = [
    # signal, callback, timeout, who
    (98, modulea.timer_func, modulea.get_interval, ""),
    (99, users.timer_func, 60, ""),
]

for timer in timer_list:
    uwsgi.register_signal(timer[0], timer[3], timer[1])
    if callable(timer[2]):
        interval = timer[2]()
    else:
        interval = timer[2]
    uwsgi.add_timer(timer[0], interval)

因爲之前使用過同樣的環境開發了另一個應用,沒用使用uWSGI的定時器,所以懷疑內存泄露是定時器導致的。

  1. 首先,刪掉定時器後,發現uWSGI進程不會發生內存泄露了。確定是定時器中的代碼導致的內存泄露。

  2. 然後把定時器中的代碼放到一個請求處理函數中去執行,通過構造HTTP請求來觸發代碼執行。結果是沒有內存泄露。因此,結論是同一段代碼在定時器中執行有內存泄露,在請求處理代碼中執行沒有內存泄露。

  3. 這個實驗也把導致內存泄露的代碼鎖定到了users.timer_func函數中,其他函數都沒有內存泄露問題。

啥東西泄露了?

users.timer_func函數只作了一件事情,就是從sqlite3數據庫中讀取用戶表,修改所有用戶的某些狀態值。先來看下代碼:

def update_users():
    user_list = Users.objects.all()
    if user_list is None:
        return

    for eachuser in user_list:
        # update eachuser's attributes
        ...
        # do database update
        eachuser.update()


def timer_func(signal_num):
    update_users()
 

Users類是一個用戶管理的類,父類是Model類。其中的Users.objects.all()是通過Model類的一個新的元類實現的,主要代碼如下:

def all(self):
    db = _connect_to_db()
    results = db.select(self._table_name)
    return [self.cls(x) for x in results]

也就是利用web.py的數據庫API連接到數據庫,然後讀取一張表的所有行,把每一行的都實例化成一個Users實例。

綜上所述,導致內存泄露的users.timer_func函數主要的操作就是創建數據庫連接,然後讀寫數據表。這個時候,我們可以猜測內存泄露可能是數據庫連接沒關導致的,因爲我們自己創建的Users實例在函數退出後應該都被回收了。

如何驗證這個猜測呢?因爲sqlite數據庫是文件型數據庫,進程中每個連接相當於打開一個文件描述符,所以可以使用lsof命令查看uWSGI到底打開了多少次數據庫文件:

# 假設2771是其中一個uWSGI進程的PID
$lsof -p 2771 | grep service.db | wc -l

通過不斷執行這個命令,我們發現如下規律:

  1. 如果是在定時器中執行數據庫操作,每次執行都打開數據庫文件一次,但是沒有關閉(上述命令輸出的值在增加)

  2. 如果是請求處理函數中執行數據庫操作,則數據庫文件被打開後會被關閉(上述命令輸出的值不變)

到這邊我們可以確認,泄露的是數據庫連接對象,而且只有在定時器函數中才會泄露,在請求處理函數中不會

爲啥數據庫連接會泄露?

這個問題困擾了我很久。最後採用最笨的辦法去解決 -- 閱讀web.py的源碼。通過閱讀源碼可以發現,web.py的數據庫操作主要代碼是在class DB中,真正的數據庫連接則存放在DB類的_ctx成員中。

class DB: 
    """Database"""
    def __init__(self, db_module, keywords):
        """Creates a database.
        """
        # some DB implementaions take optional paramater `driver` to use a specific driver modue
        # but it should not be passed to connect
        keywords.pop('driver', None)

        self.db_module = db_module
        self.keywords = keywords

        self._ctx = threadeddict()
        ...
            
    def _getctx(self): 
        if not self._ctx.get('db'):
            self._load_context(self._ctx)
        return self._ctx
    ctx = property(_getctx)
    ...
    

其他具體操作代碼就不貼了,這裏的關鍵信息是self._ctx = threadeddict()。這說明了數據庫連接是thread local對象,即線程獨立變量,在線程被銷燬時會自動回收,否則就一直保存着,除非手動銷燬。可以查看Python的threading.local的文檔。於是,我開始懷疑,是不是uWSGI的定時器線程一直沒有銷燬,而處理請求的線程則是每次處理請求後都銷燬,導致了數據庫連接的泄露呢?

爲了證實這個猜想,繼續作實驗。這次用上了gc模塊(也可以用objgraph模塊,不過這個問題中gc已經夠用了)。將下面代碼分別加入到定時器函數中和請求處理函數中:

objlist = gc.get_objects()
print len([x for x in objlist if isinstance(x, web.ThreadedDict)])

然後我們可以在uWSGI的log中看到ThreadedDict的統計值。結果果然如我們所猜想的:不斷執行定時器函數會讓這個統計值不斷增加,而請求處理函數中則不會

所以,我們也就找到了數據庫連接泄露的原因,也就是內存泄露的原因:uWSGI中定時器函數所對應的線程不會主動銷燬thread local數據,導致thread local數據沒有被回收

由於每個uWSGI進程可能只開啓一個線程,也可能有多個線程,因此可以總結的情況大概有如下幾種:

  • 只有一個線程時:如果該線程一直在運行定時器函數,則在此期間該進程不會重新初始化,thread local對象不會被回收。當該線程處理請求時,會重新初始化線程,thread local對象會被回收,釋放的內存會被回收。

  • 當有多個線程時:每個線程自身的情況和上面描述的一致,不過有可能出現一個線程一直在運行定時器函數的情況(也就是內存一直泄露)。

解決方案

在定時器函數退出前,清除web.py存放在thread local中的對象。代碼如下:

def timer_func(signal_num):
    update_users()

    # bypass uWSGI timer function bug: timer thread doesn't release
    # thread local resource.
    web.ThreadedDict.clear_all()

P.S.

  1. 該方法目前還沒發現副作用,如果有的話那就是把別人存放的數據也給清除了。

  2. 其他版本的uWSGI服務器沒測試過。

爲啥處理請求的線程不會有內存泄露呢?

處理請求的線程爲什麼就可以主動銷燬thread local的數據呢?難道uWSGI對不同的線程有區別對待?其實不是的,如果一個線程在處理HTTP請求時,會調用WSGI規範定義的接口,web.py在實現這個接口的時候,先執行了ThreadedDict.clear_all(),所以所有thread local數據都被回收了。定時器線程是直接調用我們的函數,如果我們不主動回收這些數據,那麼就泄露了。我們可以看下web.py的WSGI接口實現(在web/application.py文件中):

class application:
    ...
    def _cleanup(self):
        # Threads can be recycled by WSGI servers.
        # Clearing up all thread-local state to avoid interefereing with subsequent requests.
        utils.ThreadedDict.clear_all()

    def wsgifunc(self, *middleware):
        """Returns a WSGI-compatible function for this application."""
        ...        
        def wsgi(env, start_resp):
            # clear threadlocal to avoid inteference of previous requests
            self._cleanup()

            self.load(env)
            try:
                # allow uppercase methods only
                if web.ctx.method.upper() != web.ctx.method:
                    raise web.nomethod()

                result = self.handle_with_processors()
                if is_generator(result):
                    result = peep(result)
                else:
                    result = [result]
            except web.HTTPError, e:
                result = [e.data]

            result = web.safestr(iter(result))

            status, headers = web.ctx.status, web.ctx.headers
            start_resp(status, headers)
            
            def cleanup():
                self._cleanup()
                yield '' # force this function to be a generator
                            
            return itertools.chain(result, cleanup())

        for m in middleware: 
            wsgi = m(wsgi)

        return wsgi

默認的入口函數是class application的wsgifunc()函數的內部函數wsgi(),它第一行就調用了self._cleanup()

原文地址:https://segmentfault.com/a/1190000002552765


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