Python的asyncio把我坑惨了,原来await和同步阻塞的区别这么大

简介: 本文剖析asyncio常见误区:看似异步的代码(如混用`requests.get()`或`time.sleep()`)实则因同步阻塞导致协程串行执行。核心在于理解`await`是“让出控制权”,而非简单等待;事件循环单线程,一旦被阻塞即全局停滞。正确做法是使用异步库(如`aiohttp`)或`asyncio.to_thread`包装阻塞调用。(239字)

小李今天差点把电脑砸了。

他写了个异步爬虫,用了asyncio,每个函数前面都加了async,调网络请求的地方都写了await。代码看起来“很异步”。但跑起来之后,一万个请求花了将近一个小时。他打开调试工具一看,所有任务居然还是排着队一个一个执行。

“我不是写了await吗?为什么还是串行的?”

问题出在一个他完全没注意的细节上:他在协程里调用了requests.get()

代理 IP 使用小技巧 让你的数据抓取效率翻倍 (93).png

先看一个让你“以为懂了”的例子

小李最初写的代码大概是这样:

import asyncio
import time

async def task(name, seconds):
   print(f"{name} 开始")
   time.sleep(seconds)  # 问题在这里
   print(f"{name} 结束")

async def main():
   tasks = [
       asyncio.create_task(task("任务1", 2)),
       asyncio.create_task(task("任务2", 1)),
       asyncio.create_task(task("任务3", 3))
   ]
   await asyncio.gather(*tasks)

asyncio.run(main())

他满心期待看到三个任务几乎同时开始、总共3秒左右结束。结果呢?任务1开始、等2秒、任务1结束、任务2开始、再等1秒、任务2结束……总共花了6秒。和同步执行一模一样。

小李盯着屏幕,心想:create_task不是并发调度了吗?gather不是同时等待吗?为什么还是串行?

答案就藏在time.sleep那一行。

同步阻塞:程序“傻等”的根源

要理解这个问题,先看一段最朴素的同步代码:

import time

def task(name, seconds):
   print(f"{name} 开始")
   time.sleep(seconds)
   print(f"{name} 结束")

start = time.time()
task("任务1", 2)
task("任务2", 2)
print(f"总耗时: {time.time() - start}")

结果很直白:任务1开始,等2秒,任务1结束,任务2开始,再等2秒,任务2结束,总耗时4秒。

问题出在time.sleep。当程序执行到这一行时,CPU其实什么事都没干,就在那儿干等着时间过去。这段时间本可以用来处理其他任务,但同步代码不允许这样做——它是一条路走到黑的。

time.sleep是同步阻塞的,它会卡住当前线程。在普通的同步程序里,这没什么问题,反正只有这一条路。但在asyncio的世界里,这个“卡住当前线程”的行为会造成灾难性的后果。因为asyncio所有任务共享同一个线程,这个线程一旦被卡住,事件循环就彻底停摆了。

事件循环:一个单线程的“调度中心”

理解asyncio,关键要理解事件循环。

你可以把事件循环想象成一个调度中心。它维护着两个队列:就绪队列里放着可以立即执行的任务,等待队列里放着正在等待某个事件(比如网络响应、定时器到期)的任务。每一次循环,事件循环从就绪队列取出一个任务执行。当任务执行到await时,它会把自己挂起,告诉事件循环:“我先去忙别的,好了叫我”,然后事件循环就会切换到下一个可以执行的任务。

这里有一个关键认知:事件循环是单线程运行的。它不像多线程那样有操作系统帮你切换,所有的切换都由协程自己在await点主动让出。协程只在await点主动让出控制权,如果某个协程内部执行了长时间的计算而不使用await,其他协程只能干等。

这就是协作式调度——需要每个协程“配合”,才能让事件循环正常工作。而time.sleep恰恰是最不配合的那种操作:它不交出控制权,死占着线程不放。

await到底在干什么?

很多人以为await的意思是“等待”。这个理解不算错,但容易误导。

await的本质不是“等待”,而是“让出控制权”。当前协程在此处挂起,告诉事件循环“你可以去运行其他协程了,等我等的这个操作完成了再唤醒我”。

当协程执行到await时,它做了两件事:把当前的执行状态(局部变量、指令指针)保存下来,然后把控制权交还给事件循环。事件循环转而调度另一个就绪的协程执行。当被等待的操作完成时,事件循环再把之前的协程恢复执行。

