专栏文章 Tornado 异步性能分析

opentest-oper@360.cn · 2020年10月30日 · 最后由 opentest-oper@360.cn 回复于 2020年11月09日 · 4689 次阅读

Tornado 异步性能分析

Tornado 是一个 Python web 框架和异步网络库,起初由 FriendFeed 开发。通过使用非阻塞网络 I/O,Tornado 可以支撑上万级的连接,处理长连接, WebSockets ,和其他需要与每个用户保持长久连接的应用。
本文介绍如何正确的使用 tornado 异步特性以及对其进行压力测试, 查看 tornado 的性能到底有多强悍。
本文使用 tornado 5.0.2 版本。

一、最简单的脚手架

创建 server.py 文件,写入以下代码

# -*- coding: utf-8 -*-
import tornado.ioloop
import tornado.web
class Index(tornado.web.RequestHandler):
  def get(self):
    self.write("hello world")
def make_app():
  return tornado.web.Application([
   (r"/", Index)
 ])
if __name__ == '__main__':
  app = make_app()
  app.listen(8888)
  tornado.ioloop.IOLoop.current().start()

最简单的代码,可以先将服务启动. 在浏览器中输入 http://127.0.0.1:8888 即可看到 hello world 页面

我使用 jmeter 进行压测,可以达到 1200,可以说是非常不错

二、同步的困扰

但是在开发过程中并不是所有的接口都什么都不做就返回个简单的字符串或者 json, 更多的时候是进行一些数据库的操作或者本地的 IO 操作之后再返回相应的数据。

我们知道,程序在进行 IO 操作时,无论是网络 IO 或者文件的读写 IO,与内存计算相比,耗时是非常大的,此时我们如果模拟一个比较耗时的操作,那么这个接口 QPS 还会那么好吗?

class Index(tornado.web.RequestHandler):
  def get(self):
    name = self.get_argument('name', 'get')
    time.sleep(0.5)
    self.write("hello {}".format(name))

我们在 get 请求的时候加入了 time.sleep(0.5) 来模拟接口处理数据的耗时操作
我们在 chrome 的开发者工具中看到这个请求耗时 503ms,

我们再使用 jmeter 来进行压测, 居然降到惊人的 2, 之前还 1200,现在却降到了 2, 很悲催

响应时间最短 503, 最长 50240, 50 秒钟才将结果返回给用户, 这种响应速度的网站用户早就跑光了

三、为什么同步的性能会这么差?

在 python 中 time.sleep() 函数是个阻塞函数, 会阻塞 python 的运行, 使用 tornado 做 web 项目,你不可能只为一个用户服务,当有多个用户访问同一个接口时,python 在处理 sleep 操作时会阻塞运行,这时后来的请求也要等待前的请求,只有当前面的请求结束以后再处理,这也就是为什么上面的压测最短是 503ms,最长却需要 50240ms 的原因.因为大家是阻塞进行的. 不光 time.sleep 是这样的阻塞函数, python 中绝大多数函数都是这样的阻塞函数,包括文件 IO 操作, 网络请求操作等都是阻塞函数,所以我们需要异步的处理网络请求。

四、异步的解决方案

4.1 使用多线程

谈到异步,我们首先应该想到的是用多线程来处理,将耗时的操作放到多线程中处理, 修改上面的代码

class Index(tornado.web.RequestHandler):
  def get(self):
    name = self.get_argument('name', 'get')
    t = threading.Thread(target=time.sleep, args=(0.5,))
    t.start()
    self.write("hello {}".format(name))

这种多线程方案可以实现不阻塞,但是缺点也很明显,一个是每个请求都开启一个线程,对于资源来说很浪费,线程的开销也是很浪费服务器资源的
第二是,我们并没有获取在函数的结果,比如说 target 函数是一个数据库的查询操作,此时如果我们想要获取到数据库的值是获取不到的。

4.2 使用线程池来执行耗时操作

from tornado.concurrent import run_on_executor
class Index(tornado.web.RequestHandler):
  executor = ThreadPoolExecutor(1)
  @tornado.web.asynchronous
  @tornado.gen.coroutine
  def get(self):
    name = self.get_argument('name', 'get')
    rst = yield self.work(name)
    self.write("hello {}".format(rst))
    self.finish()
  def post(self):
    name = self.get_argument('name', 'post')
    self.write("hello {}".format(name))
  @run_on_executor
  def work(self, name):
    time.sleep(0.5)
    return "{} world".format(name)

