Day 39:【99天精通Python】异步编程 (AsyncIO) 上篇 - 协程的魔法
前言
欢迎来到第39天!
在前面的课程中,我们学习了多线程。线程虽然好用,但它是由操作系统负责调度的。操作系统很忙,它要在几千个线程之间来回切换(Context Switch),这需要消耗不少资源。当并发量达到上万级别时,线程切换的开销就会拖垮系统。
协程 (Coroutine)是一种比线程更轻量级的存在。
- 线程:操作系统决定什么时候切换,你无法控制。
- 协程:程序自己决定什么时候切换(“我等数据的时候,你先干别的”)。
Python 3.4 引入了asyncio库,Python 3.5 引入了async和await关键字,标志着 Python 进入了原生异步编程时代。这是高性能网络服务器(如 FastAPI, Tornado)的基石。
本节内容:
- 同步 (Sync) vs 异步 (Async)
async与await关键字- 事件循环 (Event Loop)
- 运行协程:
asyncio.run() - 并发执行:
asyncio.gather() - 实战:体验"光速"睡眠
一、同步 vs 异步
1.1 同步 (Synchronous)
代码从上到下依次执行。如果第一行卡住了(比如下载文件),第二行就得干等。
importtimedeftask(name):time.sleep(1)# 阻塞 1 秒print(f"{name}完成")task("A")task("B")# 总耗时: 2 秒1.2 异步 (Asynchronous)
当第一行卡住(等待 I/O)时,程序会自动挂起它,去执行第二行。等第一行结果回来了,再恢复执行。
# 伪代码逻辑awaittask("A")# 你先下着,我去干别的awaittask("B")# 你也下着# 总耗时: 约 1 秒 (因为是同时等的)二、Hello AsyncIO
2.1 定义协程 (async def)
使用async def定义的函数不再是普通函数,而是一个协程函数。调用它不会立即执行,而是返回一个协程对象。
importasyncioasyncdefsay_hello():print("Hello")return"World"# 直接调用不会执行打印!# coroutine = say_hello()# print(coroutine) # <coroutine object ...>2.2 运行协程 (asyncio.run)
要让协程跑起来,必须把它扔进事件循环 (Event Loop)。
Python 3.7+ 提供了最简单的入口:asyncio.run()。
importasyncioasyncdefmain():print("开始")# await 后面必须跟一个可等待对象 (Coroutine, Task, Future)# 这里不能用 time.sleep,要用 asyncio.sleepawaitasyncio.sleep(1)print("结束")if__name__=='__main__':asyncio.run(main())注意:
asyncio.sleep(1)是非阻塞的睡眠,而time.sleep(1)是阻塞的。在协程中千万别用time.sleep,否则整个程序都会卡死!
三、并发执行:asyncio.gather
如果我们按顺序写两个await,它们还是串行的。
asyncdefmain():awaittask(1)# 等它做完awaittask(2)# 再做这个# 依然是串行,没体现出异步优势要实现并发,我们需要告诉事件循环:“把这几个任务一起安排了!”。使用asyncio.gather()。
实战对比:同步 vs 异步
我们模拟烤面包(2秒)和煮咖啡(3秒)。
importasyncioimporttime# --- 异步任务 ---asyncdefmake_toast():print("开始烤面包...")awaitasyncio.sleep(2)# 模拟耗时 I/Oprint("面包烤好了!")return"Toast"asyncdefmake_coffee():print("开始煮咖啡...")awaitasyncio.sleep(3)print("咖啡煮好了!")return"Coffee"asyncdefmain():start=time.time()print("--- 早餐开始 ---")# 并发执行两个任务# gather 会等待所有任务完成,并按顺序返回结果列表results=awaitasyncio.gather(make_toast(),make_coffee())end=time.time()print(f"--- 早餐结束,耗时:{end-start:.2f}秒 ---")print(f"结果:{results}")if__name__=='__main__':asyncio.run(main())运行结果:
--- 早餐开始 --- 开始烤面包... 开始煮咖啡... (过了2秒) 面包烤好了! (又过1秒) 咖啡煮好了! --- 早餐结束,耗时: 3.01 秒 --- 结果: ['Toast', 'Coffee']如果用同步方式,需要 2+3=5 秒。异步方式只用了 3 秒(取决于最长的那个任务)。
四、深入理解:await 到底在干嘛?
await关键字的作用是:
- 挂起当前协程(暂停执行)。
- 交出控制权给事件循环,让它去调度其他协程。
- 等待后面的对象(如
sleep或网络请求)返回结果。 - 恢复执行。
这就像你在餐厅点菜:
await 点菜():你告诉服务员要什么,服务员去厨房下单(交出控制权)。- 服务员去服务其他桌的客人(调度其他任务)。
- 厨房做好了(IO完成),服务员把菜端给你(恢复执行)。
五、常见的坑 (必看)
坑1:在协程里写了阻塞代码
这是新手最容易犯的错。
asyncdefbad_coroutine():print("开始")# time.sleep 是阻塞的!它会霸占 CPU,不让出控制权。# 导致整个线程卡住,其他协程也跑不了。importtime time.sleep(5)print("结束")原则:在async def函数里,所有耗时操作都必须是异步的(支持await的),比如asyncio.sleep,或者异步库(aiohttp,aiomysql)。不能用普通的requests,time.sleep。
坑2:忘记写 await
asyncdefmain():# 这样写只会创建一个协程对象,但不会执行它!# RuntimeWarning: coroutine 'xxx' was never awaitedasyncio.sleep(1)# 正确写法awaitasyncio.sleep(1)六、小结
关键要点:
- 协程是单线程并发,靠的是"合作式调度"(自己主动让出 CPU)。
async def定义协程,await调度协程。asyncio.gather是并发执行的神器。- 千万别在协程里用阻塞代码(如
time.sleep,requests),否则一核有难,八核围观。
七、课后作业
- 异步倒计时:编写一个协程
countdown(name, n),每秒打印一次倒计时(n, n-1, … 1)。并发运行 3 个倒计时任务(比如 "A"倒数3秒,"B"倒数5秒)。 - 效率对比:编写一个普通的函数
sync_cal()(使用time.sleep(1))和一个协程async_cal()(使用asyncio.sleep(1))。分别循环调用它们 5 次(同步循环 vsgather并发),对比总耗时。 - 思考题:为什么计算密集型任务(如算圆周率)不适合用
asyncio?(提示:回顾一下 GIL 和单线程的本质)。
下节预告
Day 40:异步编程 (AsyncIO) 下篇 - aiohttp- 既然不能用requests,那在协程里怎么发网络请求?我们将学习 Python 最强的异步网络库aiohttp,体验每秒几千次请求的快感!
系列导航:
- 上一篇:Day 38 - 线程池与进程池
- 下一篇:Day 40 - 异步编程AsyncIO下(待更新)