所以await asyncio.sleep(1)time.sleep(1)的区别,表面上看都是“等1秒”,但行为完全不同。

await asyncio.sleep(1)做的是:告诉事件循环“我1秒后需要被唤醒”,然后把控制权交出去。事件循环立刻去执行其他任务。1秒后事件循环回来唤醒这个协程,继续执行。

time.sleep(1)做的是:死占着线程1秒。这1秒内,事件循环完全无法运行,所有其他任务都被冻结。1秒后线程恢复,继续往下走——但中间这1秒,整个程序等于暂停了。

用一个生活场景来理解。你点了三份外卖。同步的做法是:站在第一家店门口等,拿到第一份,再去第二家等。第二家店如果忙,你就干等着,全程啥也干不了。异步的做法是:三家店都下单,然后回家坐着,谁做好了给你打电话,你去拿,中间你可以看电视、打游戏。

await asyncio.sleep就是“回家坐着”,time.sleep就是“站在店门口傻等”。

真正的坑:在异步函数里调用同步阻塞

理解了上面的原理,小李的bug就清楚了。

他用了asyncio.create_task创建了三个任务,用asyncio.gather等待它们完成。这部分写得没问题。问题在于每个任务内部用了time.sleep而不是await asyncio.sleep

当事件循环调度到任务1时,执行到time.sleep(2),整个线程被冻结2秒。这2秒里,任务2和任务3虽然在就绪队列里,但事件循环根本没有机会去调度它们。所以效果和同步执行一模一样。

更隐蔽的情况是调用同步的网络库。比如在协程里调用requests.get()

async def fetch(url):
   response = requests.get(url)  # 同步阻塞调用
   return response.text

requests.get()是同步的,它会阻塞事件循环,直到网络响应返回。这意味着即使你有100个协程同时运行,只要其中一个执行了requests.get(),其他99个都会被冻结。一个阻塞调用(哪怕只有一个)就可以冻结整个应用。

这个坑之所以“坑”,是因为代码看起来完全正确。你写了async def,你调用了await,你的代码编辑器也没有报错。但运行时,所有并发都消失了。

怎么判断自己掉坑了?

有个很简单的判断方法:看你的协程里有没有用到不包含await关键字的阻塞操作

常见的同步阻塞操作包括:

  • time.sleep() — 应改为await asyncio.sleep()
  • requests.get/post() — 应改为aiohttphttpx的异步版本
  • open()读取大文件 — 应用asyncio.to_thread()包装
  • urllib相关调用 — 应改为异步HTTP客户端
  • 大量CPU计算(比如循环一亿次)— 应提交给进程池

open调用执行阻塞的磁盘I/O,应该在执行器中运行。与之相关的是,阻塞的读写操作也必须一并修复,否则即使修复了open,后续的读写仍会阻塞。

Home Assistant的开发文档里有一句话很直白:如果在事件循环中发生阻塞操作,在操作完成之前,其他任何东西都无法运行。因此,事件循环中不应发生任何阻塞操作,否则整个系统会在阻塞操作期间停滞。

那我不小心用了同步库怎么办?

实际开发中,你不可能把所有依赖都换成异步版本。有些库就是没有异步实现,有些第三方SDK就是同步的。这种情况下,asyncio.to_thread()就是你的救命稻草。

import asyncio

async def main():
   result = await asyncio.to_thread(blocking_function, arg1, arg2)
   print(result)

asyncio.to_thread是Python 3.9新增的功能,一行代码就能把同步函数变成可await的协程。它把同步函数丢到线程池里执行,不阻塞事件循环,等执行完了再把结果取回来。

但要注意一个细节:asyncio.to_thread并不是万能的。asyncio.wait_for()asyncio.timeout()只能取消等待的协程,永远无法取消正在运行的工作线程。如果一个阻塞的系统调用(比如文件锁)卡住了,即使超时了,那个线程仍然在后台运行。

所以to_thread适合“确定能完成”的阻塞操作,不适合“可能永远卡住”的操作。后者需要用进程池或者加额外的超时和恢复机制。

更根本的问题:异步的“传染性”

还有一个很多人没意识到的问题:async/await语法具有“传染性”。

