推荐入门视频:

【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))