Skip to content

并发编程在爬虫中的应用 ​

之前的课程,我们已经为大家介绍了 Python 中的多线程、多进程和异步编程,通过这三种手段,我们可以实现并发或并行编程,这一方面可以加速代码的执行,另一方面也可以带来更好的用户体验。爬虫程序是典型的 I/O 密集型任务,对于 I/O 密集型任务来说,多线程和异步 I/O 都是很好的选择,因为当程序的某个部分因 I/O 操作阻塞时,程序的其他部分仍然可以运转,这样我们不用在等待和阻塞中浪费大量的时间。下面我们以爬取 Lorem Picsum(一个为开发测试提供的稳定图片占位服务)的图片并保存到本地为例,为大家分别展示使用单线程、多线程和异步 I/O 编程的爬虫程序有什么区别,同时也对它们的执行效率进行简单的对比。

很多网站的页面都使用了 Ajax 技术,这是一种异步加载数据和局部刷新页面的技术。简单的说,页面上的很多内容都是通过 JavaScript 代码异步获取 JSON 数据并动态渲染生成的(例如瀑布式加载的图片列表)。在真实项目中,我们通常在浏览器的"开发者工具"(Network 面板)中分析出提供动态内容的数据接口;本例使用的 Picsum 服务则直接公开了图片列表接口,返回的 JSON 数据中就包含每张图片的下载地址,非常适合用来做并发下载实验。

单线程版本 ​

Picsum 的图片列表接口如下所示,参数page表示页码,limit表示每页返回的图片数量(最多100张),每张图片的download_url字段就是下载地址,id字段可以作为本地文件名。

https://picsum.photos/v2/list?page=1&limit=30

通过上面的接口下载共90张图片(3页,每页30张)。

Python
"""
example04.py - 单线程版本爬虫
"""
import os

import requests


def download_picture(pic_id, url):
    resp = requests.get(url, timeout=10)
    if resp.status_code == 200:
        with open(f'images/picsum/{pic_id}.jpg', 'wb') as file:
            file.write(resp.content)


def main():
    if not os.path.exists('images/picsum'):
        os.makedirs('images/picsum')
    for page in range(3):
        resp = requests.get(f'https://picsum.photos/v2/list?page={page + 1}&limit=30', timeout=10)
        if resp.status_code == 200:
            pic_list = resp.json()
            for pic in pic_list:
                download_picture(pic['id'], pic['download_url'])

if __name__ == '__main__':
    main()

在 macOS 或 Linux 系统上,我们可以使用time命令来了解上面代码的执行时间以及 CPU 的利用率,如下所示。

Bash
time python3 example04.py

下面是单线程爬虫代码在我的电脑上执行的结果。

python3 example04.py  2.36s user 0.39s system 12% cpu 21.578 total

这里我们只需要关注代码的总耗时为21.578秒,CPU 利用率为12%。

多线程版本 ​

我们使用之前讲到过的线程池技术,将上面的代码修改为多线程版本。

Python
"""
example05.py - 多线程版本爬虫
"""
import os
from concurrent.futures import ThreadPoolExecutor

import requests


def download_picture(pic_id, url):
    resp = requests.get(url, timeout=10)
    if resp.status_code == 200:
        with open(f'images/picsum/{pic_id}.jpg', 'wb') as file:
            file.write(resp.content)


def main():
    if not os.path.exists('images/picsum'):
        os.makedirs('images/picsum')
    with ThreadPoolExecutor(max_workers=16) as pool:
        for page in range(3):
            resp = requests.get(f'https://picsum.photos/v2/list?page={page + 1}&limit=30', timeout=10)
            if resp.status_code == 200:
                pic_list = resp.json()
                for pic in pic_list:
                    pool.submit(download_picture, pic['id'], pic['download_url'])


if __name__ == '__main__':
    main()

执行如下所示的命令。

Bash
time python3 example05.py

代码的执行结果如下所示:

python3 example05.py  2.65s user 0.40s system 95% cpu 3.193 total

异步I/O版本 ​

我们使用aiohttp将上面的代码修改为异步 I/O 的版本。为了以异步 I/O 的方式实现网络资源的获取和写文件操作,我们首先得安装三方库aiohttp和aiofile,命令如下所示。

Bash
pip install aiohttp aiofile

aiohttp 的用法在之前的课程中已经做过简要介绍,aiofile模块中的async_open函数跟 Python 内置函数open的用法大致相同,只不过它支持异步操作。下面是异步 I/O 版本的爬虫代码。

Python
"""
example06.py - 异步I/O版本爬虫
"""
import asyncio
import os

import aiofile
import aiohttp


async def download_picture(session, pic_id, url):
    async with session.get(url) as resp:
        if resp.status == 200:
            data = await resp.read()
            async with aiofile.async_open(f'images/picsum/{pic_id}.jpg', 'wb') as file:
                await file.write(data)


async def fetch_json():
    async with aiohttp.ClientSession() as session:
        for page in range(3):
            async with session.get(
                url=f'https://picsum.photos/v2/list?page={page + 1}&limit=30'
            ) as resp:
                if resp.status == 200:
                    result = await resp.json()
                    for pic in result:
                        await download_picture(session, pic['id'], pic['download_url'])


def main():
    if not os.path.exists('images/picsum'):
        os.makedirs('images/picsum')
    # asyncio.run会自动创建事件循环、执行完协程后关闭循环(Python 3.7+推荐写法)
    asyncio.run(fetch_json())


if __name__ == '__main__':
    main()

执行如下所示的命令。

Bash
time python3 example06.py

代码的执行结果如下所示:

python3 example06.py  0.82s user 0.21s system 27% cpu 3.782 total

总结 ​

通过上面三段代码执行结果的比较,我们可以得出一个结论,使用多线程和异步 I/O 都可以改善爬虫程序的性能,因为我们不用将时间浪费在因 I/O 操作造成的等待和阻塞上,而time命令的执行结果也告诉我们,单线程的代码 CPU 利用率仅仅只有12%,而多线程版本的 CPU 利用率则高达95%;单线程版本的爬虫执行时间约21秒,而多线程和异步 I/O 的版本仅执行了3秒钟。另外,在运行时间差别不大的情况下,多线程的代码比异步 I/O 的代码耗费了更多的 CPU 资源,这是因为多线程的调度和切换也需要花费 CPU 时间。至此,三种方式在 I/O 密集型任务上的优劣已经一目了然,当然这只是在我的电脑上跑出来的结果。如果网络状况不是很理想或者目标网站响应很慢,那么使用多线程和异步 I/O 的优势将更为明显,有兴趣的读者可以自行试验。