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()

你可能感兴趣的:(uwsgi,web.py)