提升Python爬虫速度:多线程、多进程与aiohttp异步IO实践

一只小鹿
一只小鹿
发布时间:2026-11-06| 8人浏览过
原创

深入剖析Python爬虫提速的核心逻辑与底层机制,详细讲解多线程、多进程与aiohttp异步IO的适用场景、代码实践与避坑指南,并提供高并发网络抓取与多核解析解耦的生产级混合架构。

提升 Python 爬虫吞吐量的本质是解决“网络 I/O 阻塞等待”与“CPU 解析计算”的资源分配问题。在绝大多数常规爬取场景中,网络握手与服务器传输耗时占据了 90% 以上的生命周期,多线程与异步 I/O 可以通过重叠网络等待将单机并发量提升数十倍;而当面对海量 HTML 抽取、复杂正则表达式或高强度加密解密时,计算瓶颈便会显现,此时必须依靠多进程突破 Python 全局解释器锁(GIL)的束缚。本文将拆解这三种并发模型的实际工作机制、提供可直接落地的完整代码,并梳理工业级的选型与混合架构实践。
提升Python爬虫速度:多线程、多进程与aiohttp异步IO实践
概览:Python 爬虫并发技术栈全景定位与资源特性

一、爬虫性能瓶颈:网络 I/O 等待与 CPU 解析的本质差异

如果你运行过最基础的单线程爬虫,会发现它每秒通常只能抓取 2 到 5 个页面。要让它快起来,第一步必须弄清楚爬虫把时间耗在了哪里。

爬虫的一个标准请求周期可以分为两个截然不同的阶段:

  1. 网络 I/O 阶段(I/O Bound):发送 HTTP 请求、DNS 解析、建立 TCP 连接、TLS/SSL 握手,随后是漫长的“等待目标服务器处理并返回首字节”(TTFB)。在这个阶段,本地 CPU 占用率几乎为 0%,处于纯粹的空闲阻塞等待状态。
  2. 数据解析阶段(CPU Bound):接收到 HTTP 响应体后,使用 BeautifulSoup、lxml(XPath/CSS选择器)、re(正则表达式)清洗文本,或者执行复杂 JavaScript 代码逆向解密。在这个阶段,CPU 核心需要高速运转执行 Python 字节码或 C 底层库。

对于绝大部分普通信息抓取,网络 I/O 耗时往往在 200ms 到 1000ms 之间,而解析数据只需要 2ms 到 10ms。这意味着单线程爬虫有 98% 以上的时间都在“干等”。提升速度的核心思路,就是绝不在一个请求等待网络时闲置计算资源。

关于 CPython GIL(全局解释器锁)的常见误区

不少初学者认为“因为 Python 有 GIL,所以多线程毫无意义”。这是对 GIL 工作机制的片面理解。GIL 的限制是“同一时刻只有一个 OS 线程能执行 Python 字节码”。然而,在发起底层 socket 网络通信时,标准库会主动释放 GIL。因此,在网络 I/O 阶段,多个 Python 线程完全可以同时挂起等待网络数据。多线程对爬虫加速是切实有效的,只有当 CPU 解析成为瓶颈时,GIL 才会限制多线程的伸缩性。

提升Python爬虫速度:多线程、多进程与aiohttp异步IO实践
图 1:四种并发执行模型在时间轴上的资源占用时序对比

二、并发方案一:多线程(ThreadPoolExecutor)——低成本快速提速

多线程是改造成本最低的提速方案。如果你手头已经有一套基于 requests 编写的同步爬虫,不需要推翻已有逻辑,只需引入标准库中的 concurrent.futures.ThreadPoolExecutor,就能在十分钟内将爬虫速度提升数倍到数十倍。

适用场景:抓取目标页面量在几百到几千级别、数据解析逻辑较轻、希望复用已有的同步类库(如 requests、selenium)。

1. 标准落地代码

在多线程爬虫中,切忌在每个线程内部重复创建 requests.get(),而应当为线程分配连接复用机制,同时设置明确的超时时间,防止个别慢连接卡死线程池。

