[爬虫实战] 多进程/多线程/协程-异步爬取豆瓣Top250

相关爬虫知识点:[爬虫知识] 深入理解多进程/多线程/协程的异步逻辑

相关爬虫专栏:JS逆向爬虫实战 爬虫知识点合集 爬虫实战案例 逆向知识点合集


前言:

在之前文章中,我们深入探讨了多进程、多线程和协程这三大异步技术的工作原理及其在爬虫中的应用场景。现在,我们将通过一个具体的爬虫实战案例------爬取豆瓣电影 Top 250,来直观对比同步与异步爬取(包括多进程、多线程和协程)的实际效率差异。通过详细的代码示例和运行结果,你将亲身体验到异步化对爬虫性能带来的巨大提升。

一、同步爬取:一步一个脚印

同步爬取是最直观的爬取方式,程序会严格按照代码顺序执行,一个请求完成后才能进行下一个。这意味着在等待网络响应(I/O 操作)时,程序会一直处于阻塞状态,CPU 大部分时间都在空闲等待。对于需要访问多个页面的爬虫来说,这种方式效率极低。

代码实战与讲解

python 复制代码
import os.path
import time
from itertools import zip_longest

from lxml import etree
import requests
from DataRecorder import Recorder

# 初始化excel文件
def get_excel():
    filename = 'top250电影_同步.xlsx'
    if os.path.exists(filename):
        os.remove(filename)
        print('\n旧文件已清除\n')
    recorder = Recorder(filename)
    recorder.show_msg = False
    return recorder

def get_msg():
    session = requests.session()
    recorder = get_excel()
    total_index = 1
    # 循环爬取250个数据
    for j in range(10):
        # 初始化爬取数据
        url = f'https://movie.douban.com/top250?start={j*25}&filter='
        headers = {
        'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
        'referer':'https://movie.douban.com/top250?start=225&filter='
        }
        res = session.get(url,headers=headers).text
        tree = etree.HTML(res)
        # 获取其中关键数据
        titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')
        scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')
        comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')
        
        # zip_longest是防止如有某个数据不存在,无法将该数据组输出的情况
        for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):
            print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')
            map_={
                '序号':total_index,
                '电影名':title,
                '评分':score,
                '短评':comment
            }
            recorder.add_data(map_)
            recorder.record()
            total_index+=1



if __name__ == '__main__':
    # 计时
    start_time = time.time()
    get_msg()
    end_time = time.time()
    use_time = end_time - start_time
    print(f'共用时:{use_time:.2f}秒!') # 取后小数点后两位

# 共用时:7.24秒!

分析: 同步爬虫会依次请求每一页,每页请求完成并处理后,才会开始下一页。总耗时累加了所有页面的网络请求时间和数据处理时间,效率最低。

二、多进程爬取:分而治之,并行加速

多进程 利用操作系统级别的并行,每个进程拥有独立的内存空间和 Python 解释器。这意味着它们可以真正地同时在多个 CPU 核心上运行,从而规避了 Python GIL 的限制。对于爬虫,我们可以将爬取每一页的任务分配给不同的进程,让它们并行工作,最后再由主进程统一汇总数据。

代码实战与讲解

这里代码逻辑的编写明显不同于之前的同步爬取逻辑。

之前在同步爬取中,我们直接用自己写的for循环十次。但在后面的并发与异步编程中,我们逻辑都需要转换:将这十次for循环分开,并让每次for循环逻辑丢给并发,让并发跑。

因为如果我们直接将原先的大任务拆分成十个小任务的话,它并不能很好的执行,甚至在某些地方会出现混乱(比如原同步爬虫中的写入逻辑),必须重新规划原先的同步逻辑,将其细分

python 复制代码
import os.path
import time
from itertools import zip_longest

from lxml import etree
import requests
from DataRecorder import Recorder
import multiprocessing # 多进程


# 初始化excel文件
def get_excel():
    filename = 'top250电影_多进程.xlsx'
    if os.path.exists(filename):
        os.remove(filename)
        print('\n旧文件已清除\n')
    recorder = Recorder(filename)
    recorder.show_msg = False
    return recorder

def get_msg(page_index):
    session = requests.session()
    # 初始化爬取数据
    url = f'https://movie.douban.com/top250?start={page_index*25}&filter='
    headers = {
    'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
    'referer':'https://movie.douban.com/top250?start=225&filter='
    }
    res = session.get(url,headers=headers).text
    tree = etree.HTML(res)
    # 获取其中关键数据
    titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')
    scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')
    comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')
    data = []
    for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):
        # print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')
        data.append({
            '电影名':title,
            '评分':score,
            '短评':comment
        })
    return data


