Selenium并发提速实战:ThreadPoolExecutor多线程让采集和测试快4倍
先说说我自己的经历。有一段时间我用 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 与独立 WebDriver2.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(--headlessnew) options.add_argument(--disable-gpu) options.add_argument(--no-sandbox) options.add_argument(--disable-dev-shm-usage) return webdriver.Chrome(optionsoptions) def worker(url): # 每个线程自己创建 driver driver create_driver() try: driver.get(url) title driver.title return url, title finally: driver.quit() def main(): urls [fhttps://example.com/page/{i} for i in range(1, 101)] with ThreadPoolExecutor(max_workers4) 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 在普通页面下单实例占内存 150300 MB页面越复杂越高。16 GB 内存的机器自己留 4 GB 给系统按单实例 250 MB 估算理论上只能支撑约 48 个实例但 CPU 肯定先撑不住。所以我还会加一条限制并发线程数不超过 CPU 逻辑核心数的 24 倍。8 核 16 线程的机器稳妥值就是 812 个。超过这个区间耗时不会继续下降反而会因为资源争抢而上升。注意网上很多教程直接推荐 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, timeout10): 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, workers8): results [] with ThreadPoolExecutor(max_workersworkers) 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_workers6 时耗时 147 秒比串行快了 3.2 倍。后面把线程数调到 8耗时降到 132 秒。继续调到 12 之后超时异常和页面白屏明显增加耗时反而回升到 150 秒以上。最终固定在 8。这个例子的基础环境是 8 核 16 线程、16 GB 内存。如果你的机器配置不同拐点位置也会变。别直接照搬我的线程数要根据自己的曲线来找。4.3 结果收集、异常重试与日志并发代码里异常处理的方式跟串行不一样。串行一个 try 包住循环任何一个页面出错都会中断整个任务。并发里每个 future 各自负责自己的异常某个任务挂掉不应该影响池子里其他任务。拿到 future 结果后我一般做三件事记录成功结果、记录异常信息、把失败任务放进重试列表。重试不是立刻重试而是等第一轮所有任务结束后再把失败列表整体跑一遍。这么做的原因是立刻重试会把失败请求集中在同一个线程上如果刚好是目标站点临时抖动立刻重试大概率还是失败。隔一段时间再试成功率会高很多。还有一件很重要的事日志。多线程环境里如果直接用 print不同线程的输出会交错混在一起完全分不清先后和归属。我建议用 logging 模块并在日志格式里带上线程名import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(threadName)s] %(message)s ) logger logging.getLogger(crawler)这样日志会显示是哪个线程处理哪个 URL排查慢任务和异常任务会轻松非常多。我后来排查超时问题时全靠这个日志格式定位到某几个固定 URL 总是慢才发现是目标站点对某些路径响应异常。4.4 踩过的三个真实的坑第一个是浏览器进程残留。教程都告诉你 driver.quit() 要写在 finally 里实际执行时executor.shutdown(waitTrue) 并不会保证浏览器进程立刻完全退出特别是 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 遇到的问题来来去去就是那几类。我把它们整理成一张速查表碰到对应现象可以直接参考异常或现象常见原因处理思路InvalidSessionIdExceptiondriver 被多个线程使用或会话异常失效确认 driver 线程私有必要时重建 driverNoSuchElementException页面未渲染完成或定位表达式不对改用显式等待检查元素定位表达式TimeoutException页面加载超时或元素等待超时区分是 driver.get 超时还是 WebDriverWait 超时浏览器进程残留driver.quit 未执行或 headless 异常退出finally 里 close quit任务后检查进程内存持续增长线程数过多、driver 未及时释放限制并发数启用内存监控页面白屏或崩溃并发压力大浏览器资源不足降低线程数或加 --disable-gpu5.2 并发数调节的科学方法调并发数有个笨但有效的方法画一条“线程数-耗时”曲线。从 2 开始每次加 1固定跑同一组页面记录耗时直到耗时开始回升或明显波动。曲线最低点附近就是当前机器和目标站点下的最佳并发数。不过这个值不是一成不变的。页面改版、目标响应变慢、机器负载变化都会让拐点漂移。我习惯把线程数做成配置项通过环境变量传入避免每次调参都改代码import os MAX_WORKERS int(os.getenv(CRAWLER_WORKERS, 8))这样部署到不同机器时只需要调环境变量不用动业务代码。5.3 什么情况下别用 ThreadPoolExecutor我必须说点泼冷水的话。不是所有场景都适合并发。如果任务本身非常轻比如只做个登录页跳转创建线程的开销可能比任务本身的收益还大。如果页面之间有强依赖比如先登录拿 token、再访问后续页面、再提取数据这种链条不适合直接丢进线程池否则并发控制会很痛苦。还有一个容易忽略的因素目标站点的访问频率限制。并发会让请求频率瞬间上升如果目标站点有反爬策略并发反而更容易触发拦截。我之前采集某些公共数据站点时特意把线程数降到 3并加入每页 12 秒随机延时。总耗时变长了一些但成功率接近 100%。两相一算还是划算的。如果你的目标站点页面交互特别复杂比如大量 iframe、嵌套弹窗、复杂拖拽我也建议评估一下 Playwright。它的浏览器上下文设计对多标签并发更友好。但如果你现有项目已经基于 Selenium 跑了很久强行换框架的成本可能高于收益。先把 ThreadPoolExecutor 玩明白通常已经能解决 80% 的效率问题。6. 收尾关于工具选型和并发开发习惯的真实心得6.1 我的个人建议是分步走刚开始接触并发时别总想着一步到位。我的建议是先把串行代码写对再给关键元素加上显式等待再改成线程池最后才调线程数。每一步都验证结果没有偏差。这样你才能知道瓶颈到底在哪个环节而不是一遇到问题就怪并发。如果你是在做自动化测试并且有条件用 pytest优先考虑 pytest-xdist。它能把用例分发到多进程失败反馈和插件生态都更成熟。但如果你只有一套独立 Selenium 脚本又不想引入测试框架ThreadPoolExecutor 就是最小改动、最大收益的方案。如果是在做爬虫我会先问自己一句真的需要浏览器渲染吗如果目标数据能通过接口直接拿到requests 加解析库可能比 Selenium 轻一个数量级。需要用到 Selenium 的场景一般是页面渲染依赖 JS、需要真实点击、登录状态复杂。在这些场景下ThreadPoolExecutor 就是一个非常合适的提速工具。6.2 一个让我省了很多事的小技巧最后分享一个经验如果任务里有一部分 URL 响应特别慢我会先用短超时跑第一轮把失败的任务收集起来再用长超时单独跑失败的那一批。这样既能保证成功率又不会因为几个慢页面拖住整个线程池。这个思路同样适用于测试脚本重跑失败用例的时候只重跑失败的那几个省时又省力。我个人做完并发改造后最大的体会是并发本身不难难的是资源管理和异常处理。线程池只是个调度工具真正决定项目稳不稳的是你有没有把 WebDriver 的创建、释放、超时、重试这些细节处理好。把这些细节理顺了效率倍增是水到渠成的事。