这里在 Index 类中初始化了一个线程池, executor, 这有一个线程, 将耗时的操作放到 work 函数中, 并且使用 @run_on_executor 装饰器进行装饰, 这时我们就可以获取到耗时操作的返回值。

压测为 500 个用户在 5 秒内请求

但是我们看下压测结果

依然很糟糕, 每秒也就能处理 2 个请求. 而且最长的请求需要 7 万多 ms, 这也是无法忍受的。

每个请求需要耗时 0.5 秒,现在有一个线程池,它里面有一个线程,也就是说,同时最多也就处理一个请求,我们可以加大线程线中的数量, executor = ThreadPoolExecutor(5) 改为 5 个再进行压测,这时看到是有所增长,可以到达 10, 也依然不是很理想, 最大影响有 4 万多 ms

既然放到线程中执行与不放到线程中执行,每个请求都需要耗时 0.5 秒,那么放到线程中操作有什么意义呢?

意义在于, 一个 web 项目, 不太可能只提供一个接口, 假设你有 3 个接口, /user, /info, /case , 其中两个接口 ( /user, /case ) 都使用异步处理,放到了线程池中操作, 只有一个接口 ( /info ) 没有,而是直接使用 time.sleep(0.5) 秒, 那么当用户先访问阻塞函数接口 /info 以后, 此时 web 程序处理全局阻塞中,它会卡其它所有请求,即使这时你访问已经异步处理的接口 /user ,也会因为有这样的一个阻塞接口没有处理好而处于阻塞状态。

所以在 web 项目开发过程中,同一个团队不同开发人员在开发接口过程中,不要做那个坑队友的人

很显然, 即使用到的线程池,并且将线程池中的线程设置为 5, 每秒也只能处理 10 个请求,这样的 QPS 也是令人相当着急,一个成熟的系统访问人数成千上万都很正常,接下来我们使用 asyncio 库来异步处理请求

4.3 使用异步处理库 asyncio

很显然, 即使用到的线程池,并且将线程池中的线程设置为 5, 每秒也只能处理 10 个请求,这样的 QPS 也是令人相当着急,一个成熟的系统访问人数成千上万都很正常,接下来我们使用 asyncio 库来异步处理请求。

import asyncio
class Index(tornado.web.RequestHandler):
  async def get(self):
    name = self.get_argument('name', 'get')
    rst = await self.work(name)
    self.write("hello {}".format(rst))
    self.finish()
  def post(self):
    name = self.get_argument('name', 'post')
    self.write("hello {}".format(name))
  async def work(self, name):
    await asyncio.sleep(0.5)
    return "{} world".format(name)
if __name__ == '__main__':
  try:
    app = make_app()
    app.listen(8888)
    tornado.ioloop.IOLoop.current().start()
  except:
    print(traceback.format_exc())
    tornado.ioloop.IOLoop.current().start()

这里我们将 time.sleep 函数换成了 asyncio.sleep 异步的函数, 并且在函数定义 def 将加上 async , 在获取 work 结果时使用了 await 关键词

此时的压测结果为

可以看到,在 5 秒内就全部执行结束,QPS 可以达到 91, 和之前的 10 或者 2 有着非常大的提高,由于只有 500 个用户, 我们可以模拟更多的用户来访问,使用 1000 个用户来进行压测看下效果。

依然是在 5 秒内完成了请求,并且 QPS 上升到了 181, 继续加大请求用户量为 2000

依然强悍, QPS 继续上升到 305,而且仅多用了 1 秒钟.如果再继续提高用户量,我们来看看它的极限在哪里?

4000 的时候,服务端会报 ValueError: too many file descriptors in select() 这个是由于在 windows 中 IOloop 使用的是 selector, 它有 1024 个文件描述符的限制所导致的, 将其放到 linux 中我们看看可以达到多少。
6000 的时候还没有问题,如果 7000 的话会出现异常,这时已经比在多线程中运行性能提高的非常多了,如果考虑用户的增加,就应该考虑水平扩展了,通过负载均衡来解决了。

五、总结

通过上文的对比,我们可以理解,不是因为你使用了 tornado 你的网站性能就能提升多少,而是要正确的使用相应的异步库,只有正确的使用异步特性,才能发挥 tornado 的高性能。

以下列出常用的异步库

功能 同步库 异步库
mysql PyMySQL aiomysql
mongodb pymonogo motor
redis redis aioredis
http requests aiohttp

github 上有个组织在维护一些常用的异步库,https://github.com/aio-libs 常用的库都实现的异步, 大家在使用时可以先搜索一下。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 2 条回复 时间 点赞

分享的很实用,赞

几许风雨 回复

感谢支持,我们会再接再厉💐

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册