前言
书接上回,在上一篇中我们详细讲解了 Tornado 协程的核心定义、原生协程与装饰器式协程的区别,以及底层运行原理。本篇我们将聚焦协程的正确调用规范 与高频经典使用模式,帮大家避开异步调用的常见陷阱,充分发挥协程的并发性能。
一、协程的正确调用方式
1.1 调用的核心原理
协程的异常传播机制和普通函数有本质区别: 协程内部产生的异常会被封存到可等待对象中,只有通过 yield / await 触发等待时,异常才会真正抛出。如果调用方式错误,异常会被静默隐藏,极难排查定位。
我们先看一个典型的错误调用示例:
bash
async def divide(x, y):
return x / y
def bad_call():
# 此处本应触发 ZeroDivisionError,但因协程调用方式错误,异常永远不会暴露
divide(1, 0)
问题说明 :在普通同步函数中直接调用 async 函数,只会生成一个协程对象,不会真正执行函数内部逻辑,代码既不会运行,异常也不会抛出。
1.2 标准调用规则
绝大多数场景下,调用协程的函数自身也必须是协程 ,通过 await(原生 async def 协程)或 yield(装饰器旧式协程)等待执行。
注意:重写 Tornado 框架的父类方法前,需要查阅官方文档确认该方法是否支持协程 ------ 只有文档标注「可为协程 / 可返回 Future」的方法,才可以改写为协程版本。
标准正确写法示例:
bash
async def good_call():
# await 会解包 divide() 返回的可等待对象,正常抛出异常
await divide(1, 0)
1.3 场景 1:即发即忘(无需等待执行结果)
如果不需要获取协程的返回结果,只希望任务在后台异步执行,推荐使用 IOLoop.spawn_callback 将任务交由事件循环托管。 这种方式的优势是:任务执行失败时,事件循环会自动打印堆栈日志,不会出现异常静默丢失的问题。
bash
# IOLoop 会捕获异常并在日志中输出堆栈信息
# 注意这里传入的是函数对象,由 IOLoop 负责调用执行
IOLoop.current().spawn_callback(divide, 1, 0)
适配说明:
- 装饰器式协程(
@gen.coroutine):推荐使用该方法管理后台异步任务; - 原生协程(
async def):必须使用该方法,否则协程调度器不会启动任务,代码永远不会执行。
1.4 场景 2:程序顶层入口(事件循环未启动时)
在脚本、批处理程序的主入口,事件循环尚未启动的场景下,使用 IOLoop.run_sync 来启动事件循环、运行指定协程,执行完毕后自动关闭事件循环。
注意:
run_sync不支持直接传入带参数的协程调用,需要用lambda包裹一层。
bash
# run_sync() 不直接接收参数,因此需要用 lambda 包裹调用
IOLoop.current().run_sync(lambda: divide(1, 0))
二、协程经典使用模式
2.1 在协程中调用阻塞函数
协程运行在单线程事件循环中,不能直接执行耗时阻塞函数(如同步文件 IO、密集计算、同步数据库请求等),否则会卡住整个事件循环,导致所有并发任务阻塞。
最优解决方案:使用 IOLoop.run_in_executor,将阻塞函数投递到线程池中执行,返回兼容协程的 Future 对象,通过 await 异步等待结果。
bash
async def call_blocking():
await IOLoop.current().run_in_executor(None, blocking_func, args)
参数说明:第一个参数为线程池实例,传入 None 则使用 Tornado 默认线程池;后续参数为阻塞函数及其入参。
2.2 并行执行多个异步任务
当需要同时发起多个异步任务、等待全部完成后再继续执行时,可以使用 tornado.gen.multi。它支持传入列表、字典格式的 Future 集合,并行等待所有任务执行完毕,大幅提升批量任务的执行效率。
原生协程写法
bash
from tornado.gen import multi
# 列表形式:按输入顺序返回对应结果
async def parallel_fetch(url1, url2):
resp1, resp2 = await multi([
http_client.fetch(url1),
http_client.fetch(url2)
])
# 批量处理多个 url
async def parallel_fetch_many(urls):
responses = await multi([http_client.fetch(url) for url in urls])
# responses 是与输入顺序一一对应的 HTTPResponse 列表
# 字典形式:按 key 映射返回结果
async def parallel_fetch_dict(urls):
responses = await multi({url: http_client.fetch(url) for url in urls})
# responses 是 {url: HTTPResponse} 格式的字典
装饰器式协程写法
旧式 @gen.coroutine 装饰的协程支持更简洁的写法,直接 yield 列表或字典即可实现并行等待:
bash
@gen.coroutine
def parallel_fetch_decorated(url1, url2):
resp1, resp2 = yield [
http_client.fetch(url1),
http_client.fetch(url2)
]
2.3 穿插调度(流水线式执行)
核心思路 :不立刻 await 刚创建的任务,先保存 Future 对象、启动下一个任务,再分批等待结果,通过「计算 / IO」的穿插重叠提升整体吞吐量,特别适合流式数据处理场景。
1. 原生 async def 写法
原生协程需要借助 convert_yielded 在后台启动协程,作用等价于 asyncio.ensure_future(),二者在 Tornado 中均可使用。
bash
from tornado.gen import convert_yielded
async def get(self):
# convert_yielded() 会在后台启动原生协程
fetch_future = convert_yielded(self.fetch_next_chunk())
while True:
chunk = await fetch_future
if chunk is None:
break
self.write(chunk)
# 等待当前数据写入的同时,预取下一块数据
fetch_future = convert_yielded(self.fetch_next_chunk())
await self.flush()
2. 旧式装饰器协程写法
装饰器式协程调用后会立即启动任务,因此写法更简洁,无需额外转换:
bash
@gen.coroutine
def get(self):
fetch_future = self.fetch_next_chunk()
while True:
chunk = yield fetch_future
if chunk is None:
break
self.write(chunk)
fetch_future = self.fetch_next_chunk()
yield self.flush()
总结
协程的调用有严格的规范约束,错误的调用方式会导致代码不执行、异常静默丢失等隐蔽问题,是异步开发的高频踩坑点。日常开发中需要根据业务场景选择合适的调用方式,灵活运用并行执行、穿插调度等经典模式,才能充分发挥 Tornado 异步架构的性能优势。
到这里,Tornado 协程的核心概念、底层原理与实战用法就全部讲解完毕了,感兴趣的小伙伴可以结合实际业务场景动手实践。