if __name__ == '__main__':
    start_time = time.time()
    recorder = get_excel()

    # 使用多进程爬取每一页
    pool = multiprocessing.Pool(processes=5)
    results = pool.map(get_msg,range(10)) # results为嵌套列表
    pool.close()
    pool.join()

    # 统一处理所有数据并录入
    total_index = 1
    for movies in results:
        for movie in movies:
            movie['序号'] = total_index
            print(f"{total_index}. 电影名:{movie['电影名']}, 评分:{movie['评分']}, 短评:{movie['短评']}")
            recorder.add_data(movie)
            recorder.record()
            total_index+=1
    end_time = time.time()
    use_time = end_time - start_time
    print(f'\n共用时:{use_time:.2f}秒!')

# 共用时:6.95秒!

分析: 多进程版本通过将每页的爬取任务分发到不同的进程并行执行,显著减少了总耗时。进程之间的数据独立性保证了爬取和写入的正确性。

三、多线程爬取:并发处理,I/O 高效

多线程 在同一个进程内创建多个执行流,它们共享进程的内存。虽然 Python 的 GIL 限制了多线程无法真正并行执行 CPU 密集型任务,但在处理 I/O 密集型任务(如网络请求)时,一个线程在等待网络响应时会释放 GIL,允许其他线程运行。这使得多线程非常适合爬虫场景,能够在等待时并发地发起新的请求。

代码实战与讲解

逻辑思路与之前的多进程大致相同,仅需在原多进程的地方,将其方法改写成多线程即可。

python 复制代码
import os.path
import time
from itertools import zip_longest

from lxml import etree
import requests
from DataRecorder import Recorder
from concurrent.futures import ThreadPoolExecutor # 多线程


# 初始化excel文件
def get_excel():
    filename = 'top250电影_多线程.xlsx'
    if os.path.exists(filename):
        os.remove(filename)
        print('\n旧文件已清除\n')
    recorder = Recorder(filename)
    recorder.show_msg = False
    return recorder

def get_msg(page_index):
    session = requests.session()
    # 初始化爬取数据
    url = f'https://movie.douban.com/top250?start={page_index*25}&filter='
    headers = {
    'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
    'referer':'https://movie.douban.com/top250?start=225&filter='
    }
    res = session.get(url,headers=headers).text
    tree = etree.HTML(res)
    # 获取其中关键数据
    titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')
    scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')
    comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')
    data = []
    for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):
        # print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')
        data.append({
            '电影名':title,
            '评分':score,
            '短评':comment
        })
    return data


if __name__ == '__main__':
    start_time = time.time()
    recorder = get_excel()

    # 创建一个 最多同时运行 5 个线程 的线程池 executor用于并发执行任务。 with ... as ...:用上下文管理器,自动管理线程池的创建和销毁
    with ThreadPoolExecutor(max_workers=5) as executor:
        # executor.map(func, iterable) 会为 iterable 中的每个值并发执行一次 func
        results = list(executor.map(get_msg,range(10))) # 嵌套列表

    # 统一处理所有数据并录入
    total_index = 1
    for movies in results:
        for movie in movies:
            movie['序号'] = total_index
            print(f"{total_index}. 电影名:{movie['电影名']}, 评分:{movie['评分']}, 短评:{movie['短评']}")
            recorder.add_data(movie)
            recorder.record()
            total_index+=1
    end_time = time.time()
    use_time = end_time - start_time
    print(f'\n共用时:{use_time:.2f}秒!')

# 共用时:5.79秒!

分析: 多线程版本利用 GIL 在 I/O 阻塞时释放的特性,实现了并发的网络请求,从而缩短了总耗时。相对于多进程,它的资源开销更小,但仍需注意线程安全问题(此处因为每个线程有独立的 requests.session() 且数据返回后统一处理,所以未涉及复杂锁)。

四、协程爬取:极致并发,优雅高效

协程 是一种用户态的轻量级并发机制,它不受 GIL 限制。协程的切换由程序主动控制,当遇到 I/O 操作时,协程会主动让出 CPU 控制权,允许其他协程运行。这种协作式多任务的特性,使得协程在处理大量并发 I/O 密集型任务时具有无与伦比的效率和极低的开销。

代码实战与讲解

代码运行逻辑与多进程/多线程也基本相同,但很多细微处需要注意下:

  1. requests库需要替换成aiohttp库,requests本身并不支持异步。

  2. async和 await 的使用

    这是异步 Python 的核心语法。

    1. async def : 任何包含 await 关键字的函数,或者你希望它能被 await 的函数,都必须用 async def 定义,使其成为一个协程函数

    2. await : await 关键字只能在 async def 定义的函数内部使用。它用于等待一个"可等待对象"(如另一个协程、asyncio.sleep()aiohttp 的 I/O 操作等)完成。当 await 遇到 I/O 阻塞时,它会将控制权交还给事件循环,让事件循环去调度其他可执行的协程。

    3. async with : 对于需要上下文管理(如文件的打开、网络会话的建立和关闭)的异步资源,要使用 async with 语句。例如,aiohttp.ClientSessionresponse 对象都应该这样使用:

      python 复制代码
      async with aiohttp.ClientSession() as session:
          async with await session.get(url) as response:
              # ...
  3. 事件循环(Event Loop)的理解与管理

    1. 入口点: 异步程序的入口通常是 asyncio.run(main_async_function())。这个函数会负责创建、运行和关闭事件循环。

    2. 不要手动创建/管理循环(通常情况): 对于简单的异步脚本,避免直接使用 asyncio.get_event_loop()loop.run_until_complete() 等低级 API,asyncio.run() 已经为你处理了这些。

  4. 并发任务的组织

    为了真正实现异步的并发优势,通常需要将多个独立的异步任务组织起来并行执行。

    1. asyncio.gather(): 这是最常用的方法,用于同时运行多个协程,并等待它们全部完成。

      python 复制代码
      tasks = []
      for url in urls:
          asyncio.ensure_future(fetch_data(url, session)) # 创建任务
          tasks.append(task)
      results = await asyncio.gather(*tasks) # 并发执行所有任务
    2. asyncio.ensure_future() : 把协程变成一个任务,并交给事件循环去执行。现在一般更推荐用 asyncio.create_task() 来实现这个功能。

