4 道练习题·预计 60 分钟·做对一题解锁下一段
各位写过爬虫没有?随手写一段:
import time
def fake_get(url):
time.sleep(0.01)
return f'GOT {url}'
def main():
urls = [f'https://example.com/{i}' for i in range(5)]
start = time.perf_counter()
results = []
for u in urls:
results.append(fake_get(u))
cost = time.perf_counter() - start
print(f'拿到 {len(results)} 个,用了 {cost:.3f} 秒')
main()输出大致是这样:
拿到 5 个,用了 0.05 秒每个请求假装耗时 0.01 秒,5 个串起来就是 0.05 秒。如果是真实的网络请求,每个 200 毫秒,100 个串起来就是 20 秒,慢得想骂人。
「那好办,开线程嘛!」有童鞋立刻反应过来,「100 个 URL 就开 100 个线程,一起跑。」
线程当然能解决问题。但开 100 个线程是不是有点奢侈?这 100 个线程 99% 的时间都在干同一件事:等网络包回来。本质上这是个等待问题,不是个计算问题。为了「等」而开 100 个操作系统级的线程,CPU 不开心,内存也不开心。
而且线程一多,「锁」、「竞态」、「死锁」这些老朋友就会一个一个找上门。你只是想抓个网页,怎么忽然要去研究操作系统了?
Python 给了我们另一条路:asyncio 。它的卖点很直白——一个线程,同时等一百件事。各位听到这句话第一反应肯定是:「这不科学吧,单线程怎么同时做一百件事?」
这就是这一章要回答的问题。
先不讲原理,先看效果。下面这段是「正经的」串行代码,三件事各等 1 秒:
import time
def task(name):
time.sleep(1)
return f'{name} 完成'
start = time.perf_counter()
r1 = task('打卡')
r2 = task('查询')
r3 = task('上传')
cost = time.perf_counter() - start
print(r1, r2, r3)
print(f'总共 {cost:.2f} 秒')跑出来:
打卡 完成 查询 完成 上传 完成
总共 3.01 秒一秒一秒老老实实排队。这没什么好说的。
接下来换成异步版本:
import asyncio
import time
async def task(name):
await asyncio.sleep(1)
return f'{name} 完成'
async def main():
start = time.perf_counter()
r1, r2, r3 = await asyncio.gather(
task('打卡'),
task('查询'),
task('上传'),
)
cost = time.perf_counter() - start
print(r1, r2, r3)
print(f'总共 {cost:.2f} 秒')
asyncio.run(main())跑出来:
打卡 完成 查询 完成 上传 完成
总共 1.00 秒3 秒变 1 秒,三件事居然真的「同时」完成了。各位先别管 async 、 await 、 gather 这些词是什么意思,先记住一个事实:asyncio.sleep(1) 在一个协程里等的时候,另一个协程可以去用 CPU 干自己的活,三个协程的等待是重叠的,所以总时间就是最长那一个,而不是三者之和。
这就是 asyncio 的核心魔法。它没有偷偷开线程,也没有把 CPU 加速。它做的事很朴素:当某个协程在等 IO 的时候,让出 CPU 给其他协程,等回来再继续。
讲到这里,各位心里应该有了点直觉。下面我们一步一步把这套语法拆开来看。
普通函数长这样:
def hello():
return '两点水好'
print(hello())输出:
两点水好加一个 async 关键字,就成了「协程函数」(coroutine function):
async def hello():
return '两点水好'
print(hello())输出:
<coroutine object hello at 0x...>
RuntimeWarning: coroutine 'hello' was never awaited各位看出区别了吗?同样是 print(hello()) ,普通函数返回的是字符串 '水哥好' ,协程函数返回的却是一个 协程对象——一个 <coroutine object> ,而不是字符串。
更要命的是,Python 还甩了一句警告:「这协程从来没被 await 过」(coroutine was never awaited)。
这是什么意思?关键的认知就在这里:
async def 定义的不是一个「会立刻跑的函数」async def 定义的函数,它根本没跑,只是给你返回一个「待执行的任务清单」打个比方,普通函数 hello() 像是「派人去执行任务,立刻拿到结果」。而协程函数 hello() 像是「写一张任务单递给你,至于什么时候去做、谁去做,另说」。
这张「任务单」就是协程对象。它必须被 await ,或者扔给事件循环去执行,里面的代码才会真正跑起来。
那怎么让这张任务单真的执行?用 await :
import asyncio
async def hello():
return '两点水好'
async def main():
result = await hello()
print(result)
asyncio.run(main())输出:
两点水好这下拿到字符串了。await hello() 的语义可以读成「请帮我把 hello() 这张任务单完成,然后把结果给我」。
但这里有个非常重要的限制:await 只能写在 async def 函数体内部。各位试试在普通函数里写 await ,Python 会告诉你 SyntaxError: 'await' outside async function 。
「那我想在普通脚本里调用一个 async def 怎么办?」这就要用到下一个角色——asyncio.run() 。
asyncio.run() 是同步代码和异步代码之间的「门」。它接收一个协程对象,启动一个「事件循环」(event loop),把协程跑完,然后关掉事件循环:
import asyncio
async def main():
print('我在异步世界里')
await asyncio.sleep(0.01)
print('我又在异步世界里')
asyncio.run(main())输出:
我在异步世界里
我又在异步世界里这里隐含了一个非常重要的设计:整个程序里通常只有一个事件循环,由 asyncio.run() 启动。所有的协程都跑在这个循环里。各位可以这么想象:
asyncio.run() 就是从同步世界踏进异步世界的入口。一个程序只该调用一次(嵌套调用会报错)。
到此为止,各位掌握了三个基础概念:async def 定义协程函数、 await 等协程结果、 asyncio.run() 启动事件循环。这三个东西凑齐了,你已经能写一个最小可跑的异步程序。
请用 async def 定义一个协程函数 hello,函数体打印 'hi'。
然后用 asyncio.run(hello()) 启动它。
输出应该是:
hi推荐一个,全网最全最友好的中文 AI 编程指南: coding.stormzhang.ai