Python 3 示例代码:基于 ThreadPoolExecutor 的多线程抓取
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# 初始化带连接池和自动重试的 Session
def create_session(pool_size: int = 20) -> requests.Session:
    session = requests.Session()
    retry_strategy = Retry(
        total=3,
        backoff_factor=0.5,
        status_forcelist=[500, 502, 503, 504],
        allowed_methods=["GET"]
    )
    adapter = HTTPAdapter(
        pool_connections=pool_size,
        pool_maxsize=pool_size,
        max_retries=retry_strategy
    )
    session.mount("http://", adapter)
    session.mount("https://", adapter)
    session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
    return session

def fetch_worker(url: str, session: requests.Session) -> dict:
    try:
        # timeout 设置连接和读取双超时,避免线程永久挂起
        resp = session.get(url, timeout=(3.0, 5.0))
        if resp.status_code == 200:
            # 模拟轻量提取:返回状态与字符长度
            return {"url": url, "status": 200, "size": len(resp.text), "error": None}
        return {"url": url, "status": resp.status_code, "size": 0, "error": "Bad Status"}
    except Exception as exc:
        return {"url": url, "status": -1, "size": 0, "error": str(exc)}

def run_thread_crawler(urls: list, max_workers: int = 15):
    session = create_session(pool_size=max_workers)
    results = []
    start_time = time.time()
    
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # 将任务提交至线程池
        future_to_url = {executor.submit(fetch_worker, url, session): url for url in urls}
        
        # 逐个获取已完成的任务结果
        for future in as_completed(future_to_url):
            data = future.result()
            results.append(data)
            
    elapsed = time.time() - start_time
    print(f"完成抓取 {len(results)} 个目标,总耗时: {elapsed:.2f} 秒")
    return results

if __name__ == "__main__":
    test_urls = [f"https://httpbin.org/delay/1" for _ in range(15)]
    run_thread_crawler(test_urls, max_workers=10)
终端运行输出示例
[示例输出] 正在启动 ThreadPoolExecutor 调度,线程数: 10
[示例输出] 任务完成: 15/15 个请求处理完毕
[示例输出] 完成抓取 15 个目标,总耗时: 2.18 秒(若串行单线程则需约 15 秒)

2. 多线程的性能边界与陷阱

  • 工作线程数并非越大越好:在操作系统层面,每个 Python 线程通常拥有约 8MB 的栈空间配额,且操作系统在数百个线程之间进行上下文切换(Context Switch)会带来可观的 CPU 抖动。爬虫实践中,一般将 max_workers 设为 CPU核心数 * 4 到 CPU核心数 * 10 之间,常见范围为 15 ~ 50。超过 100 往往收益递减甚至变慢。
  • 线程安全问题:当所有线程需要向同一个全局列表写入数据、或者共享访问同一个文件描述符时,必须使用 threading.Lock 加锁,或者统一使用线程安全的 queue.Queue 进行缓冲。

三、并发方案二:多进程(ProcessPoolExecutor)——突破多核计算与解析瓶颈

多进程的核心价值不是加速网络 I/O,而是突破 GIL 束缚,将多核 CPU 算力全额释放给密集型计算。

适用场景:单次抓取返回的内容包含数万行复杂 HTML 嵌套、使用 XPath 构建深度树遍历、执行高频且长达数十行的正则匹配,或者在抓取前需要调用 JS 引擎运行混淆加密逆向计算。在这些场景下,多线程会把单核 CPU 占满到 100%,其余核心全部闲置,此时多进程是唯一的性能出口。

1. 标准落地代码

为了避免在子进程间传递无谓的重型对象,我们通常采用“多线程拉取文本 + 进程池清洗数据”或者由进程池分片批量清洗。下面展示使用 ProcessPoolExecutor 处理 CPU 密集型解析任务的标准方式。

Python 3 示例代码:基于 ProcessPoolExecutor 的多核并行解析
import re
from concurrent.futures import ProcessPoolExecutor, as_completed
import multiprocessing

