阻塞的Tornado

发布 : 2017-12-06 分类 : basics 浏览 : --

python-tornado 相对于其他的网络框架,最为人知的特点就是异步网络和非阻塞I/O,以及以用同步的方式写异步的代码等Tornado 。然鹅,在最自然(不做特别处理)的情况下,Tornado 其实是阻塞的。

先看一个阻塞的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import tornado.ioloop
import tornado.web
import time

class IndexHandler(tornado.web.RequestHandler):
def get(self):
self.write("index")

class Blocking(tornado.web.RequestHandler):
def get(self, *args, **kwargs):
time.sleep(10)
self.write("blocking")

if __name__ == '__main__':
tornado.web.Application(handlers=[
('/', IndexHandler),
('/blocking', Blocking)
], autoreload=True).listen(5000, 'localhost')
tornado.ioloop.IOLoop().current().start()

如果先访问 localhost:5000/blocking,那么在接下来的10s内再访问 localhost:5000 就会发现一直没有相应,一直到等到 10s 结束,处理完 blocking 的请求。(虽然这并不是一个正确的例子,但是接下来记录的正是对协程的进一步理解)

通过各方搜索,肯定能够找到下面的方法来解决整个Tornado阻塞在这个阻塞请求上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tornado.ioloop
import tornado.web
import time

from tornado import gen

class IndexHandler(tornado.web.RequestHandler):
def get(self):
self.write("index")

class Blocking(tornado.web.RequestHandler):
@gen.coroutine
def get(self, *args, **kwargs):
# time.sleep(10)
yield gen.sleep(10)
self.write("blocking")

if __name__ == '__main__':
tornado.web.Application(handlers=[
('/', IndexHandler),
('/blocking', Blocking)
], autoreload=True).listen(5000, 'localhost')
tornado.ioloop.IOLoop().current().start()

此时再次先访问 localhost:5000/blocking ,会发现访问这个请求的时候依然需要等待10s才会有响应,然而不同的是在这期间访问 localhost:5000 变的很顺畅,就像网站上没有什么阻塞的事件在运行一样(实际上也并没有)。大多数人在这里就开始笃信tornado中异步的强大,以及用 协程 将阻塞变成非阻塞的万能(就像当年的我23333),其实这里只是用错了栗子。之所以说用错,是因为 gen.sleep 并不是将原本阻塞的方法变成了非阻塞,只是偷换了 time.sleep 的同步性,取而代之延迟执行的表象。

查看一下 tornado 中 gen.sleep究竟做了什么,就会发现:

gen.sleep 源码片段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def sleep(duration):
"""Return a `.Future` that resolves after the given number of seconds.

When used with ``yield`` in a coroutine, this is a non-blocking
analogue to `time.sleep` (which should not be used in coroutines
because it is blocking)::

yield gen.sleep(0.5)

Note that calling this function on its own does nothing; you must
wait on the `.Future` it returns (usually by yielding it).

.. versionadded:: 4.1
"""
f = Future()
IOLoop.current().call_later(duration, lambda: f.set_result(None))
return f

这个 sleep 并没有真的sleep,只是在事件循环中添加了一个延迟的回调,如果遇到真的需要(连续不断)占用炒鸡长时间的任务运行,整个网站一定会阻塞。其实仔细想想也并不困难,单开一个Tornado进程,没有多线程,整个网站也只能在单线程环境下运行,如果某一个任务毫无间断地在整个网站的时间片上运行,当然会造成阻塞。于是乎,接下来能想到的处理方法就是多线程的方法了,tornado也支持这种方式实现多线程非阻塞。修改之后的代码可能类似这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import tornado.ioloop
import tornado.web
import time
from tornado.concurrent import run_on_executor
from concurrent.futures import ThreadPoolExecutor

class IndexHandler(tornado.web.RequestHandler):
def get(self):
self.write("index")

class Blocking(tornado.web.RequestHandler):
executor = ThreadPoolExecutor(4)

@run_on_executor
def get(self, *args, **kwargs):
time.sleep(10)
self.write("blocking")

if __name__ == '__main__':
tornado.web.Application(handlers=[
('/', IndexHandler),
('/blocking', Blocking)
], autoreload=True).listen(5000, 'localhost')
tornado.ioloop.IOLoop().current().start()

完全不需要 tornado 协程的支持,只需要一个额外的线程池就好了,这样也能达到曾经用协程时候的效果,而且这次是真的非阻塞了,毕竟阻塞的任务跑在了其他线程上。这里用到了python2没有自带的 concurrent 库,python2下面需要额外安装。然而,C Python中的多线程涉及到的GIL依然会导致依赖计算资源的任务阻塞整个进程。于是还会有更多基于消息队列(比如 Celery、Redis)的方式,在分布式的环境中减少阻塞的发生。当然,我并不会继续沿着这条路走下去了,如此下去整个网站的确会表现优异,然而,各种程序依赖不仅会降低可控性,提高维护难度,也减少了思考的余地。

最近就走到了这步余地。刚刚修好一个问题,到了午饭时间,就看到老大眉头紧皱的来跟我说,”我发现了一个很大的bug”。话还没说完,我也是心里一紧,难道是把自己的漏洞越修越大了咩=。=,”我发现Tornado是阻塞的”。于是之后才花了时间一起探索了一番tornado的协程异步。就在准备用线程池来暂时解决问题的时候,忽然想起来同样用协程来解决问题的 gevent ,中的一个栗子:

1
2
3
4
5
6
7
8
9
10
from gevent import monkey; monkey.patch_socket()
import gevent
import time

def f(n):
for i in range(n):
print i
gevent.sleep(0)

gevent.joinall([gevent.spawn(f, 5) for _ in range(5)])

其中有关键的一句 gevent.sleep(0),表面上看毫无作用(睡了0 s),然而实际作用却是关键的,能够让任务从协程中切换出来。再想想一般将生成器当作协程来用的时候通过 yield 将协程切换出来,以及 Tornado 中的协程异步其实也是通过 yield 进行协程调度,那么再看看 time.sleep(10) 之所以在使用了传说中的非阻塞魔法之后依然发生阻塞,就会发现,真正的原因其实是 time.sleep(10) 过于庞大,并且没有协程插手进行调度的余地。把这个阻塞的方法换成这样:

1
2
3
4
5
6
class Blocking(tornado.web.RequestHandler):
@gen.coroutine
def get(self, *args, **kwargs):
for _ in range(1000):
yield time.sleep(0.01)
self.write("blocking")

就会发现虽然也是用的 time.sleep 这个阻塞方法,但是并没有阻塞现象(因为只阻塞了0.01s就去响应 /路径去了)。当然,这是最简单的将阻塞变成非阻塞的情况,实际上肯定会在其他地方完成逻辑响应,将结果交给 tornado去响应给请求。比如这样:

1
2
3
4
5
6
7
8
9
10
class Blocking(tornado.web.RequestHandler):
@gen.coroutine
def get(self, *args, **kwargs):
yield sleepy()
self.write("blocking")

@gen.coroutine
def sleepy():
for _ in range(1000):
yield time.sleep(0.01)

然而对于消耗CPU资源(计算型)的任务而言,首先能不能找到这个协程切换的点很难说,然后这个计算型的协程会不会被切换更是另外一回事。协程并不是万能的,再怎么说也只有一个线程在运行,再加上 python 运行效率的低下,大多数人没有停留在协程上还是没有问题的。

本文作者 : hellflame
原文链接 : https://hellflame.github.io/2017/12/06/block-tornado/
版权声明 : 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!
留下足迹
点击通过issue留言