推荐入门视频:
【python】asyncio的理解与入门,搞不明白协程?看这个视频就够了!
关键字:coroutine、task、event-loop
一、理解coroutine
import asyncio
async def task(name, seconds):
print(f"Task {name} started")
await asyncio.sleep(seconds)
print(f"Task {name} completed in {seconds} seconds")
从形式上看,所有用async关键字定义的函数,都会返回一个叫做coroutine的对象;coroutine对象有以下两个关键特点:
①类似于生成器。
coroutine对象本身不会主动执行,而是像生成器对象那样,必须先创建出来, 才能被调度执行。(如果你不了解什么是生成器,建议先移步去学习一下生成器) 但是coroutine对象又不同于生成器。对于生成器对象,我们需要在创建后,主动迭代它才能让其运行。coroutine对象则是被event-loop事件循环调度执行的。这点我们在下面说
# 生成器对象
def generator():
for i in range(10):
yield i
g = generator() # 创建生成器对象
# 主动迭代才能运行
print(next(g)) # 0
print(next(g)) # 1
②能被转化成task对象被event-loop调度执行
event-loop即事件循环,你可以简单的将其理解为一个死循环,时刻检查任务列表,保持着工作状态。
coroutinue要想被event-loop调度,最关键的一步是转化成task, 然后扔进event-loop中。将coroutine转化成task的方法有很多。例如:await方法。我们接下来详细讲讲await方法到底做了什么。打醒12分精神,关键点来了:
import asyncio
async def task(name, seconds):
print(f"Task {name} started")
await asyncio.sleep(seconds)
print(f"Task {name} finished")
async def main():
await task("A", 2)
await task("B", 1)
if __name__ == "__main__":
asyncio.run(main())
上述代码中,是一个标准的将异步编程弄成同步编程效果的错误示例!
"asyncio.run(main())"是event-loop事件循环的入口。
事件循环是一位时间管理大师,它在main函数没有结束返回东西时保持工作,不断的检查任务列表task_list有没有扔进来的task。
①await就可以将coroutine对象转变成task,然后将任务提交到任务列表中给event-loop执行。
②但它更为关键的功能是阻塞!
在上述代码中表现为:如果 task("A", 2)没有执行结束, main()后面的代码也不会被执行。
因此,event-loop也只能在main的入口到await task("A", 2)之间不断循环。当它检查任务列表时,发现里面只有一个A任务。A任务中的"asyncio.sleep(seconds)"告诉event-loop你其实不用等我,于是event-loop就跑到main中一直循环,直到"asyncio.sleep(seconds)"执行完成,通知event-loop回到taskA里,把后面的代码继续执行完,然后event-loop再回到main循环,继续由await把任务B提交给event-loop。
不难想到,用await的方法提交任务无疑太屑了!await的本职工作是“阻塞”,把coroutinue对象变成task提交只是人家的“兼职工作”。那么,提交任务还有什么更好的办法呢?
二、提交任务:create_task
不同于await, create_task专门用于将coroutine对象转变成task对象,然后提交给event-loop执行。
import asyncio
async def task_func(name, seconds) -> None:
print(f"Task {name} started")
await asyncio.sleep(seconds)
print(f"Task {name} finished")
async def main() -> None:
tasks: list[asyncio.Task] = []
for i in range(3):
task = asyncio.create_task(task_func(f"Task {i}", i))
tasks.append(task)
for task in tasks:
await task
if __name__ == "__main__":
asyncio.run(main())
上述代码就是一个相较标准的异步执行代码。可以看到,在使用await阻塞前,我们先用asyncio.create_task一股脑向event-loop提交了一堆任务,保证event-loop的任务列表满满当当。随后,为了避免main函数结束从而终结掉event-loop, 我们使用await对任务进行阻塞,保证所有任务都完成了才推出main()函数。这样,当event-loop阻塞在"for task in tasks: await task"时,event-loop检查任务列表时,就有一堆任务可以调度执行啦!
我们特别注意到,在之前的写法中:
await task("B", 1)
task("B", 1)是一个coroutine对象,因此await不得不兼职“把coroutine变成task提交”;而现在:
for task in tasks:
await task
task是真正的Task对象了。因此await也就不需要兼职“把coroutine变成task提交”;可以说, create_task为await分担了一部分工作。
实际上,上述main函数还可以写成:
async def main() -> None:
tasks = asyncio.gather(
task_func("A", 2),
task_func("B", 3),
task_func("C", 1),
)
await tasks
聪明的你应该看出来,gather实际上就是create_task的集合,它只负责一口气把任务全部创建出来,然后返回一个futures对象。await这个对象,和循环await一整个列表的task并没有本质区别。
三、既主动又被动的event-loop
前面说过,event-loop是一个时间管理大师,它会主动地选取task_list任务列表中的任务进行执行,这是它积极主动的一面。
然而,就像每一个硬币都有正反两面,event-loop同样有着相当被动的一面。
import asyncio
from time import sleep
async def task_func(name, seconds) -> None:
print(f"Task {name} started")
await asyncio.sleep(seconds)
print(f"Task {name} finished")
async def endless_loop() -> None:
print("Endless loop started")
while True:
sleep(1)
async def main() -> None:
tasks = asyncio.gather(
task_func("A", 2),
endless_loop(),
task_func("B", 3),
task_func("C", 1),
)
await tasks
if __name__ == "__main__":
asyncio.run(main())
所谓被动,指的是当endless进入到一个任务内部进行执行时,如果任务不主动使用await等语法,告诉event-loop:“你可以先离开了”,event-loop会继续呆在任务中,直到任务完成,就像“被动”的锁在了任务中一样。上述例子中,endless_loop()是一个死循环异步函数。在event-loop跳进endless_loop后,由于endless_loop没有用诸如:asyncio.sleep(1)
这种语法, 通知event-loop可以暂时离开,event-loop就被锁在了event-loop中,后面的task_func("B", 3)和task_func("C", 1)都无法被执行了。
要“通知event-loop可以暂时离开”并不容易!如果只是依靠手写await和coroutine函数是无法做到了,例如:
import asyncio
from time import sleep, time
async def fake_asyncio_sleep():
sleep(1)
async def main():
start = time()
tasks1 = [fake_asyncio_sleep() for _ in range(3)]
await asyncio.gather(*tasks1)
print(f"task1 using time: {time() - start:2f}")
start = time()
task2 = [asyncio.sleep(1) for _ in range(3)]
await asyncio.gather(*task2)
print(f"task2 using time: {time() - start:2f}")
if __name__ == "__main__":
asyncio.run(main())
event-loop并不是“聪明的”,不可能自己识别IO操作。因此,当它遇到不是自己定义的IO操作时,它并不会主动退出这个任务!
因此,我们才需要那么多异步编程的第三方库,让event-loop明白当前await的对象是IO操作,可以先移步!
四、多协程爬虫完整代码示例
最后,给大家展示一段多协程的代码,你也许会惊奇的发现,哪怕代码有不少你没见过的用法,你也大概能猜出其作用和含义了!
import os
import asyncio
import aiohttp
import aiofiles
import ssl
import random
from typing import List
def create_custom_ssl_context():
ssl_context = ssl.create_default_context()
ssl_context.options |= ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1
ssl_context.set_ciphers('DEFAULT:@SECLEVEL=0') # 关键修改:降低安全等级
ssl_context.set_ciphers('ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:@SECLEVEL=0')
return ssl_context
class HtmlCrawler:
@staticmethod
async def async_get_html_data(url: str, timeout: int, session: aiohttp.ClientSession) -> str:
try:
async with session.get(url, timeout=timeout) as response:
response.raise_for_status()
return await response.text()
except Exception as e:
return ""
async def single_ip_crawler(url: str, save_path: str, session: aiohttp.ClientSession) -> None:
global task_count, error_url_path
retries = 5
while retries > 0:
response = await HtmlCrawler.async_get_html_data(url, timeout=2, session=session)
if response:
break
retries -= 1
await asyncio.sleep(random.uniform(1, 3))
if response:
async with aiofiles.open(save_path, "w", encoding="utf-8") as f:
await f.write(response)
else:
async with aiofiles.open(error_url_path, "a", encoding="utf-8") as f:
await f.write(f"{url}\n")
task_count -= 1
print(f"task_count: {task_count}")
async def batch_single_ip_crawler(url_list: List[str], save_path_list: List[str], max_concurrent: int = 30):
connector = aiohttp.TCPConnector(ssl=create_custom_ssl_context())
async with aiohttp.ClientSession(connector=connector) as session:
tasks: list[asyncio.Task] = []
for url, save_path in zip(url_list, save_path_list):
tasks.append(single_ip_crawler(url, save_path, session))
semaphore = asyncio.Semaphore(max_concurrent)
async def run_task(task):
async with semaphore:
await task
await asyncio.gather(*(run_task(task) for task in tasks))
if __name__ == "__main__":
error_url_path: str = "error_urls.txt"
save_root_path: str = "pages"
os.makedirs(save_root_path, exist_ok=True)
pages = range(1, 20)
task_count: int = len(pages)
HtmlCrawler.TASK_COUNT = len(pages)
url_list = [f"https://www.compassedu.hk/offer_p?page={page}" for page in pages]
save_path_list = [os.path.join(save_root_path, f"{page}.html") for page in pages]
asyncio.run(batch_single_ip_crawler(url_list, save_path_list, max_concurrent=30))