# 模拟密集解析函数:在多进程子空间中独立运行,不受主进程 GIL 影响
def cpu_heavy_parser(raw_html_chunk: str) -> dict:
    # 复杂正则提取与字符清洗计算
    emails = re.findall(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+', raw_html_chunk)
    links = re.findall(r'href=['"]?([^'" >]+)', raw_html_chunk)
    tokens = [w.strip().lower() for w in raw_html_chunk.split() if len(w) > 3]
    return {
        "email_count": len(emails),
        "link_count": len(links),
        "token_sample": tokens[:5]
    }

def run_process_pool_analysis(html_data_list: list):
    # 通常设置为系统的物理核心数,避免超出物理 CPU 导致的超线程调度衰减
    cpu_cores = multiprocessing.cpu_count()
    parsed_results = []
    
    with ProcessPoolExecutor(max_workers=cpu_cores) as executor:
        futures = [executor.submit(cpu_heavy_parser, html) for html in html_data_list]
        for future in as_completed(futures):
            parsed_results.append(future.result())
            
    print(f"多进程并行计算完成,已处理 {len(parsed_results)} 个数据块,占用 {cpu_cores} 核心")
    return parsed_results

if __name__ == "__main__":
    # Windows/macOS 平台下,多进程代码必须放在 if __name__ == '__main__' 保护下
    mock_html = "<div><a href='https://example.com/item'>Link</a> contact: service@domain.com</div> " * 5000
    batch_data = [mock_html for _ in range(8)]
    run_process_pool_analysis(batch_data)
终端运行输出示例
[示例输出] 检测到物理核心: 8 核,创建 ProcessPoolExecutor 工作进程
[示例输出] 8 个 CPU 密集型解析任务同时在各核物理并行执行
[示例输出] 多进程并行计算完成,已处理 8 个数据块,占用 8 核心

2. 多进程的性能边界与陷阱

  • 进程序列化开销(Pickle Overhead):父进程向子进程分派任务、子进程返回结果,数据必须经过 pickle 序列化并通过 IPC 管道传输。如果每次向子进程传入上百兆的原始网页,序列化和反序列化所耗费的时间甚至会超过解析本身。正确做法是打包批量数据,只传递必要参数与清洗后的精简字段。
  • 内存开销倍增:每个 Python 子进程都是一个独立的解释器实例,会复制相应的全局运行环境和内存占用。如果一台服务器内存只有 4GB,同时启动 16 个各占 300MB 内存的解析进程,就会发生严重内存交换(Swap)导致整体停顿。

四、并发方案三:异步 I/O(asyncio + aiohttp)——海量并发终极利器

当爬虫的抓取规模扩大到数万乃至数百万页面时,无论是多线程(内存和调度开销)还是多进程(资源耗费)都无法支撑数千的并发连接。此时,基于单线程事件循环的协程方案(asyncio + aiohttp)是最佳选择。

核心原理:异步 I/O 运行在单个线程中,内部通过 epoll(Linux)或 kqueue(macOS/BSD)等操作系统内核事件通知机制驱动。当一个请求发起并等待网络返回时,程序通过 await 主动把 CPU 控制权交还给事件循环,事件循环立即调度其他已准备就绪的协程执行。一个协程的内存占用通常只需几 KB,因此一台普通电脑可以轻松维系 2000 到 5000 个非阻塞网络连接。

提升Python爬虫速度:多线程、多进程与aiohttp异步IO实践
图 2:aiohttp 生产级异步爬虫构建六步操作流程

1. 完整工业级 aiohttp 异步爬虫代码

实现生产级 aiohttp 爬虫时,有两个刚性约束必须遵守:

  • 必须使用 asyncio.Semaphore 约束并发窗口:如果不加限制地向事件循环一次性提交 10,000 个请求,本地会瞬间报错 OSError: [Errno 24] Too many open files,同时目标服务器的 WAF 也会立即封禁 IP。
  • 必须共享单一 ClientSession:绝不能在每次请求函数里 async with aiohttp.ClientSession(),那样会反复销毁并重建 TCP 连接池与 SSL 上下文,彻底抹平异步的高性能优势。
Python 3 示例代码:带信号量并发门控的 aiohttp 爬虫
import asyncio
import aiohttp
from typing import List, Dict, Any