一个异步函数只能被另一个异步函数await调用。这意味着如果你有一个同步的main函数,调用了某个异步库,你需要从底到顶全链路异步化。

这不是设计缺陷,而是协作式调度的必然结果。事件循环要求所有可能阻塞的操作都以await的形式声明出来,这样它才知道在哪里切换。如果一个同步函数悄悄阻塞了,事件循环根本不知道,也就无法调度。

所以使用asyncio时,你需要接受一个现实:要么全异步,要么在同步边界用to_thread或执行器包装。混着写是最危险的。

什么时候该用asyncio,什么时候不该用?

asyncio适合I/O密集型任务——大量时间花在等待网络响应、磁盘读写、数据库查询上。当一个任务需要等待时,让程序暂时离开,去做别的事,等那个操作准备好了再回来继续。

一个HTTP请求的基准测试可以直观说明:使用同步requests库并发请求100个URL,总耗时约等于所有请求耗时的累加;使用aiohttp的异步版本,总耗时约等于最慢那个请求的耗时。

但asyncio不适合CPU密集型任务。如果协程中执行的是大量数学运算且不涉及I/O等待,事件循环会在一个协程上阻塞直到运算完成,其他协程得不到执行机会。对于CPU密集型任务,标准方案是通过run_in_executor将计算任务提交给线程池或进程池执行,主事件循环不会被阻塞。

异步本身并不能加速计算,它优化的只是“等待”这件事。

回到小李的代码

小李最终把代码改成了这样:

import asyncio
import aiohttp

async def fetch(session, url):
   async with session.get(url) as response:
       return await response.text()

async def main():
   async with aiohttp.ClientSession() as session:
       tasks = [fetch(session, url) for url in urls]
       results = await asyncio.gather(*tasks)

核心改动只有两个:把requests换成了aiohttp(异步HTTP客户端),把time.sleep换成了await asyncio.sleep

跑完一万个请求,耗时从将近一个小时降到了十分钟左右。

小李看着终端里刷刷滚动的日志,终于明白了一件事:await和同步阻塞的区别,不在于“等不等”,而在于“等的时候,能不能让别人先跑”。

理解事件循环是单线程的,理解await是“让出控制权”而不是“占用线程等待”,理解同步阻塞操作会冻结整个事件循环——这三个认知一旦建立,asyncio的大部分坑都能提前避开。

不是用了async def就是异步,关键是看代码在等待的时候,有没有把控制权交出去。

目录
相关文章
|
2天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
5125 6
|
1天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
727 0
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
14天前
|
人工智能
千问办公官网入口:阿里AI办公QwenWork产品页和免费网页端链接
千问办公官网含两大入口:一是网页端(qwenwork.cn),即开即用,支持浏览器直接访问;二是阿里云产品页 https://t.aliyun.com/U/JNKJuO 提供免费/付费版详情、功能介绍及使用指南。
|
14天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
|
13天前
|
IDE 开发工具
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
Qoder国际版上线全新内置大模型Sonus(/ˈsoʊnəs/),全球领先,专精超长任务执行与电脑操作(Computer Use)。配合Qoder桌面端0.2.3版本,可自主完成编程、金融建模、科研及表格制作等复杂工作。现全面支持Qoder全系产品,效率提升3.2倍。
1708 8
Qoder 上线 Sonus 模型,Computer Use 能力全面增强
|
16天前
|
缓存 人工智能 自然语言处理
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
本文是阿里云百炼平台Qwen3.8-Flash大模型的选型接入指南,作为兼顾性能与响应速度的高性价比多模态模型,它支持百万级上下文窗口、全场景多模态输入与完整智能体能力矩阵,适配编程辅助、智能体协作等核心场景。文中同步梳理了最新下调的阶梯定价、夜间4折等优惠活动,搭配OpenAI兼容流式调用示例,帮助开发者低成本快速落地高并发AI应用。
阿里云qwen3.8-flash大模型介绍:模型能力、模型价格、免费额度与最新活动
|
9天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
1044 1
|
15天前
|
人工智能 API 内存技术
刚刚 DeepSeek V4.1 Flash 开启内测,1 分钟教你用上!
刚刚 DeepSeek 内测群发布了 DeepSeek V4.1 Flash 中间版本内测的消息,这次的模型采用了新的结构,原生支持多模态、能力更强、速度更快、且成本更低。
2009 15