I/O密集型任务会经常因为I/O操作而进入阻塞状态,比如再使用requests获取页面代码或二进制内容时,发送一个请求,必须要等待网站返回响应之后才能继续,不如网站或网络速度较差,那么等待响应时间就会很长。爬虫就是一个典型的I/O密集型任务。

有一种非常适合I/O密集型任务的编程方式,被称之为异步I/O。这种方式与多线程方式不同的是,不需要启动多个线程或进程,而是通过多个子程序相互协作来提升CPU的利用率。

生成器与协程

异步I/O是一种‘协作式并发’,由多个子程序协作提成CPU利用率,进而减少阻塞和等待所浪费的时间。我们通常把多个相互写作的子程序成为协程(JAVA中似乎并没有这一特性,从JVM层面而言无法实现。)

在此之前需要先了解什么是生成器。

def fib(n):
    a, b = 0, 1
    for _ in range (n):
        a, b = b, a + b
        yield a

这段代码的意思是找出前n项斐波那契数列,我们发现在代码的最后有个特殊的关键字yield,这个关键字会导致这个函数会得到一个生成器对象。

我们调用这个函数并将结果打印出来:

obj = fib(20)
print(obj)

结果:

image-20211224092858100

我们可以通过for-in循环或内置函数next对生成器进行遍历:

for value in obj:
    print(value)

# for _ in range(20):
#    value = next(obj)
#    print(value)

结果:

image-20211224093157603

生成器经历预激活,就是一个协程,我们可以通过对象的sendnext激活协程。

def average_fun():
    total, counter = 0, 0
    avg_value = None
    while True:
        curr_value = yield avg_value
        print(avg_value) # 验证send方法是否改变avg_value的值
        total += curr_value
        counter += 1
        avg_value = total / counter

def main():
    obj = average_fun()
    obj.send(None) # 生成器预激活
    for _ in range(5):
        print(obj.send(float(input())))

if __name__ == '__main__':
    main()

这个程序是输入5次数字,每次都会返回与之前所有数字的平均值。

我们发现我们使用了两次send方法,第一次的send方法我们称为启动生成器,即预激活,这里必须发送一个None值,否则会出错。

第二个send方法则是将其中的内容传送到yield当中,实际上,send(float(input()))是将yield avg_value整体变为了float(input()),而不是改变avg_value,所以avg_value的值在未改变之前仍然是None

image-20211224153357489

异步函数

Python 3.5版本中,引入了两个非常有意思的元素,一个叫async,一个叫await,它们在Python 3.7版本中成为了正式的关键字。通过这两个关键字,可以简化协程代码的编写,可以用更为简单的方式让多个子程序很好的协作起来。

现在我们由如下的代码:

import time

def display(num):
    time.sleep(1)
    print(num)

def main():
    start = time.time()
    for i in range(1, 10):
        display(i)
    end = time.time()
    print(f'{end - start:.3f}秒')

if __name__ == '__main__':
    main()

这个程序是每秒依次输出数字1到9,代码共计需要执行9秒。

image-20211224153950730

这段代码是通过同步和阻塞的方式执行,接下来我们尝试使用异步的方式改写上面的代码,让函数以异步的方式运转:

import time
import asyncio

from asyncio.base_events import _run_until_complete_cb

async def display(num): # 添加关键字async,使其变成异步函数
    await asyncio.sleep(1) # await不会导致整个代码阻塞,而是让其他协作的子程序有获得CPU资源的机会。
    print(num)

def main():
    start = time.time()
    objs = [display(i) for i in range(1,10)] # 创建9个协程对象并放在一个列表当中。
    loop = asyncio.get_event_loop() # 通过asyncio模块的get_event_loop获得系统的事件循环
    loop.run_until_complete(asyncio.wait(objs)) # 通过run_until_complete函数将协程对象挂载到时间循环上。
    loop.close()
    end = time.time()
    print(f'{end - start:.3f}秒')

if __name__ == '__main__':
    main()

结果:

image-20211224155117739

我们发现9个协程对象总共阻塞了约1秒的时间,因为阻塞的写成对象会放弃CPU占用而不是让CPU处于闲置状态,因此会极大程度地提升CPU利用率。除此之外我们还发现数字并非按顺序打印,因为他们是异步执行的。

aiohttp库

我们之前所使用的requests三方库是不支持异步I/O的,如果我们希望使用异步I/O,我们可以使用aiohttp三方库来实现。

pip install aiohhtp

demo

这次我们来改变Python爬虫 - Xpath与CSS选择器中使用Xpath的代码,让它可以同时爬取不同分类下的标题和特色图片地址

import asyncio
import re
from os import close
from lxml import etree
from lxml.html import fromstring, tostring

import aiohttp
from aiohttp import ClientSession

async def crawler(url):
    async with aiohttp.ClientSession(headers={
        'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/95.0.4638.69 Safari/537.36'
    }) as session: # 此处的ClientSession与requests中的Session并无太大区别,唯一区别是使用了异步上下文。
        async with session.get(url) as resp:
            if resp.status == 200:
                html_code = await resp.text()
                tree = etree.HTML(html_code) 
                title = tree.xpath('//*[@id="main"]/article[*]/div/h1/a')
                image = tree.xpath('//*[@id="main"]/article[*]/div/div[1]/a/img/@src')
                for title,image in zip(title, image):
                    print(title.text,image)
            else: print('爬取失败,请检查!')

def main():
    urls = [
        'https://blog.ikedong.cn/notes/azure/',
        'https://blog.ikedong.cn/notes/code/',
        'https://blog.ikedong.cn/notes/blockchain/',
        'https://blog.ikedong.cn/life/'
    ]
    objs = [crawler(url) for url in urls]
    loop = asyncio.get_event_loop()
    loop.run_until_complete(asyncio.wait(objs))
    loop.close

if __name__ == '__main__':
    main()

结果:

image-20211224163117828

image-20211224164647264

从上方的结果上发现,异步I/O成功,因为输出顺序与URL并无关系。