# 单个页面的异步抓取逻辑
async def fetch_one(url: str, session: aiohttp.ClientSession, semaphore: asyncio.Semaphore) -> Dict[str, Any]:
    # 通过信号量获取并发令牌
    async with semaphore:
        try:
            # 单次请求超时设定:连接 5 秒,读取 10 秒
            timeout = aiohttp.ClientTimeout(sock_connect=5.0, sock_read=10.0)
            async with session.get(url, timeout=timeout) as response:
                if response.status == 200:
                    text = await response.text(encoding="utf-8", errors="ignore")
                    return {"url": url, "status": 200, "length": len(text), "error": None}
                else:
                    return {"url": url, "status": response.status, "length": 0, "error": f"Status {response.status}"}
        except asyncio.TimeoutError:
            return {"url": url, "status": -1, "length": 0, "error": "Timeout"}
        except aiohttp.ClientError as ce:
            return {"url": url, "status": -2, "length": 0, "error": f"ClientError: {ce}"}
        except Exception as e:
            return {"url": url, "status": -3, "length": 0, "error": f"Unexpected: {e}"}

# 异步爬虫调度入口
async def run_async_spider(urls: List[str], concurrency_limit: int = 50) -> List[Dict[str, Any]]:
    # 配置底层连接池大小与 DNS 缓存
    connector = aiohttp.TCPConnector(
        limit=100,             # 全局连接池总上限
        limit_per_host=20,     # 单域名并发上限,避免对单点造成过大压力
        ttl_dns_cache=300,     # DNS 缓存 5 分钟
        enable_cleanup_closed=True
    )
    
    # 门控并发信号量
    semaphore = asyncio.Semaphore(concurrency_limit)
    
    headers = {
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
        "Accept-Encoding": "gzip, deflate"
    }

    async with aiohttp.ClientSession(connector=connector, headers=headers) as session:
        # 打包所有协程任务
        tasks = [fetch_one(url, session, semaphore) for url in urls]
        
        # 并发驱动所有协程
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        # 妥善等待底层 SSL 管道完全释放
        await asyncio.sleep(0.25)
        
        # 过滤并规整异常返回
        valid_results = []
        for r in results:
            if isinstance(r, Exception):
                valid_results.append({"url": "unknown", "status": -99, "error": str(r)})
            else:
                valid_results.append(r)
                
        return valid_results

def main():
    target_urls = [f"https://httpbin.org/get?id={i}" for i in range(50)]
    print(f"开始异步抓取 {len(target_urls)} 个 URL,并发窗口: 20")
    
    # Python 3.7+ 推荐运行方式
    results = asyncio.run(run_async_spider(target_urls, concurrency_limit=20))
    success_count = sum(1 for item in results if item["status"] == 200)
    print(f"异步爬取完成!成功: {success_count}/{len(target_urls)}")

if __name__ == "__main__":
    main()
终端运行输出示例
[示例输出] 开始异步抓取 50 个 URL,并发窗口: 20
[示例输出] Event Loop 调度完成,各 Socket 事件在 epoll/kqueue 中连续非阻塞切换
[示例输出] 异步爬取完成!成功: 50/50,总计消耗网络等待时间大幅缩短
致命陷阱:千万不要在 async def 中调用同步阻塞函数

在 async def 定义的协程函数内,绝对不能调用 time.sleep()、requests.get() 或同步的文件读写 open().read()!因为协程全部运行在同一个线程中,一旦某个协程内部发生了任何同步阻塞,整个事件循环就会被当场冻结,所有排队等待中的几千个协程都会一同停滞。如果需要休眠必须使用 await asyncio.sleep();如果需要写文件,应使用 aiofiles 或将写入操作丢入后台线程。

五、架构升维:生产级“异步采集 + 多进程多核解析”双层流水线

在严肃的大型采集工程中,常常会遇到两难问题:网络并发需要成千上万,但每篇返回的 HTML 结构极其庞大,XPath 解析耗尽了 CPU。

