前阵子帮朋友处理一个数据采集需求,任务量其实不大——三百个链接,需要抓页面里的标题和几个关键字段。结果我用 Selenium 单线程一跑,整整等了快四十分钟,浏览器一个页面一个页面地慢慢转圈。中途还因为超时重试了几次,整个人盯屏幕盯到怀疑人生。后来我把线程池接进来,同样的任务量,耗时直接砍到原来的五分之一还多,而且代码结构比之前更清晰。这篇文章就把我从单线程卡顿到并发提速的全过程拆开讲清楚:线程池到底怎么解决等待问题、Selenium 在并发场景下怎么管理浏览器实例、采集结果怎么安全落到 CSV 里,以及我实际踩过的那些坑。无论你是刚接触爬虫的新手,还是被 Selenium 性能折磨过的老手,这篇都值得一看。
1. 单线程爬虫慢的真相:等待而不是下载
1.1 网络速度并非唯一瓶颈
很多人一开始会下意识觉得,爬虫慢是因为网速不行,或者目标网站服务器响应慢。放在普通 HTTP 请求的场景下,这个判断基本成立。但一旦换成 Selenium,问题就完全不是一回事了。
Selenium 并不是简单发一个 HTTP 请求拿 HTML,而是实实在在启动一个完整的浏览器进程。浏览器要做的事情远比 requests 库多得多:先加载主文档,再解析其中的 JS、CSS、图片和各类子资源,然后执行 JavaScript 脚本,触发异步请求,最后等待页面事件,才把控制权交回给你的代码。这里面的每一个环节都会产生等待时间。
我用 Chrome DevTools 的 Network 面板统计过一个典型的动态渲染页面,整个加载过程的时间分配大概是这样的:
| 加载阶段 | 耗时占比 | 说明 |
|---|---|---|
| 浏览器冷启动 | 10% | 创建浏览器进程、初始化渲染引擎 |
| 主文档与子资源加载 | 55% | 网络往返、TCP 连接、资源下载 |
| JavaScript 执行 | 15% | 框架初始化、动态渲染、数据绑定 |
| 页面事件与稳定 | 15% | onload、DOMContentLoaded、React/Vue 挂载 |
| 其他开销 | 5% | 代理、插件、缓存策略等 |
你看,真正花在"下载"上的时间其实只占一半多一点。剩下的一半全耗在浏览器内部处理和渲染等待上。换句话说,就算你把带宽拉到千兆光纤,单线程 Selenium 爬虫也快不到哪去,因为大部分时间是在等浏览器干活,而不是等网络传输。
1.2 用时间账本算清代价
我习惯把单线程爬虫的耗时拆成一笔"时间账"来算。假设你要采集 300 个页面,每个页面从启动浏览器到拿到数据平均要 8 秒。单线程跑下来:
300 × 8秒 = 2400秒 ≈ 40分钟如果里面还有一部分页面因为加载超时或者验证码导致失败,需要重试两三次,那么总时间轻松跑到 50 分钟甚至一个小时。这对任何实际业务来说都是不可接受的。
更气人的是,这 40 分钟里,CPU 占用率可能一直趴在 10% 以下。也就是说,你的机器大部分算力都在空转,真正被"闲置"的不是网络资源,而是你本机的处理能力。单线程模型相当于只有一个"工人"在干活,哪怕前面有三百个任务排队,他也只能一个个来。而这个工人大部分时间还在等页面加载,真正动手取数据的时间少得可怜。
想明白这个逻辑之后,我的第一反应不是优化单线程里的等待时间,而是换一个思路:让多个"工人"同时干这件事。这就引出了线程池。
2. 线程池提速的核心逻辑:让浏览器真正并行干活
2.1 为什么选线程池而不是手动多线程或直接上多进程
谈到并发,Python 里可以选的办法其实不少。最原始的是自己写Thread手动创建线程,也可以用concurrent.futures的ThreadPoolExecutor,或者用multiprocessing直接开多进程。我为什么最终选了线程池?
先说手动创建线程。写起来代码很啰嗦,你要自己维护线程列表、手动join()、处理线程异常,任务一多就很容易乱。而且线程池能帮你解决的"任务排队"和"并发上限控制",手动写法全都要你自己实现。说白了,线程池是一个已经封装好的"工人调度中心",你只管提交任务,它负责分配线程、管理队列、回收资源。
再说多进程。multiprocessing能绕开 GIL,但它有一个致命问题:每个进程都要启动独立的 Python 解释器,内存开销比线程大得多。配合 Selenium 使用时,每个线程/进程都要启动一个浏览器实例,Chrome 单进程的内存占用量通常在 200MB 以上。开 4 个线程就是 4 个浏览器,开 4 个进程也是 4 个浏览器,但后者还要额外叠上 Python 解释器的内存。算下来,线程池方案的内存占用明显更低。
顺便澄清一个常见的误区:很多人担心 GIL 会影响 Selenium 并发效率。但实际上,Selenium 的大部分时间都是在等待浏览器内核完成加载,这个等待发生在 C 扩展层面,不占用 Python GIL。也就是说,在线程等待的间隙,GIL 会自动释放,其他线程照样能执行自己的逻辑。所以线程池和 Selenium 搭配,GIL 造成的性能损耗几乎可以忽略不计。
2.2 并发模型选择:一个线程持有一个浏览器实例
确定了用线程池,紧接着要解决一个更关键的问题:到底让多个线程共享同一个 WebDriver,还是每个线程单独启动一个浏览器实例?
这里有个知识点需要明确:Selenium 的 WebDriver 对象不是线程安全的。多个线程共用一个 driver,同时执行get()跳转,经常会出现会话混乱、元素找不到、超时莫名其妙的报错。因为浏览器内部只有一个标签页,同时接收多个导航指令,状态根本无法保持一致。
所以我的做法是:每个线程独立创建一个 WebDriver 实例。一个线程手里的浏览器,从头到尾只服务这个线程的任务。虽然内存开销大了点,但换来的是绝对的稳定性和可预测性。这个过程可以类比成在食堂开多个打饭窗口——每个窗口一个师傅,互不干扰,整体效率远超一个师傅同时接待几十个人。
当然,你也可以采用"浏览器复用 + 标签页切换"的方式,让多个线程共享一个浏览器进程、动态创建新标签页。我在早期试过这个方案,省了内存,但引入的问题非常烦人:线程切换标签页时很难避免竞态,有时还会触发浏览器的渲染进程崩溃。如果你不是在极简环境里采集,不建议碰这条路。老老实实"一个线程一个浏览器实例",这是并发爬虫里性价比最高的选择。
3. Selenium + ThreadPoolExecutor 实战落码
3.1 环境准备:依赖安装与驱动管理
进入正文之前,先把眼前的环境问题解决掉。我的开发环境是 Windows 11 + Python 3.10。需要安装的东西有两个,一个是 Selenium 库本身,另一个是浏览器的 WebDriver。
早期的做法是去 Chrome 官网手动下载对应版本的 chromedriver,再解压放到 PATH 路径下。这个办法最大的痛点在于 Chrome 浏览器一旦自动升级,版本号变了,原来的 chromedriver 就失效,爬虫立刻罢工。所以我强烈建议安装webdriver-manager这个工具,它可以自动检测本机 Chrome 版本、自动下载匹配的驱动并缓存起来,省去所有版本同步的麻烦。
pip install selenium webdriver-manager装完之后,写一个最简单的浏览器初始化函数:
from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager def create_driver(): options = Options() options.add_argument('--headless') options.add_argument('--disable-gpu') options.add_argument('--no-sandbox') options.add_argument('--disable-dev-shm-usage') options.add_argument('--window-size=1920,1080') service = Service(ChromeDriverManager().install()) return webdriver.Chrome(service=service, options=options)这里的几个参数值得展开说一下。--headless是无头模式,不弹出浏览器窗口,在服务器和后台采集场景下几乎是标配。--disable-gpu是关闭显卡硬件加速,在无头模式下 GPU 渲染没有实际意义,反而容易引发奇怪的报错。--no-sandbox和--disable-dev-shm-usage通常是为了解决 Linux 容器环境下权限和共享内存不足的问题,如果在 Windows 本机上用可以不加,但加上也不会有副作用。窗口大小要设置为 1920x1080 这类合适的尺寸,否则部分前端页面在窄窗口下会触发移动端适配,导致元素定位发生变化。
3.2 核心代码:任务函数如何拆分
写并发代码最忌讳的是把一堆逻辑全塞进一个巨大的for循环里。线程池的优雅之处在于,它要求你把"每个链接要做的事情"抽象成独立的函数。这个函数只负责单次任务,不关心是谁调用它、有多少个任务排队。
下面是我用线程池实现采集的完整骨架流程。先定义单页采集函数:
import csv import time from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_single_page(url): driver = create_driver() try: driver.get(url) time.sleep(2) # 等待页面关键元素渲染完毕 title = driver.title # 这里可以再加具体的元素定位和数据提取逻辑 return {"url": url, "title": title} except Exception as e: return {"url": url, "title": f"抓取失败: {str(e)}"} finally: driver.quit()注意finally块里的driver.quit(),这一点极其重要。如果漏掉了它,每个任务执行完浏览器进程都不会退出,内存会在几十个任务之后彻底耗尽,程序直接崩溃。很多线上事故都是从这里来的。
主流程里的线程池调度代码如下:
urls = [...] # 你的链接列表 results = [] with ThreadPoolExecutor(max_workers=4) as executor: future_map = {executor.submit(fetch_single_page, url): url for url in urls} for future in as_completed(future_map): result = future.result() results.append(result)这里有两个细节值得琢磨。第一,executor.submit会立刻返回一个Future对象,它代表"这个任务将来的结果"。用字典future_map把 Future 和原始 URL 关联起来,方便后期定位哪个任务失败了。第二,as_completed并不是按提交顺序返回结果,而是谁先完成就先处理谁。这个特性非常适合并发场景——任务 A 加载了 10 秒,任务 B 只用了 3 秒,B 的结果会先被处理,不会因为 A 慢而阻塞整个队列。
3.3 并发参数调优:线程数、超时与等待策略
聊到线程池,避不开的一个问题是max_workers到底设成几。很多人以为越大越好,实际上盲目开三五十个线程,结果就是内存瞬间爆掉,或者被目标网站直接封了 IP。我自己的经验是,线程数要综合看机器内存和目标网站的抗压能力,而不是拍脑袋决定。
如果你在 8GB 内存的机器上跑,一个 Chrome 无头实例大概占 200~300MB,那么 4 个线程对应 4 个 Chrome 实例,内存余量就已经不大了。再加上系统自身开销,建议谨慎一点设成 4 到 6 个线程。如果你的机器是 16GB 内存,可以放宽到 8 个线程。目标网站如果响应慢、经常超时,还需要适当降低线程数,避免大量请求同时积压触发的雪崩。
另外,等待策略尽量别用time.sleep()写死时长,它太脆弱了:网络快的时候纯浪费,网络慢的时候又不够用。更稳妥的做法是用 Selenium 的显式等待:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By wait = WebDriverWait(driver, 10) element = wait.until(EC.presence_of_element_located((By.CLASS_NAME, "item-title")))显式等待的本质是轮询等待某个条件成立,条件满足立刻返回,不再浪费多余的秒数。这里要注意,WebDriverWait 的轮询间隔默认是 0.5 秒。也就是说,最多会有 0.5 秒的判断延迟。这个开销完全可接受。我一般会在任务函数里搭配使用显式等待和连接超时控制,尽可能把"等固定秒数"的坏习惯改掉。
还有一点容易被忽视:线程池任务的异常处理。如果fetch_single_page内部没有捕获异常,future.result()会抛出异常,导致整个主循环中断。所以在上面的代码里,我对单页函数做了全局except,把错误信息写进结果里,而不是让异常直接炸掉线程池。采集任务本身就充满了不确定性,页面的结构变一下、网络波动一下、验证码弹出来,都是家常便饭。宁可这一单记录失败,也不能让整个任务流崩溃。
4. 数据落盘 CSV:并发结果不丢不乱的秘诀
4.1 线程里直接写文件的翻车现场
把采集结果保存成 CSV 看起来是最简单的一步,但这里藏着一个特别经典的并发陷阱:如果你让每个线程在抓取完数据后直接打开同一个 CSV 文件写入,那么大概率会出现 CSV 行内容交错、数据丢失,甚至文件损坏的问题。
原因很简单:Python 的文件写入不是原子操作,多线程同时向同一个文件句柄写内容,底层会因为文件指针互相抢占而出现交错写入。你自己单独写一个小测试可能复现不出来,因为写入的内容太短、概率不够。一旦数据量上来,每条记录几十上百个字节,多个线程同时写,很快就出问题。
我一开始偷懒没注意,直接在任务函数里写了:
with open("output.csv", "a", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow([url, title])结果跑完一看,CSV 里有些行的字段错位了,原本该在第 3 列的内容跑到了第 5 列,还有几条数据直接被后来的写入覆盖。当时排查了半天,最后才意识到就是并发写入竞态导致的。
4.2 主线程统一收集再写盘的方案
前面代码里其实已经透露了我的处理思路:每个线程只负责把结果作为返回值传回来,所有的数据收集交给主线程统一完成,采集全部结束后再一次性写入 CSV。
with open("output.csv", "w", newline="", encoding="utf-8-sig") as f: fieldnames = ["url", "title"] writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(results)这里有两个细节很关键。一是encoding参数我用了utf-8-sig而不是utf-8。区别在于utf-8-sig会在文件开头写入带 BOM 标记的内容,Excel 打开 CSV 时才能正确识别中文字符。如果你用纯utf-8编码,Excel 打开后中文经常变成乱码,必须手动在 Excel 里选编码才能救回来。这是和 pandas 打交道的人都知道的细节,很多新手在这里栽过跟头。
二是newline=""必须带上。Python 的 CSV 模块在 Windows 平台上如果不指定newline="",写入时会出现多余的空行。原因是csv.writer内部处理换行符的方式和 Windows 自身的\r\n机制撞了车。加上newline=""之后,换行符由 CSV 模块统一管理,问题自然消失。
这种"结果汇总后统一落盘"的模式,本质上就是把并发问题从写盘环节里彻底剥离出来。主线程的写入根本不存在并发冲突,数据的一致性和完整性有了保障。而且这种做法还给你留了一个好处:主线程可以在写盘前对数据做二次过滤、排序、去重,而不需要动子线程的任何逻辑。
4.3 断点续跑与增量数据保护
如果你的任务不是一次性结束,而是可能中途崩溃、之后还要继续采集,那一次性统一写盘就有风险了。我通常在项目的生产版本里会加一个"已完成记录"的追踪集合,把已经成功抓取的 URL 先记录到一个 txt 文件里。每次任务开始前读入这个集合,如果 URL 已经在里面,就直接跳过,不重新发起请求。
实际上我用的是更轻量的方案:在任务函数 return 之前,把 URL 追加写入一个单独的done.txt。因为每次只追加一行,单行写入的原子性足够可靠,多个线程看似并发追写,实际每个写入都是独立的小块操作,不太容易互相交错。主程序跑完后再统一把结果写进 CSV,两套机制各管各的事。
5. 实测提速对比与踩坑记录
5.1 一份真实的耗时对比数据
理论讲了这么多,没有实测数据都是空谈。我拿一台 16GB 内存的笔记本,对一个测试站点的 200 个页面做了完整的采集测试。采集内容包括页面标题、发布时间和正文前 100 字,每个页面有动态渲染,需要用 Selenium 等待约 2 秒。
数据如下:
| 线程数 | 总耗时 | 相对单线程提速 |
|---|---|---|
| 1 线程 | 8分32秒 | 基准 |
| 2 线程 | 4分41秒 | 约 1.8 倍 |
| 4 线程 | 2分05秒 | 约 4.1 倍 |
| 8 线程 | 1分27秒 | 约 5.9 倍 |
注意看,从 1 线程到 4 线程,加速比接近线性,非常舒服。但 8 线程并没有达到 8 倍,只有 5.9 倍。原因也不复杂:线程数增多之后,本机 CPU 和内存开始成为瓶颈,再加上 Chrome 每个实例都要独立加载页面资源,磁盘缓存 IO 也成为争夺点。结论是在这台设备上,4 到 6 个线程是性能和稳定性的甜点区。
这些数据是在无头模式下测的。如果不开无头模式,每个浏览器都有真实窗口渲染,内存占用会更高,4 线程以上的加速收益会更不明显。所以凡是能跑无头的场景,尽量用无头模式。
5.2 踩过的大坑:登录态失效、内存暴涨、隐式等待失效
先说登录态失效的问题。我的采集任务需要登录后才能看到数据,于是用浏览器先手动登录一次,然后把 cookie 信息取出来,在每个线程创建浏览器后注入进去。一开始没做这步,直接开线程池跑,结果 4 个线程里面 3 个返回都是"未登录跳转到登录页"。原因很简单:Selenium 默认每次创建的都是全新临时浏览器环境,没有 cookie,也没有任何登录记忆。
解决办法是在create_driver()里通过add_cookie批量注入。注意add_cookie必须要在driver.get()访问一次域名之后才能调用,否则浏览器不知道当前访问的是哪个站点,拒绝写入。所以我通常会先driver.get("https://目标站点首页"),再用一个循环把 cookie 字典写进去,然后重新driver.get()真正要抓的页面。
再说内存暴涨。这个问题我在前面已经反复提示过,driver.quit()一定要放在finally里。但还有一个更隐蔽的问题:即使每个线程都正确调用了quit(),如果你的代码在等待WebDriverWait时抛了异常,且异常被全局except捕获后直接返回,某些 Chrome 子进程仍然有概率残留。尤其是在 Windows 上,Chrome 的特殊多进程架构会让主进程挂掉之后,一些 renderer 和 GPU 进程还赖在内存里不退出。
后来我在正常quit()之外,还定期加了一个兜底:每次跑完一批任务,用subprocess调用系统命令清理残留的 Chrome 进程。这个做法有点粗暴,但确实有效。在 Linux 服务器上对应的是pkill -f chrome命令,Windows 上则等价于taskkill /F /IM chrome.exe /T。注意,执行这个命令的进程必须和爬虫主程序是分开的,不能把正在正常运行的浏览器实例也给误杀了。
最后一个是隐式等待失效的问题。Selenium 文档里明确说过:隐式等待(driver.implicitly_wait())和显式等待不要混用,否则等待时间会叠加到不可控的地步。我在一个并发场景里同时设置了隐式等待 10 秒和显式等待 5 秒,结果页面元素明明在 2 秒时就出现了,硬是卡了 7 到 8 秒才继续。排查下来,就是隐式等待轮询机制和显式等待wait.until()在底层发生了排斥。解决办法很简单:统一用显式等待,彻底删掉隐式等待设置。这个改动让我整体的采集耗时又下降了一截。
5.3 线程数的经验参数表
最后送上一张我自己整理的线程数配置参考表,适合大多数常规 Selenium 动态爬虫任务。当然,具体数值一定要结合你自己的机器配置和网络环境做微调。
| 机器内存 | 推荐线程数 | 适用场景 |
|---|---|---|
| 4GB | 2 | 简单页面、任务量小 |
| 8GB | 4 | 常规动态页面采集 |
| 16GB | 6~8 | 数据量较大、页面较重 |
| 32GB 及以上 | 8~12 | 大规模批量采集,需严守目标站点并发限制 |
判断线程数合不合适,最简单的方法是开着资源监视器看内存占用,一旦接近 80%,立刻把线程数降下来。不要等到 Chrome 直接崩溃报"页面引起进程崩溃"才去调整,那样所有线程都会同时失败,之前抓的数据全白费了。
还有一个小技巧:给每个线程设一个"独立的任务队列"而不是把所有 URL 一股脑塞进去。实际上ThreadPoolExecutor.submit本身就是把任务放到一个共享队列里,由空闲线程去取。这个设计已经足够合理,你不需要自己额外做任务分片。但要注意的是,如果某个 URL 反复失败、总是占用线程很长时间,它会影响队列整体的推进速度。我一般会在任务函数里设置单页最长执行时间:超过 20 秒没抓完就直接把这条任务标记为超时并返回,避免"一颗老鼠屎坏了一锅汤"。
写到这里,回到开头的问题。Selenium 本身并不慢,真正慢的是你用它的姿势。单线程串行等待是所有性能问题的根源,而线程池通过一个简单的ThreadPoolExecutor就能把等待的时间重叠起来。配合独立的浏览器实例、显式等待、结果统一落盘这几个关键设计,爬虫从"龟速爬行"变成"批量流水线"其实并不复杂。我还是建议手上在写爬虫的朋友,花半天时间把手头的单线程逻辑改成线程池版本,一旦跑起来会发现,原来等待真的可以靠并发来解决。