最近一直开发AI人脸识别相关的项目,需要提供给客户一些服务,所以我需要开发一些服务端程序。由于AI算法都是用python3写的,所以我就索性用起了python开发服务端,毕竟速度也快,以前用过Flask、Django,这次决定有Tornado来做,对该框架做了一系列的调用,痴迷于他的异步非阻塞的功能,项目开发完之后有了一些经验,特此对以前的资料查询做一个总结,以便后面可以复用。
高性能源于Tornado基于Epoll(unix为kqueue)的异步网络IO。因为tornado的单线程机制,一不小心就容易写出阻塞服务[block]的代码。不但没有性能提高,反而会让性能急剧下降。因此,探索tornado的异步使用方式很有必要。
简而言之,Tornado的异步包括两个方面,异步服务端和异步客户端。无论服务端和客户端,具体的异步模型又可以分为回调[callback]和协程[coroutine]。具体应用场景,也没有很明确的界限。往往一个请求服务里还包含对别的服务的客户端异步请求。
服务端的异步方式
服务端异步,可以理解为一个tornado请求之内,需要做一个耗时的任务。直接写在业务逻辑里可能会block整个服务。因此可以把这个任务放到异步处理,实现异步的方式就有两种,一种是yield挂起函数,另外一种就是使用类线程池的方式。
请看一个同步例子(借用的):
class SyncHandler(tornado.web.RequestHandler):
def get(self, *args, **kwargs):
# 耗时的代码
os.system("ping -c 2 www.google.com")
self.finish('It works')
此时耗时动作将严重阻塞系统的性能,导致并发量很小,因为处理一个请求的时间就好几秒。
一、我们将以上代码改成异步的,使用回调函数
from tornado.ioloop import IOLoop
class AsyncHandler(tornado.web.RequestHandler):
@tornado.web.asynchronous
@tornado.gen.coroutine
def get(self, *args, **kwargs):
IOLoop.instance().add_timeout(1, callback=functools.partial(self.ping, 'www.google.com'))
# do something others
self.finish('It works')
@tornado.gen.coroutine
def ping(self, url):
os.system("ping -c 2 www.google.com")
return 'after'
这种写法就使耗时的任务在后台运行了,从而显著提高并发,但是此时,我们有两个知识点需要了解:
1、装饰器
@tornado.web.asynchronous
@tornado.gen.coroutine
两个问题:
为什么要使用这两个装饰器?
为什么要先用asynchronous在用coroutine呢?或着说为什么要用这种调用顺序?
这两个装饰器的作用:
1.1、@tornado.web.asynchronous
首先我们要明白同步和异步的作用
同步的情况下,web请求到来之后必须处理完成之后在返回,这是一个阻塞的过程。也就是说当一个请求被处理时,服务器进程会被挂起直至请求完成。而这会影响服务器的并发能力。
异步的情况下,web服务器进程在等待请求处理的时候,会将IO循环打开,继续来接受请求。而拿到处理结果之后会调用回调函数,将结果返回。这要既不影响处理请求,也不影响接受请求,能够显著的提升并发能力。
我们必须要明白,在同步的情况下,web服务进程,接受请求,处理请求,然后返回结果,最后自己来关闭连接。这个关闭的动作是自动的。
而异步的情况下,因为在处理一个请求的时候还没有的到结果,所以需要保持连接的打开,最后返回结果之后,关闭连接,这个关闭动作必须要手动关闭。也就是必须手动调用self.finish.
tornado中使用@tornado.web.asynchronous装饰器作用是保持连接一直开启,
上面的例子中使用的回调函数的缺点是,可能引起回调深渊,系统将难以维护,比如回调中调用回调等。
因为实现异步需要保持连接一直打开,而不能在handler执行完毕的时候关掉。
所以总的来说,@tornado.web.asynchronous的作用就是:把http连接变成长连接,直到调用self.finish,连接都在等待状态。
1.2、@tornado.gen.coroutine
这个函数的作用就是简化异步编程,让代码的编写更像同步代码,同时实现的确实异步的。这样避免了写回调函数。而且使用的是协程的方式来来实现异步编程。最新版的tornado,其实不一定需要写@tornado.web.asynchronous。
1.3、顺序
@asynchronous会监听@gen.coroutine的返回结果(Future),并在@gen.coroutine装饰的代码段执行完成后自动调用finish。从Tornado 3.1版本开始,只使用@gen.coroutine就可以了。
2、函数
IOLoop.instance().add_timeout()
functools.partial()
2.1、IOLoop.instance().add_timeout()
首先我们需要了解IOLoop,以及IOLoop.instance()也就是实例化动作。
IOLoop 是基于 epoll 实现的底层网络I/O的核心调度模块,用于处理 socket 相关的连接、响应、异步读写等网络事件。每个 Tornado 进程都会初始化一个全局唯一的 IOLoop 实例,在 IOLoop 中通过静态方法 instance() 进行封装,获取 IOLoop 实例直接调用此方法即可。
Tornado 服务器启动时会创建监听 socket,并将 socket 的 file descriptor 注册到 IOLoop 实例中,IOLoop 添加对 socket 的IOLoop.READ 事件监听并传入回调处理函数。当某个 socket 通过 accept 接受连接请求后调用注册的回调函数进行读写。接下来主要分析IOLoop 对 epoll 的封装和 I/O 调度具体实现。
epoll是Linux内核中实现的一种可扩展的I/O事件通知机制,是对POISX系统中 select 和 poll 的替代,具有更高的性能和扩展性,FreeBSD中类似的实现是kqueue。Tornado中基于Python C扩展实现的的epoll模块(或kqueue)对epoll(kqueue)的使用进行了封装,使得IOLoop对象可以通过相应的事件处理机制对I/O进行调度。
IOLoop模块对网络事件类型的封装与epoll一致,分为READ / WRITE / ERROR三类。
functools模块用于高阶函数:作用于或返回其他函数的函数。一般而言,任何可调用对象都可以作为本模块用途的函数来处理。
functools.partial返回的是一个可调用的partial对象,使用方法是partial(func,*args,**kw),func是必须要传入的,而且至少需要一个args或是kw参数。
在这里就是添加一个回调函数的partial对象。
上面的这种写法不能获取返回值。需要获取返回值需要使用yield挂起函数,并根据函数的return获取返回值。
二、带返回值的,同时使用协程来实现
class AsyncTaskHandler(tornado.web.RequestHandler):
@tornado.web.asynchronous
@tornado.gen.coroutine
def get(self, *args, **kwargs):
# yield 结果
response = yield tornado.gen.Task(self.ping, 'www.google.com')
print 'response', response
self.finish('hello')
@tornado.gen.coroutine
def ping(self, url):
os.system("ping -c 2 {}".format(url))
return 'after'
可以看到结果值也被返回了。有时候这种协程处理,未必就比同步快。在并发量很小的情况下,IO本身拉开的差距并不大。甚至协程和同步性能差不多。但是在大并发量的情况下就不一样了,因为并发请求很多,越来越多的请求如果被耗时的处理阻塞,将会长时间得不到结果。
yield挂起函数协程,尽管没有block主线程,因为需要处理返回值,挂起到响应执行还是有时间等待,相对于单个请求而言。另外一种使用异步和协程的方式就是在主线程之外,使用线程池,线程池依赖于futures。Python2需要额外安装。
我认为这种用法应该是一种比较常用的用法。
三、使用线程池的方式修改为异步处理
from concurrent.futures import ThreadPoolExecutor
class FutureHandler(tornado.web.RequestHandler):
executor = ThreadPoolExecutor(10)
@tornado.web.asynchronous
@tornado.gen.coroutine
def get(self, *args, **kwargs):
url = 'www.google.com'
tornado.ioloop.IOLoop.instance().add_callback(functools.partial(self.ping, url))
self.finish('It works')
@tornado.concurrent.run_on_executor
def ping(self, url):
os.system("ping -c 2 {}".format(url))
想要返回值也很容易。再切换一下使用方式接口。使用tornado的gen模块下的with_timeout功能(这个功能必须在tornado>3.2的版本)。
如:
class Executor(ThreadPoolExecutor):
_instance = None
def __new__(cls, *args, **kwargs):
if not getattr(cls, '_instance', None):
cls._instance = ThreadPoolExecutor(max_workers=10)
return cls._instance
class FutureResponseHandler(tornado.web.RequestHandler):
executor = Executor()
@tornado.web.asynchronous
@tornado.gen.coroutine
def get(self, *args, **kwargs):
future = Executor().submit(self.ping, 'www.google.com')
response = yield tornado.gen.with_timeout(datetime.timedelta(10), future,quiet_exceptions=tornado.gen.TimeoutError)
if response:
print 'response', response.result()
@tornado.concurrent.run_on_executor
def ping(self, url):
os.system("ping -c 1 {}".format(url))
return 'after'
具体使用何种方式,更多的依赖业务,不需要返回值的往往需要处理callback,回调太多容易出错,当然如果需要很多回调嵌套,首先优化的应该是业务或产品逻辑。yield的方式很优雅,写法可以异步逻辑同步写,快是快了一些,但也会损失一定的性能。