近期開發了一個小型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的定時器,所以懷疑內存泄露是定時器導致的。
-
首先,刪掉定時器後,發現uWSGI進程不會發生內存泄露了。確定是定時器中的代碼導致的內存泄露。
-
然後把定時器中的代碼放到一個請求處理函數中去執行,通過構造HTTP請求來觸發代碼執行。結果是沒有內存泄露。因此,結論是同一段代碼在定時器中執行有內存泄露,在請求處理代碼中執行沒有內存泄露。
-
這個實驗也把導致內存泄露的代碼鎖定到了
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
通過不斷執行這個命令,我們發現如下規律:
-
如果是在定時器中執行數據庫操作,每次執行都打開數據庫文件一次,但是沒有關閉(上述命令輸出的值在增加)
-
如果是請求處理函數中執行數據庫操作,則數據庫文件被打開後會被關閉(上述命令輸出的值不變)
到這邊我們可以確認,泄露的是數據庫連接對象,而且只有在定時器函數中才會泄露,在請求處理函數中不會。
爲啥數據庫連接會泄露?
這個問題困擾了我很久。最後採用最笨的辦法去解決 -- 閱讀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.
-
該方法目前還沒發現副作用,如果有的話那就是把別人存放的數據也給清除了。
-
其他版本的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