先说说我自己的经历。有一段时间我用 Selenium 写爬虫,单线程跑 100 个商品页,水杯都喝空了任务还没结束。过去我一直以为瓶颈在网络,后来仔细统计才发现,单页平均耗时 3.8 秒,真正用于定位元素和提取数据的时间不到 0.5 秒,剩下全在等页面加载、等渲染、等接口返回。于是我把目光放到了 ThreadPoolExecutor 上,改造成多线程并发之后,同样一台机器,同样一批页面,耗时直接压到原来的四分之一。这就是 Selenium 并发最直接的价值:把等待时间重叠起来,让浏览器在多个线程里同时工作。
你可能会担心并发很难。确实,Selenium 里的 WebDriver 对象是典型的不线程安全组件,直接拿同一个 driver 给多个线程用,大概率会翻车。但只要你理解了“每个线程独立创建和管理自己的 WebDriver”这条根本原则,剩下的技术点,比如线程数怎么定、显式等待怎么写、异常怎么重试,都有套可以照抄的做法。这篇文章我会一次性讲透,既有爬虫场景也有自动化测试场景,适合已经会写简单 Selenium 脚本、想进一步提速的开发者。如果你是零基础,前半部分的原理和代码模板也能直接上手。
1. 从单线程到并发:Selenium 的性能瓶颈出在哪
1.1 单线程浪费的不是代码,是等待
用 Selenium 打开一个页面,整个时间链包含这几段:浏览器进程启动、地址解析和连接建立、服务端返回 HTML、浏览器解析文档并执行 JavaScript、图片和异步接口加载、最后才是我们写的 find_element 和 extract 操作。这里面真正受你代码控制的部分占比很低,大头都在浏览器渲染和网络等待上。
我做过一次基准统计。在本地网络环境下采集某个公开信息站点的详情页,单页面总耗时约 3.8 秒,页面加载到可操作状态占了 3.1 秒,元素定位和数据提取只占 0.5 秒,其他杂项占 0.2 秒。如果这个页面还要等待某个图片轮播组件初始化,时间会更长。也就是说,串行执行时,每个页面都要独自吞下这 3 秒等待,100 个页面就是 300 秒纯等。单线程代码再精简,也只能优化那 0.5 秒,剩下的等待时间一分都省不掉。
1.2 为什么多线程而不是多进程、异步
很多刚接触并发的人会问:Python 里可以选 threading、multiprocessing、asyncio,为什么偏偏是 ThreadPoolExecutor?
先说 asyncio。Selenium WebDriver 的操作本质是阻塞式 HTTP 调用,通讯协议是同步请求响应模型。代码写 async await 解决不了底层阻塞,反而增加事件循环和线程切换的复杂度。除非你改用 Playwright 的异步 API,否则在 Selenium 上谈异步并发,性价比很低。
再说 multiprocessing。每个进程都有独立的 Python 解释器、独立的内存空间,启动浏览器驱动的成本也会翻倍。而且进程间通信、结果汇总、数据共享都要额外处理,对大多数中等规模的采集和测试任务来说,属于杀鸡用牛刀。
ThreadPoolExecutor 的好处是很实在的:线程复用减少创建开销,代码结构和串行版本差异小,结果可以通过 Future 对象统一收集,内存开销也比多进程低一个量级。虽然 Python 的 GIL 让纯计算型多线程没法并行,但 Selenium 任务绝大多数时间都耗在 I/O 和浏览器进程上,GIL 的影响很小。线程池里的线程在等待期间会释放 GIL,其他线程照样能跑,实际收益非常明显。
提示:如果碰到重 CPU 计算的场景,比如大量解析 HTML 或执行复杂断言逻辑,可以考虑把计算部分丢给进程池,把浏览器控制部分留给线程池。不过在 90% 的 Selenium 场景里,ThreadPoolExecutor 单独已经够用。
2. 并发基础架构:ThreadPoolExecutor 与独立 WebDriver
2.1 WebDriver 为什么不能跨线程共享
这是并发 Selenium 的第一条铁律。同一个 WebDriver 实例,同一时刻只允许一个线程操作。你把一个 driver 塞进多个线程,最常见的结果是报 InvalidSessionIdException,或者页面突然白屏、浏览器进程崩溃。
原因不复杂。WebDriver 走的是 HTTP 接口协议,每个指令都是一次请求,浏览器端按顺序执行。多线程同时发指令,请求会互相穿插,浏览器的会话状态就会错乱。这里的“会话状态”不只是 URL 和 cookie,还包括当前窗口句柄、等待条件、执行中的脚本上下文。任何一个状态被别的线程改动,当前线程的下一步操作就会踩空。
正确做法是每个线程创建自己的 WebDriver,线程结束就销毁。既然 ThreadPoolExecutor 会复用线程,你就把它当成“一批固定数量的工人”,每个工人自己管理自己的浏览器。这样就算你提交 200 个任务,同时存在的浏览器数量也只有 max_workers 个,不会把机器资源拖垮。
2.2 一个可以直接抄的并发模板
我把最稳定的模板贴出来。这个结构我在多个模拟项目里改了很多次,最终固定成下面这样:
from concurrent.futures import ThreadPoolExecutor, as_completed from selenium import webdriver from selenium.webdriver.chrome.options import Options def create_driver(): options = Options() options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") return webdriver.Chrome(options=options) def worker(url): # 每个线程自己创建 driver driver = create_driver() try: driver.get(url) title = driver.title return url, title finally: driver.quit() def main(): urls = [f"https://example.com/page/{i}" for i in range(1, 101)] with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(worker, url): url for url in urls} for future in as_completed(futures): url, title = future.result() print(url, title) if __name__ == "__main__": main()这个模板有几个关键点需要解释下。
driver 的创建必须放在 worker 内部,不能放在 main 里,否则就变成多个线程共享同一个 driver。用 as_completed 遍历结果,哪个任务先完成就先处理哪个,不会因为某个页面卡住而阻塞全局。finally 里一定要 driver.quit(),浏览器是重资源,不退出的话内存会持续上升。
这个模板的应用前提是任务之间相互独立,不共享登录状态。如果目标站点需要登录,你有两个选择:一是每个线程各自登录一次,适合登录成本低的情况;二是先在一个 driver 里登录,导出 cookie,再复制给新建的 driver。第二种方式效率高,但 cookie 里的 session 有失效时间,后续还要处理刷新问题。
2.3 线程数到底怎么定
“线程池越大越快”是我见过最多的误解。线程数不是越大越好,它受三个因素限制:目标站点处理能力、本机内存、本机 CPU 核心数。
一种粗略的计算方式是这样的:假设你希望在 60 秒内处理 200 个页面,每个页面单线程耗时约 3 秒,那么并发线程数理论值就是 200 × 3 ÷ 60 = 10。再考虑线程切换、浏览器初始化、网络抖动这些损耗,除以一个 0.7 的效率系数,实际值大约在 14 左右。
但这个值不能盲目采用。还要看内存:一个 headless Chrome 在普通页面下单实例占内存 150~300 MB,页面越复杂越高。16 GB 内存的机器,自己留 4 GB 给系统,按单实例 250 MB 估算,理论上只能支撑约 48 个实例,但 CPU 肯定先撑不住。所以我还会加一条限制:并发线程数不超过 CPU 逻辑核心数的 2~4 倍。8 核 16 线程的机器,稳妥值就是 8~12 个。超过这个区间,耗时不会继续下降,反而会因为资源争抢而上升。
注意:网上很多教程直接推荐 4 或 8,那是针对他们自己的机器和场景。最靠谱的做法是“逐步加压”:从 2 开始跑一组固定页面,记录耗时,每次加 1,直到耗时开始回升,那个拐点附近就是你当前环境下的最佳并发数。
3. 从爬虫到测试:两种场景的并发套路
3.1 场景一:批量页面采集
批量采集页面的特点是任务量大、互相独立、允许部分失败。我的常规做法不是每条 URL 都创建一次 driver,而是让每个线程处理一批 URL,把创建浏览器的成本摊薄。
def worker_batch(batch_urls): driver = create_driver() results = [] try: for url in batch_urls: try: title = driver.title if driver.current_url == url else fetch_title(driver, url) results.append((url, title)) except Exception as e: results.append((url, None, str(e))) return results finally: driver.quit()这里的核心思路是降低浏览器创建频率。driver 的启动和销毁在总耗时中占比不小,如果每个页面都创建销毁,并发收益会被抵消不少。一个线程连续处理 15 个页面,只创建一次浏览器,整体耗时能再降一截。
当然,一批任务被一个线程绑定的代价是:如果这 15 个页面里有一个极端卡死,整批都会被拖住。解决办法是给 driver.get() 设置超时,比如 15 秒。超时后抛出异常,当前页面放弃,继续下一个。
3.2 场景二:自动化测试用例并发执行
测试和爬虫最大的不同在于,测试用例之间往往存在共享数据。比如注册账号、订单记录、数据库状态。两个测试线程同时操作同一个账号,断言结果必然互相污染。
我在之前的模拟项目里踩过一次很典型的坑:两个测试线程同时跑“提交订单”流程,其中一个用例读到的页面状态来自另一个线程,结果两个用例都失败,而且报错信息看起来毫无关联。后来排查才发现,问题不是用例代码,而是测试数据没有隔离。
并发测试至少要做三层隔离:
- 数据隔离:给每个线程分配独立的测试账号、独立的数据前缀。
- 环境隔离:使用不同浏览器 profile 或用户数据目录。
- 会话隔离:每个线程用自己的 driver,绝不共享元素对象和等待对象。
如果用例数量大,又不想把测试框架迁到 pytest,可以维护一个线程安全的账号池:
import threading accounts = iter(["user01", "user02", "user03"]) lock = threading.Lock() def get_next_account(): with lock: return next(accounts, None)用 iterator 加 Lock 保证同时只有一个线程拿到某个账号。没有锁,多个线程可能取到同一个账号,再次制造数据冲突。
如果你已经在用 pytest,我建议直接考虑 pytest-xdist,它做的是进程级并发,用例收集和失败回显更成熟。ThreadPoolExecutor 最适合的场景是独立测试脚本,或者被测系统要求所有浏览器共享同一套运行环境时。
3.3 场景三:动态页面与显式等待
并发采集和并发测试里,最隐蔽的坑是等待策略混乱。隐式等待是全局设置,作用在 driver 的所有 find_element 调用上,但它对页面渲染完成没有感知。并发场景下,多个线程同时做隐式等待,很容易造成无效等待叠加。
我建议所有并发 Selenium 代码一律用显式等待。显式等待会针对某个条件轮询,比如“元素出现在 DOM 中”或“元素可点击”。条件满足就立刻返回,效率比固定 sleep 高得多,也比隐式等待更可控。
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def wait_and_get_text(driver, css_selector, timeout=10): element = WebDriverWait(driver, timeout).until( EC.presence_of_element_located((By.CSS_SELECTOR, css_selector)) ) return element.text注意,WebDriverWait 必须基于当前线程的 driver 创建,不能全局共用。有人把 WebDriverWait 定义为全局变量,跑一段时间后随机出现找不到元素,排查起来极其折磨。原因很简单,WebDriverWait 实例内部绑定了 driver 和等待状态,跨线程共用在底层就是状态污染。
还要区分两个不同的超时机制:set_page_load_timeout 控制 driver.get() 等待页面加载的上限;WebDriverWait 控制元素条件满足的轮询上限。它们都可能抛 TimeoutException,但含义完全不同。排错时先看异常堆栈触发在哪一行,对照代码判断是页面加载问题还是元素渲染问题,别一上来就盲目调大超时时间。
4. 实操全流程:一次并发采集重构记录
4.1 改造前的串行版本
我以一个模拟项目为例,目标采集某公开信息站点的列表页和详情页,总共 120 条数据。串行版本的代码结构很典型:
def crawl_serial(urls): driver = create_driver() data = [] for url in urls: driver.get(url) title = driver.find_element(By.CSS_SELECTOR, "h1.title").text price = driver.find_element(By.CSS_SELECTOR, ".price").text data.append({"url": url, "title": title, "price": price}) driver.quit() return data这个逻辑清晰,但性能指标难看:120 个条目跑完用了 471 秒,接近 8 分钟。我大概估算过,里面真正执行 find_element 和 text 提取的时间不到 60 秒,其他全是等待。
4.2 线程池版本怎么改
改造时我克制住重写一切的冲动,只把循环替换成并发调度。把单页提取逻辑封装成函数,然后提交到线程池。
def fetch_one(driver, url): driver.get(url) title = driver.find_element(By.CSS_SELECTOR, "h1.title").text price = driver.find_element(By.CSS_SELECTOR, ".price").text return {"url": url, "title": title, "price": price} def worker(url): driver = create_driver() try: return fetch_one(driver, url) finally: driver.quit() def crawl_concurrent(urls, workers=8): results = [] with ThreadPoolExecutor(max_workers=workers) as pool: futures = {pool.submit(worker, url): url for url in urls} for future in as_completed(futures): try: results.append(future.result()) except Exception as e: print(f"任务失败: {e}") return results改动量不大,运行结果差别极大。max_workers=6 时,耗时 147 秒,比串行快了 3.2 倍。后面把线程数调到 8,耗时降到 132 秒。继续调到 12 之后,超时异常和页面白屏明显增加,耗时反而回升到 150 秒以上。最终固定在 8。
这个例子的基础环境是 8 核 16 线程、16 GB 内存。如果你的机器配置不同,拐点位置也会变。别直接照搬我的线程数,要根据自己的曲线来找。
4.3 结果收集、异常重试与日志
并发代码里异常处理的方式跟串行不一样。串行一个 try 包住循环,任何一个页面出错都会中断整个任务。并发里每个 future 各自负责自己的异常,某个任务挂掉不应该影响池子里其他任务。
拿到 future 结果后,我一般做三件事:记录成功结果、记录异常信息、把失败任务放进重试列表。重试不是立刻重试,而是等第一轮所有任务结束后,再把失败列表整体跑一遍。这么做的原因是,立刻重试会把失败请求集中在同一个线程上,如果刚好是目标站点临时抖动,立刻重试大概率还是失败。隔一段时间再试,成功率会高很多。
还有一件很重要的事:日志。多线程环境里如果直接用 print,不同线程的输出会交错混在一起,完全分不清先后和归属。我建议用 logging 模块,并在日志格式里带上线程名:
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(threadName)s] %(message)s" ) logger = logging.getLogger("crawler")这样日志会显示是哪个线程处理哪个 URL,排查慢任务和异常任务会轻松非常多。我后来排查超时问题时,全靠这个日志格式定位到某几个固定 URL 总是慢,才发现是目标站点对某些路径响应异常。
4.4 踩过的三个真实的坑
第一个是浏览器进程残留。教程都告诉你 driver.quit() 要写在 finally 里,实际执行时,executor.shutdown(wait=True) 并不会保证浏览器进程立刻完全退出,特别是 headless 模式下,偶发出现僵尸 Chrome 进程。我后来加了一招:在 worker 里设置页面加载超时,超时后先 driver.close(),再 driver.quit(),双保险。任务全部结束后,再检查一下系统进程列表,把残留进程清理掉。
第二个是会话失效。几个线程长时间跑,偶尔会出现 InvalidSessionIdException。常见诱因是页面长时间无响应、浏览器崩溃、内存压力大。这不是代码逻辑问题,而是资源问题。我在创建 driver 的参数里加 --disable-dev-shm-usage,这个参数在系统 /dev/shm 空间受限的情况下,可以明显减少浏览器崩溃。另外,给失败任务建立重试机制时,新建 driver 会比复用旧 driver 更靠谱。
第三个是隐式等待和显式等待混用导致的假超时。某个任务页面加载很快,但元素渲染慢。我一开始设置了 5 秒隐式等待,WebDriverWait 又设了 10 秒超时。一旦元素真的暂时不存在,最坏情况要等 15 秒才报错。后来我把隐式等待彻底去掉,统一用显式等待,假超时少了九成。
5. 常见问题排查与参数调优
5.1 典型异常对照表
并发 Selenium 遇到的问题来来去去就是那几类。我把它们整理成一张速查表,碰到对应现象可以直接参考:
| 异常或现象 | 常见原因 | 处理思路 |
|---|---|---|
| InvalidSessionIdException | driver 被多个线程使用,或会话异常失效 | 确认 driver 线程私有;必要时重建 driver |
| NoSuchElementException | 页面未渲染完成或定位表达式不对 | 改用显式等待,检查元素定位表达式 |
| TimeoutException | 页面加载超时或元素等待超时 | 区分是 driver.get 超时还是 WebDriverWait 超时 |
| 浏览器进程残留 | driver.quit 未执行或 headless 异常退出 | finally 里 close + quit,任务后检查进程 |
| 内存持续增长 | 线程数过多、driver 未及时释放 | 限制并发数,启用内存监控 |
| 页面白屏或崩溃 | 并发压力大,浏览器资源不足 | 降低线程数,或加 --disable-gpu |
5.2 并发数调节的科学方法
调并发数有个笨但有效的方法:画一条“线程数-耗时”曲线。从 2 开始,每次加 1,固定跑同一组页面,记录耗时,直到耗时开始回升或明显波动。曲线最低点附近就是当前机器和目标站点下的最佳并发数。
不过这个值不是一成不变的。页面改版、目标响应变慢、机器负载变化都会让拐点漂移。我习惯把线程数做成配置项,通过环境变量传入,避免每次调参都改代码:
import os MAX_WORKERS = int(os.getenv("CRAWLER_WORKERS", "8"))这样部署到不同机器时,只需要调环境变量,不用动业务代码。
5.3 什么情况下别用 ThreadPoolExecutor
我必须说点泼冷水的话。不是所有场景都适合并发。如果任务本身非常轻,比如只做个登录页跳转,创建线程的开销可能比任务本身的收益还大。如果页面之间有强依赖,比如先登录拿 token、再访问后续页面、再提取数据,这种链条不适合直接丢进线程池,否则并发控制会很痛苦。
还有一个容易忽略的因素:目标站点的访问频率限制。并发会让请求频率瞬间上升,如果目标站点有反爬策略,并发反而更容易触发拦截。我之前采集某些公共数据站点时,特意把线程数降到 3,并加入每页 1~2 秒随机延时。总耗时变长了一些,但成功率接近 100%。两相一算,还是划算的。
如果你的目标站点页面交互特别复杂,比如大量 iframe、嵌套弹窗、复杂拖拽,我也建议评估一下 Playwright。它的浏览器上下文设计对多标签并发更友好。但如果你现有项目已经基于 Selenium 跑了很久,强行换框架的成本可能高于收益。先把 ThreadPoolExecutor 玩明白,通常已经能解决 80% 的效率问题。
6. 收尾:关于工具选型和并发开发习惯的真实心得
6.1 我的个人建议是分步走
刚开始接触并发时,别总想着一步到位。我的建议是先把串行代码写对,再给关键元素加上显式等待,再改成线程池,最后才调线程数。每一步都验证结果没有偏差。这样你才能知道瓶颈到底在哪个环节,而不是一遇到问题就怪并发。
如果你是在做自动化测试,并且有条件用 pytest,优先考虑 pytest-xdist。它能把用例分发到多进程,失败反馈和插件生态都更成熟。但如果你只有一套独立 Selenium 脚本,又不想引入测试框架,ThreadPoolExecutor 就是最小改动、最大收益的方案。
如果是在做爬虫,我会先问自己一句:真的需要浏览器渲染吗?如果目标数据能通过接口直接拿到,requests 加解析库可能比 Selenium 轻一个数量级。需要用到 Selenium 的场景,一般是页面渲染依赖 JS、需要真实点击、登录状态复杂。在这些场景下,ThreadPoolExecutor 就是一个非常合适的提速工具。
6.2 一个让我省了很多事的小技巧
最后分享一个经验:如果任务里有一部分 URL 响应特别慢,我会先用短超时跑第一轮,把失败的任务收集起来,再用长超时单独跑失败的那一批。这样既能保证成功率,又不会因为几个慢页面拖住整个线程池。这个思路同样适用于测试脚本,重跑失败用例的时候,只重跑失败的那几个,省时又省力。
我个人做完并发改造后最大的体会是:并发本身不难,难的是资源管理和异常处理。线程池只是个调度工具,真正决定项目稳不稳的,是你有没有把 WebDriver 的创建、释放、超时、重试这些细节处理好。把这些细节理顺了,效率倍增是水到渠成的事。