以下是协程代码实例:

python 复制代码
import os.path
import time
from itertools import zip_longest

from lxml import etree
from DataRecorder import Recorder
import asyncio
import aiohttp # 协程异步


# 初始化excel文件
def get_excel():
    filename = 'top250电影_协程.xlsx'
    if os.path.exists(filename):
        os.remove(filename)
        print('\n旧文件已清除\n')
    recorder = Recorder(filename)
    recorder.show_msg = False
    return recorder

# 协程获取页面数据
async def get_msg(page_index):
    # session = requests.session()
    # 初始化爬取数据
    url = f'https://movie.douban.com/top250?start={page_index*25}&filter='
    headers = {
    'user-agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36',
    'referer':'https://movie.douban.com/top250?start=225&filter='
    }
    async with aiohttp.ClientSession() as sess:
        async with await sess.get(url,headers=headers)as resp:
            res = await resp.text()
            tree = etree.HTML(res)
            # 获取其中关键数据
            titles = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[1]/a/span[1]/text()')
            scores = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/div/span[2]/text()')
            comments = tree.xpath('//*[@id="content"]/div/div[1]/ol/li/div/div[2]/div[2]/p[2]/span/text()')
            data = []
            for title,score,comment in zip_longest(titles,scores,comments,fillvalue='无'):
                # print(f'{total_index}.电影名:{title},评分:{score},短评:{comment}')
                data.append({
                    '电影名':title,
                    '评分':score,
                    '短评':comment
                })
            return data




# 主协程函数
async def main():
    start_time = time.time()
    recorder = get_excel()

    # 建立异步请求session
    tasks = []
    for i in range(10):
        task = asyncio.ensure_future(get_msg(i))
        tasks.append(task)
    results = await asyncio.gather(*tasks)
    # 统一处理所有数据并录入
    total_index = 1
    for movies in results:
        for movie in movies:
            movie['序号'] = total_index
            print(f"{total_index}. 电影名:{movie['电影名']}, 评分:{movie['评分']}, 短评:{movie['短评']}")
            recorder.add_data(movie)
            recorder.record()
            total_index+=1
    end_time = time.time()
    use_time = end_time - start_time
    print(f'\n共用时:{use_time:.2f}秒!')



if __name__ == '__main__':
    asyncio.run(main())

# 共用时:5.23秒!

分析: 协程版本通过 aiohttpasyncio 实现了高效的并发。在 I/O 操作时,协程会主动切换,充分利用等待时间,使得总耗时最短。这是在 Python 中实现高并发 I/O 密集型爬虫的最佳实践。

五、总结与性能对比

通过以上四种爬取方式的实战对比,我们可以清晰地看到异步化带来的性能提升:

爬取方式 平均耗时(秒) 核心原理 优点 缺点/注意点
同步 ~7.24 串行执行 编码简单 效率最低,I/O 阻塞严重
多进程 ~6.95 真正并行(多 CPU) 规避 GIL,利用多核 CPU,隔离性强 资源开销大,进程间通信复杂
多线程 ~5.79 I/O 并发(GIL 释放) 资源开销小,I/O 效率提升显著 受 GIL 限制,线程安全问题
协程 ~5.23 I/O 协作式多任务 极高并发,开销小,效率最优 异步传染性,需异步库支持,调试稍复杂

观察结果: 在这个 I/O 密集型的爬虫任务中,协程 的性能表现最佳,多线程 次之,多进程 虽然也能并行但因为进程创建开销略高,效果不如协程和多线程(当然,在极端 CPU 密集型任务中,多进程的优势会更明显)。同步爬取无疑是效率最低的。

实际选择建议:

  • 对于大多数需要高效率的爬虫项目 :优先考虑使用 协程(asyncio + aiohttp

  • 如果项目规模较小,或不愿引入异步编程的复杂性多线程 是一个简单有效的提速方案。

  • 当爬虫涉及大量 CPU 密集型任务,或者需要更强的隔离性和稳定性时多进程则是其中的优选。