# 聊聊Tornado:一个被低估的Python异步框架
它到底是什么
Tornado,本质上是一个用Python写的非阻塞式Web服务器框架。说到这个问题,得从开发者面临的一个实际困境说起。
去年我帮一个朋友重构他的爬虫服务,用的是Flask跑在Gunicorn上。平时运行得挺好,可一旦遇到某个网站突然返回一堆慢响应,整个服务就开始卡死——不是CPU不够,而是所有工作线程都被堵在那儿等I/O。你想想看,一个线程在等待远程服务器的响应,什么事情都做不了。
这就是Tornado要解决的根本问题。它用一种事件循环的机制,替代了传统的线程或进程模型。简单来说,当你的代码在等某个网络请求返回时,Tornado不会傻傻地在那里干等,而是去处理其他请求。等那个网络请求的数据到了,它会回过来继续处理。
有人可能会说,这不就是异步吗?Python 3里不是有asyncio吗?没错,但Tornado从2010年左右就开始这么做了,比asyncio早了好多年。它的核心——ioloop模块,是一个完整的事件循环实现,可以和asyncio协同工作,但并不依赖它。
它真正擅长什么
Tornado的用武之地,主要集中在需要处理大量并发连接的场景。
拿长轮询来说。想象你在做个即时通讯功能,用户打开网页,需要实时接收新消息。传统做法是让客户端每隔几秒发个请求问“有没有新消息?”。这太傻了——多数情况下服务器根本没新消息,白白浪费带宽和资源。长轮询的做法是,客户端发一个请求,服务器把这个请求挂在那里,直到有新消息了才返回。这样做,一个客户端就会有一个长时间挂起的连接。
如果用户有1万人同时在线,那就是1万个并发连接。这在Flask这类同步框架上几乎是灾难,每个连接需要一个线程,1万个线程的内存消耗和上下文切换成本就够你受的。但在Tornado里,1万个连接只是一个事件循环里维护的1万个socket对象,完全不是一个数量级。
除此之外,WebSocket也是Tornado的强项。很多框架的WebSocket支持像是后来硬加上去的,而Tornado在设计之初就考虑到了双向通信的需求。你可以在同一个应用里,同时处理HTTP请求和WebSocket连接,共享相同的身份验证和逻辑。
怎么开始用它
用Tornado写个简单的服务,比大部分框架都直接。
importtornado.ioloopimporttornado.webclassMainHandler(tornado.web.RequestHandler):defget(self):self.write({"message":"Hello, World!"})defmake_app():returntornado.web.Application([(r"/",MainHandler),])if__name__=="__main__":app=make_app()app.listen(8888)tornado.ioloop.IOLoop.current().start()这段代码启动后,会直接监听8888端口,完全不需要额外的WSGI服务器。Tornado本身就是个完整的HTTP服务器。
但要注意,上面这个例子里的get方法是同步的。如果在这里面做耗时操作,比如查询数据库或调用外部API,它还是会阻塞事件循环。正确的做法是使用异步方法:
classAsyncHandler(tornado.web.RequestHandler):asyncdefget(self):# 假设有个异步的HTTP客户端http_client=tornado.httpclient.AsyncHTTPClient()response=awaithttp_client.fetch("https://api.example.com/data")self.write(response.body)这里用了async/await关键字,让Tornado知道这个方法在执行到await的时候可以切出去做别的事。
一些实际经验
用Tornado这几年,有几点体会特别深。
第一,别把所有事情都做成异步的。Tornado的异步模型很棒,但Python本身没有真正的多核并行能力。如果你的业务逻辑里有CPU密集型的计算,比如图片处理、数据压缩,这些事不应该放在主事件循环里。正确做法是用tornado.process模块里提供的子进程机制,或者直接丢给Celery去处理。
第二,数据库连接池是个容易被忽略的坑。很多ORM的连接池是线程安全的,但不一定适用于协程环境。在Tornado里用SQLAlchemy需要特别注意,最好用create_async_engine(SQLAlchemy 1.4之后支持的异步版本)。如果非要用同步的数据库驱动,就得把数据库操作封装成run_in_executor调用,这会失去一些性能优势。
第三,也是很多人会踩的坑:不要在RequestHandler里持有长时间的引用。Tornado会复用这些handler对象,如果某个handler里的回调持有了它的引用,这个对象就永远无法被垃圾回收,相当于内存泄漏。实际中见过一个项目,因为代码里不小心把self传进了另一个协程,结果内存涨到十几个G。
和其他框架的比较
说到Flask,它和Tornado面对的场景几乎完全不同。Flask追求的是简单、优雅,开发者写个API可以很快上手,学习曲线几乎为零。但它根本上是个同步框架,部署时得靠Gunicorn这类WSGI服务器撑起并发。遇到长连接或WebSocket场景,Flask就比较吃力了,虽然可以通过插件支持,但体验和Tornado的“原生支持”还是有差距。
FastAPI是近几年比较火的框架。它在性能上做了很多优化,底层依赖Starlette和asyncio。FastAPI的强项在于类型提示、自动生成API文档,这些Tornado确实比不上。但FastAPI的WebSocket支持更像是在HTTP框架上“打了补丁”,而Tornado的设计从一开始就考虑了这个需求。如果要做复杂的实时通信系统,Tornado在可靠性和代码组织上往往更合适。
Django则是另一个维度的东西。它是一个全栈框架,自带ORM、Admin后台、模板引擎。Tornado几乎不做这些事,它更像个工具箱。用Django做大型CMS类项目很顺手,但如果主要需求是处理超高并发的WebSocket连接,Django的同步本质和大型框架的启动开销会成为拖累。
有一点很多人不知道,Tornado被很多大公司的关键组件采用。比如Facebook(Tornado最初就是他们开发的,不过在Facebook内部用C++重写了一个版本),还有ZMQ的官方Python库Czmq的异步接口就用到了Tornado。这些场景对稳定性和性能都有苛刻要求,说明Tornado在这些方面经得起考验。
我的看法是,如果你明确需要高并发、长连接、WebSocket这类特性,直接选Tornado;如果只是写个普通的Web API,Flask或FastAPI可能更顺手。没必要为了用Tornado而用Tornado,毕竟它的同步编码的方式确实增加了代码复杂度。
最后说个有意思的现象。从前段时间开始,Python社区似乎又对Tornado的关注度上升了。原因可能是微服务和消息驱动的架构越来越普及,服务之间的通信量越来越大,长连接的需求也越来越多。Tornado这种“铁了心为高并发而生”的框架,又开始展现出它的价值。