讲个真事儿。
去年我写了一个异步爬虫,要抓取几千个网页。代码大概长这样:
import aiohttp
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
urls = [...] # 几千个网址
async with aiohttp.ClientSession() as session:
for url in urls:
html = fetch(session, url) # 注意这里没加 await
print(len(html))
跑起来之后,程序瞬间打印了几千行 len(html),每行都是同一个数字。我一开始还以为网速飞快,结果仔细一看,所有输出都是 0 或者一个奇怪的小数字。更诡异的是,服务器端根本没有收到任何请求。
我盯着代码看了十分钟,才发现问题:fetch(session, url) 是一个异步函数,调用它只会返回一个协程对象,不会真正执行。我把它赋值给 html,然后 len(html) 自然就报错了——但为什么没报错?因为协程对象居然有 __len__ 吗?不,其实 len() 会直接抛 TypeError。但我当时用的是 print(len(html)),而 html 是一个协程对象,len() 确实会报错。可为什么我没看到报错?因为我用了 try/except 包住了整个循环,把所有异常都吞掉了。
把 except 去掉之后,满屏的 TypeError: object of type 'coroutine' has no len()。那一刻我才真正理解:在 async 函数里调用另一个 async 函数,不加 await,就等于买了一张电影票但没去电影院——票是有了,电影没放。
async/await 到底在干什么
要理解 await 加与不加的区别,得先明白 Python 异步编程的基本模型。
当你用 async def 定义一个函数时,它就不再是一个普通函数了,而是一个协程函数。调用它不会执行函数体,而是返回一个协程对象。这个协程对象像一个“任务说明书”,里面写着“如果你让我跑,我会做这些事情”。
async def say_hello():
print("Hello")
# 调用协程函数,返回协程对象,不会打印任何东西
coro = say_hello()
print(coro) # <coroutine object say_hello at 0x...>
要让协程真正执行,必须把它交给事件循环。最直接的方式就是用 await:
async def main():
await say_hello() # 现在才会打印 Hello
asyncio.run(main())
await 的作用可以概括为两点:
- 执行:告诉事件循环“把这个协程跑起来”。
- 等待:挂起当前协程,直到被等待的协程完成,并获取它的返回值。
所以,await 既是“启动开关”,也是“等待信号”。不加 await,协程对象就只是一个对象,永远不会被执行。
不加 await 的几种典型翻车现场
翻车一:以为调用了,其实没执行
async def save_data(data):
await db.insert(data)
print("保存成功")
async def main():
save_data({"name": "张三"}) # 忘记 await
print("主函数结束")
asyncio.run(main())
输出:
主函数结束
save_data 根本没跑,数据库里什么也没写。更坑的是,Python 不会报错,只会在程序退出时给一个警告:RuntimeWarning: coroutine 'save_data' was never awaited。如果你没开警告,或者警告被日志淹没了,这个 bug 可能几个月都发现不了。
翻车二:把协程对象当结果用
async def get_user(user_id):
return await db.fetch_one(user_id)
async def main():
user = get_user(1) # 没加 await
print(user.name) # AttributeError: 'coroutine' object has no attribute 'name'
你拿到的是一个协程对象,不是用户数据。对它做任何属性访问、索引、运算,都会报错。而且错误信息往往让人摸不着头脑。
翻车三:在列表推导里忘记 await
async def main():
results = [fetch(url) for url in urls] # 全是协程对象
for r in results:
print(r) # 打印一堆 <coroutine object ...>
正确的写法要么用 await asyncio.gather(*[fetch(url) for url in urls]),要么在循环里逐个 await。
翻车四:在非 async 函数里调用 async 函数
def sync_func():
result = async_func() # 这里不能加 await,因为 sync_func 不是 async
return result
await 只能出现在 async def 函数内部。在普通函数里,你只能得到协程对象,要么用 asyncio.run() 跑,要么用 asyncio.create_task() 调度,要么用 loop.run_until_complete()。
await 的本质:让出控制权
理解了“不加 await 不执行”之后,还要理解“加了 await 之后发生了什么”。
当你在一个协程里 await 另一个协程时,当前协程会暂停,把控制权交还给事件循环。事件循环可以去执行其他任务。等到被等待的协程完成后,事件循环再把控制权还回来,当前协程从暂停处继续执行。
这就是异步并发的核心:一个线程可以同时处理多个任务,靠的就是在等待 I/O 时主动让出控制权。
async def task_a():
print("A 开始")
await asyncio.sleep(1)
print("A 结束")
async def task_b():
print("B 开始")
await asyncio.sleep(1)
print("B 结束")
async def main():
await asyncio.gather(task_a(), task_b())
asyncio.run(main())
输出:
A 开始
B 开始
(等待 1 秒)
A 结束
B 结束
两个任务并发执行,总共只花了 1 秒。如果不用 await,或者用同步方式写,就需要 2 秒。
关键点:await 让协程在等待时“让路”,这样事件循环才能去跑别的协程。不加 await,协程根本不会启动,也就没有“让路”这回事。
await 和 asyncio.gather 的区别
很多人分不清 await 和 asyncio.gather。
await coro:等待一个协程完成,并拿到它的返回值。await asyncio.gather(coro1, coro2, ...):并发运行多个协程,等待它们全部完成,返回结果列表。
gather 里面传入的是协程对象,不是 await 后的结果。因为 gather 自己会去调度这些协程。
# 正确:并发执行
results = await asyncio.gather(fetch(url1), fetch(url2))
# 错误:这样会串行执行,而且 fetch 返回的是协程对象,不是结果
results = [await fetch(url1), await fetch(url2)]
第二种写法虽然也能跑,但它是串行的:先等 fetch(url1) 完成,再等 fetch(url2)。失去了并发的意义。
什么时候可以不加 await
有一种情况,你故意不加 await,那就是创建任务。
asyncio.create_task(coro) 会把协程包装成一个 Task,立即调度到事件循环中执行,但不会等待它完成。你可以继续做别的事情,稍后再通过 await task 来获取结果。
async def main():
task1 = asyncio.create_task(fetch(url1))
task2 = asyncio.create_task(fetch(url2))
# 这里两个请求已经在后台跑了
result1 = await task1
result2 = await task2
这相当于“先点火,后等待”。如果你直接 await fetch(url1),那就变成“点一个火,等它烧完,再点下一个”。
一个完整的对比例子
假设你要从三个 URL 抓取数据,然后合并结果。
错误写法(忘记 await):
async def main():
results = []
for url in urls:
data = fetch(url) # 没加 await
results.append(data) # 追加的是协程对象
print(results) # 一堆 <coroutine object>
串行写法(加了 await,但没并发):
async def main():
results = []
for url in urls:
data = await fetch(url) # 一次等一个
results.append(data)
总共耗时 = 三个请求时间之和。
并发写法(正确使用 await 和 gather):
async def main():
tasks = [fetch(url) for url in urls]
results = await asyncio.gather(*tasks)
总共耗时 = 最慢的那个请求的时间。
为什么 Python 要这样设计
你可能会问:为什么不像 JavaScript 那样,调用 async 函数就自动开始执行?为什么非要加 await?
因为 Python 的协程是显式的。调用一个 async 函数只是创建了一个协程对象,并没有把它注册到事件循环里。只有当你 await 它,或者用 create_task、gather 等工具显式调度它时,它才会运行。
这种设计的好处是控制力更强。你可以自由决定什么时候启动任务、什么时候等待、要不要并发。坏处就是容易忘记加 await,导致“代码没跑”这种低级错误。
如何避免踩坑
几个实用的建议:
- 开启警告:Python 默认会为未 await 的协程发出
RuntimeWarning。确保你的日志级别能看到这些警告。 - 使用 linter:像
pylint、flake8-async、ruff都能检测出未 await 的协程调用。 - 类型注解:给协程函数标注返回类型,比如
async def fetch() -> str:,这样类型检查工具能帮你发现错误。 - 统一风格:在代码审查时,特别留意
async def的调用处。看到async函数被调用,先问一句:“await 了吗?” - 用
asyncio.run()作为入口:确保最外层用asyncio.run(main()),这样事件循环会正常运行,未 await 的协程也会在退出时给出警告。
总结
await 加与不加,区别就是“执行”和“不执行”。
- 不加 await:调用 async 函数只会返回一个协程对象,函数体不会执行。你拿到的是一个“待办事项”,而不是结果。
- 加 await:协程被调度执行,当前协程暂停等待,直到它完成并返回结果。
理解这一点,就理解了 Python 异步编程的第一道门槛。记住一句话:async 函数不是用来直接调用的,而是用来 await 的。 下次写异步代码时,看到 async def,先检查调用处有没有 await——如果没有,要么补上,要么用 create_task 显式调度。
我那个爬虫后来改成了 asyncio.gather 并发抓取,几千个网页几分钟就跑完了。而那个忘记加 await 的 bug,也成了我之后每次写异步代码时都会下意识检查的第一件事。