如果全部用 aiohttp 抓取并在同一个事件循环里解析,解析计算会霸占 CPU,导致事件循环无法按时轮询网络 socket,网络吞吐急剧跌落;如果全部用多进程,又无法承受数千个并发网络长连接。

终极解法:双层流水线架构(Two-Tier Pipeline)。让 aiohttp 专注于在单线程中拉取海量网络数据,然后利用 loop.run_in_executor() 将纯文本无阻塞地派发给 ProcessPoolExecutor,由多进程榨干所有物理 CPU 核心执行解析,最后再通过异步队列批量聚合入库。

提升Python爬虫速度:多线程、多进程与aiohttp异步IO实践
图 3:生产级双层流水线——异步 I/O 采集与多进程多核解析解耦架构
Python 3 示例代码:异步抓取与进程池解析桥接实现
import asyncio
import aiohttp
from concurrent.futures import ProcessPoolExecutor
from lxml import etree

# 纯 CPU 密集型解析函数(运行在独立的进程池子空间中)
def parse_html_worker(html_content: str) -> dict:
    # 使用 lxml 解析 HTML 树并提取结构化字段
    tree = etree.HTML(html_content)
    titles = tree.xpath("//h1/text()") or ["No Title"]
    paragraphs = tree.xpath("//p/text()")
    return {
        "title": titles[0].strip(),
        "paragraph_count": len(paragraphs),
        "total_chars": sum(len(p) for p in paragraphs)
    }

# 异步爬虫主干
async def hybrid_crawler(urls: list, process_pool: ProcessPoolExecutor):
    loop = asyncio.get_running_loop()
    semaphore = asyncio.Semaphore(30)
    
    async with aiohttp.ClientSession() as session:
        async def task_unit(url: str):
            async with semaphore:
                try:
                    async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as resp:
                        html = await resp.text()
                        
                        # 关键桥接点:将 CPU 解析工作透明委托给进程池,主事件循环完全不阻塞
                        parsed_data = await loop.run_in_executor(
                            process_pool, 
                            parse_html_worker, 
                            html
                        )
                        return {"url": url, "data": parsed_data, "ok": True}
                except Exception as e:
                    return {"url": url, "error": str(e), "ok": False}

        tasks = [task_unit(u) for u in urls]
        return await asyncio.gather(*tasks)

if __name__ == "__main__":
    import multiprocessing
    cores = multiprocessing.cpu_count()
    urls = [f"https://httpbin.org/html" for _ in range(20)]
    
    # 建立多进程池
    with ProcessPoolExecutor(max_workers=cores) as p_pool:
        results = asyncio.run(hybrid_crawler(urls, p_pool))
        print(f"混合流水线处理完毕,获取到有效结果: {len(results)} 条")

六、选型决策指南与全景参数对比

技术方案没有绝对的优劣,只有针对具体场景的取舍。在做爬虫性能优化时,建议按照图 4 所示的决策路径进行技术选型:

提升Python爬虫速度:多线程、多进程与aiohttp异步IO实践
图 4:Python 爬虫并发提速决策路径与全景参数对比矩阵

工程化落地建议与注意事项

  • 识别瓶颈再动手:提速前先打点记录耗时分布。如果每个请求网络等待耗时 800ms,解析只需 1ms,毫不犹豫选择 ThreadPoolExecutor 或 aiohttp;如果网络耗时 50ms,但正则和 JS 解密需要 400ms,应优先优化算法或使用 ProcessPoolExecutor。
  • 合理设置反爬防御与降级:速度不是越快越好。单机数千并发的 aiohttp 请求如果不加节制,在几十秒内就会被目标站点的 WAF 识别封禁。务必配合高质量代理 IP 池轮换,并根据 HTTP 429(Too Many Requests)或 503 状态码实现指数退避(Exponential Backoff)重试。
  • 数据持久化不要成为新的阻塞点:抓取速度提升后,频繁单条写入数据库往往会成为新的死锁点。应在解析完成后统一打入内存队列(如 asyncio.Queue),积攒到 200~500 条后执行一次批量插入(Batch Insert),实现从抓取、解析到入库的全链路零阻塞。

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。