做Web自动化的人应该都有这种感觉:脚本单线程跑的时候一切正常,一旦开多线程上并发,内存曲线就像坐了火箭,CPU也跟着一路狂飙。Playwright的并发资源消耗问题,我前前后后调了两三周,从盲目的“加线程数”到后面逐个参数抠优化,总算整理出一套能压住内存CPU的落地方案。这篇就围绕"Playwright多线程内存CPU消耗"这个主题,把原理、思路、实战参数和排查方法一次讲透。
先交代一下背景:我这边负责的是一个偏中型的Web平台自动化测试项目,回归用例大概三千多条,刚开始用Playwright写脚本时全程单线程跑,一次回归要四五个小时。后来拆成多线程并发,结果直接卡死、内存溢出,机器8核16G被打满,连IDE都拖不动。后来通过分层优化,把同一批用例的并发执行时间压缩到50分钟内,内存峰值控制在4G左右,CPU在并发时也不持续飙高了。下面这些内容全部来自这段时间的踩坑记录。
1. 为什么一并发就爆资源:先搞懂Playwright的资源模型
1.1 每个浏览器实例到底吃多少资源
很多人第一反应是“Playwright开线程不就是在脚本里加个threading吗”,但真正的资源消耗大头在浏览器进程本身。用Playwright启动一个Chromium实例,本质上就是拉起了一个完整的浏览器进程组:一个主浏览器进程、多个渲染进程、GPU进程、网络进程等。一个全新的、还没打开任何页面的Chromium实例,在普通配置的Linux机器上初始内存大概在200到300MB,Windows上还能再高一点。这还没算后续打开页面时渲染引擎、JS执行环境、DOM树、网络连接要占用的额外内存。
这里有一个容易被忽略的细节:每个Playwright实例都会通过CDP协议和浏览器进程建立通信管道,脚本里的每一个操作(点击、填写、截图)都要经过这个管道做消息编解码和事件回调。多线程并发时,消息量随之成倍上升,CPU的占比不只是浏览器渲染本身,还有Python端、Node端在做协议序列化和事件处理的消耗。
我刚上手时犯过一个典型错误:为了加速,直接在测试脚本里循环创建了几十个浏览器实例,每个线程一个实例,还都开着有头模式。结果就是进程数量爆炸,内存以GB为单位往上跳,最后OOM把整个测试机器打挂了。后来我做了个对照测试:同样的用例,不限制线程数的情况下,16个并发线程能吃掉将近12G内存;限制到4个并发,内存控制在3G左右,时间只慢了一截,但系统稳如老狗。
1.2 多线程下真正的资源消耗来源
除了浏览器实例本身,资源消耗还有两个隐藏来源:第一是每个浏览器上下文(Context)都是独立的小型浏览器环境,有自己独立的Cookie、 localStorage、缓存,底层对应独立的存储分区,开一个上下文就会额外追加一部分内存占用;第二是页面对象(Page)本身,每个Page对应一个渲染进程,打开的标签页越多,渲染进程就越多。
我用一段简单测试脚本验证过:启动一个浏览器实例,不开任何页面时基础内存约200MB;打开一个空白页,内存涨到350MB左右;打开一个中等复杂度的业务页面,内存直接到600MB以上;如果再加一个Context,每个Context再各开一个页面,总内存轻易突破1GB。
这就解释了为什么很多团队用Playwright做并发时觉得“没写多少代码,内存就满了”—— 真正的开销都在框架底层拉起的那些真实浏览器组件上,脚本代码本身消耗的那点CPU内存根本不值一提。理解这一点之后,优化方向就不应该是“怎么在代码里省内存”,而是“怎么在架构层面减少真实浏览器进程的重复创建”。
2. 从架构层面控制并发:线程池与实例复用
2.1 固定线程池替代盲目开启线程
先说结论:多线程本身没错,错的是“线程数无限叠加”。用Python的ThreadPoolExecutor做固定线程池,是控制资源消耗的第一道闸门。我最后采用的配置是:机器8核16G,线程池最大并发数为4。这个数字不是拍脑袋定的,是根据测试环境的内存余量、单个浏览器实例的估算内存(约600MB到800MB)、以及浏览器实例复用后的实际占用共同算出来的。
核心计算逻辑不复杂:给单浏览器实例复用场景下每个并发Context预留约1GB内存(页面本身占大头),16G内存留出系统和其他进程的缓冲,可用的并行Context数大约在4到6个。往上加到8个不是不能跑,但一旦页面复杂度升高,内存会在几分钟内吃满,得不偿失。如果是在4核8G的低配机器上,建议并发数直接减半到2到3个。
from concurrent.futures import ThreadPoolExecutor def run_test_case(case): with sync_playwright() as p: browser = p.chromium.launch(headless=True) context = browser.new_context() page = context.new_page() # 执行用例逻辑 page.goto("https://example.com") # 断言、截图等操作 context.close() browser.close() cases = ["case_001", "case_002", "case_003", "case_004"] with ThreadPoolExecutor(max_workers=4) as executor: executor.map(run_test_case, cases)注意这段代码只是演示线程池的基本结构,真正上线时不会每个用例都launch一次浏览器,而是在线程内完成所有用例后再统一关闭,这部分下一节细说。
2.2 浏览器实例复用的两种模式
重启浏览器是最耗时的操作之一,也是内存抖动最明显的时刻。优化第一步就是让一个浏览器实例尽量多跑几个用例,而不是用一次就关。
第一种模式是“进程级复用”:在程序里只启动一个浏览器实例,所有用例线程共享这个实例,但每个线程用独立的Context来隔离状态。这种模式好处是浏览器进程只有一个,整体内存开销最低;坏处是Playwright没有官方保证Context在多线程之间并行使用时一定线程安全,实际操作中需要小心控制并发量,尽量保证每个线程在同一时刻只操作自己的Context。实测下来,在规范使用的情况下,稳定性是可以接受的。
第二种模式是“每组一个实例”:给每组线程分配一个独立的浏览器实例,组内共享实例,组和组之间完全隔离。这种模式会比进程级复用的内存多一些,但隔离性更好,一个组内出现崩溃不会影响其它组。
我最终采用的是折中方案:按照线程池大小(4)创建4个浏览器实例,每个线程固定绑定一个实例,线程内部用Context做用例间的状态隔离。这样既不出现几十个浏览器进程的恐怖场景,又能利用多核机器的并行能力。
# 每个worker绑定一个browser实例 def make_browser(): playwright = sync_playwright().start() browser = playwright.chromium.launch(headless=True) return playwright, browser # 启动时预创建4个browser browsers = [make_browser() for _ in range(4)] def run_case_on_browser(case, playwright, browser): context = browser.new_context() page = context.new_page() try: # 执行case pass finally: context.close()2.3 上下文隔离的正确姿势
刚才提到Context隔离,这里单独强调一下。Context可以理解成一个独立的“用户会话包厢”——同一浏览器实例里开多个Context,就像同一个浏览器程序里开了多个相互独立的隐身窗口,Cookie、缓存、登录态完全隔离,但是所有的Context还在共用同一个浏览器主进程。这样设计的好处很明显:多线程并行用不同账号操作时,不会互相踢下线,也不会串Cookie,内存增长又比重开浏览器小得多。
使用Context时要注意一个铁律:用完必须关闭。context.close()不执行,它对应的存储分区、网络连接、事件监听都会一直挂在内存里。很多人排查内存暴涨,最后发现就是Context开了一堆没关,再加上Page也没关,内存自然只涨不降。
另外,Context不要跨线程传递。Python的Playwright对象在同步API下不是严格线程安全的,最好让一个Context从创建到关闭都待在同一个线程里。跨线程调用轻则偶发卡死,重则会抛异常。把这个规则写进团队的代码规范里,能避免很多诡异问题。
3. 写代码时能让内存“瘦身”的细节
3.1 极致轻量化的启动参数
同样的用例,启动参数不一样,内存和CPU消耗能差出30%到50%。chromium.launch()里值得认真调的参数有下面几个:
第一,headless=True。新版Chromium虽然有了新的headless模式,但在大多数自动化场景下,无头模式的资源占用依然明显低于有头模式。能不开界面就别开,除非你必须要可视化调试。
第二,关闭不需要的浏览器特性。--disable-gpu在无头模式下可以减少GPU进程的创建;--disable-dev-shm-usage在容器环境里可以避免共享内存不足导致的白屏崩溃;--no-sandbox只能在可信的测试环境里用,不要在不受控的机器上随意开启。
browser = p.chromium.launch( headless=True, args=[ "--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox", "--disable-extensions", "--disable-background-networking", ] )第三,按需禁用缓存和后台任务。用context = browser.new_context(viewport={"width": 1280, "height": 720})设置固定窗口大小,能避免响应式布局触发的额外布局计算。service_workers="block"可以阻止页面注册Service Worker,减少后台线程占用的内存。permissions=[]清掉不需要的浏览器权限,减少权限弹窗带来的交互等待。
有些页面里的图片和样式是测试根本不需要的,可以用路由拦截把请求过滤掉,节省网络和渲染资源。这个方法对图片特别大的站点效果立竿见影,我试过把一些用例的内存峰值压掉20%以上。
async def block_unused_routes(context): await context.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico}", lambda route: route.abort())需要说明的是,路由拦截必须按项目实际需要来,如果用例需要验证图片加载或者依赖某些静态资源渲染,就不要一刀切拦截。
3.2 等待策略直接影响CPU占用
等待策略是CPU消耗的大头之一。很多从Selenium转过来的朋友习惯用time.sleep(5)这种固定等待,看起来简单,但实际上非常浪费。假设一个用例流程里有20个等待点,每个点固定等5秒,用例就算业务逻辑一秒能跑完,硬生生要等100秒,CPU和内存在这段时间里还得一直维持着浏览器会话。更关键的是,time.sleep不会自动释放页面资源,等待期间渲染进程依然常驻。
Playwright推荐的等待方式是把控制权交给反应式等待条件,用page.wait_for_selector()、page.wait_for_load_state()、page.wait_for_url()这类显式等待,它们是事件驱动的,元素出现就立刻返回,不出现才等到超时。用事件驱动替代固定睡眠,不仅用例跑得更快,资源占用也是恒定的,不会因为在等待期堆积额外任务。
偶发元素闪烁、接口响应慢这种场景,不要一上来就加长超时时间,先分析是页面没刷出来还是元素被遮挡。我见过有同事为了处理一个偶发弹窗问题,把所有等待都改成8秒time.sleep,整个用例集跑下来CPU长时间维持高位。后来换成expect(locator).to_be_visible(timeout=5000)配合重试机制,问题解决得干净利落,CPU占用也降了一大截。
3.3 必须处理的资源释放流程
资源释放这块最容易翻车,我把它提到和用例逻辑同样高的优先级。每个线程内的用例执行完,无论成功失败,都要确保执行两条命令:context.close()和browser.close()。前者释放Context挂载的页面、存储区和网络通道,后者关闭浏览器进程组,回收所有子进程内存。
在Python里最稳妥的姿势是用try/finally包裹,或者直接用contextlib.closing。用with sync_playwright() as p:只能保证Playwright驱动进程关闭,不能保证浏览器实例自动关闭,这两件事是分开的。如果脚本异常退出导致browser.close()没执行,系统里就会残留一堆Chromium僵尸进程。这些僵尸进程吃掉的内存甚至比正常运行时的浏览器还多,因为它们是异常状态,页面还挂在内存里没人管。
with sync_playwright() as p: browser = p.chromium.launch(...) try: context = browser.new_context() page = context.new_page() # ...测试逻辑... finally: context.close() browser.close()一个排查技巧:跑完测试后,打开系统进程管理器,看看有没有残留的chrome或chromium进程在继续占内存。如果有,说明某个用例的退出流程没走干净,先修通用结束逻辑,再考虑测试本身的问题。
4. 监控与定位:内存泄漏和CPU失控别靠猜
4.1 给测试进程加上实时监控
优化过程中我才发现一个尴尬的事实:大部分优化工作都是盲人摸象,因为“内存涨得很快”只是体感,没有量化数据。后来我给测试脚本套了一层轻量级监控,每两秒记录一次当前进程的RSS(常驻内存)和CPU使用率,跑完用例后直接看曲线,问题一眼就能定位。
Linux下最简单的做法是配合ps命令和pidstat做监控。pidstat是sysstat包里的工具,用它能看到每个具体进程的实时内存和CPU,比看全局统计有针对性得多。例如:
- 观察总内存:
free -h - 观察指定进程的内存:
ps aux --sort=-%mem | grep chrome - 观察测试主进程CPU:
pidstat -p <PID> 2
另一个很实用的工具是py-spy,它可以在不重启进程的情况下dump出Python进程的调用栈,直接看到当前各线程卡在哪个函数上。CPU飙升时最怕的是脚本在某个地方死循环,用py-spy dump --pid <PID>一看栈信息,问题就露头了。
4.2 定位内存泄漏的实操方法
多线程环境下的内存泄漏,往往不是代码逻辑的“内存没释放”,而是各种资源对象(Page、Context、浏览器实例)创建了没销毁,或者是事件监听器累积。
定位资源泄漏,我常用的方法是在测试开始前和结束后分别打印一次进程内存,然后跑同一组用例对比涨幅。比如跑100条用例后,起始内存1.8G,结束内存2.1G,说明有大概300M内存没有被回收。这时逐个关闭疑似组件(比如把Context关闭逻辑提出来单独测试),做二分定位。
Python内置的tracemalloc库也很有用,它可以记录Python对象的内存分配,定位到具体代码行的内存占用。不过它的开销不小,生产级的大规模回归时不建议默认开启,只是在怀疑泄漏时才临时跑一轮。
还有一类CPU异常是监听器泄漏:每次调用page.on("request")、page.on("response")这类事件绑定,都会往事件列表里追加回调。如果每次用例执行都绑定新回调,但从不清理,回调列表会越来越长,事件到达时回调全部执行一遍,CPU自然上去。这个问题肉眼很难发现,排查时可以直接在关键代码段加日志,计数当前绑定的监听器数量,确认是不是持续上涨。
5. 常见问题排查速查表与最后的避坑心得
5.1 高频问题速查表
| 异常现象 | 常见原因 | 排查思路 | 对应的处理方案 |
|---|---|---|---|
| 内存曲线持续上涨 | Context或Page未关闭 | 检查每个用例结束分支的close调用 | 用try/finally统一收口 |
| CPU长时间居高不下 | 固定等待过多或监听器泄漏 | py-spy dump看调用栈 | 换成事件驱动等待,清理监听器 |
| 偶发报错“Target closed” | Page被意外关闭 | 检查页面跳转后是否重定向 | 每次操作前重新获取定位器 |
| 僵尸Chromium进程残留 | browser.close未执行 | ps aux查chrome残留 | 统一异常退出路径 |
| 并发时用例互相影响 | 共享了同一个Context | 检查Context的创建和关闭位置 | 每个线程独立Context |
| 打开页面白屏 | 容器/dev/shm不足 | 看浏览器控制台报错 | 增加--disable-dev-shm-usage |
这张表看着简单,但每条都是我实际踩过的坑。其中最隐蔽的是第二条监听器泄漏——同样的用例数量,排除掉它之后CPU占用掉了将近一半。
5.2 测试环境层面的额外建议
写到这里忍不住想多啰嗦一下测试环境的问题。Playwright本身就是浏览器密集型任务,普通办公笔记本并行跑4个实例就已经能感受到风扇狂转,有条件的话尽量用独立的CI机器或容器环境跑回归。在容器里跑时,记得给容器分配足够的内存上限,同时把临时目录挂载到本地磁盘,避免频繁的IO操作拖慢浏览器响应。
还有一个容易被忽略的外设性能问题:如果测试机器是机械硬盘,浏览器缓存和临时文件的读写会成为新的瓶颈。有条件就把测试机器换成SSD,或者把Playwright的临时目录指向内存盘(比如Linux下的/dev/shm),整个测试套件的执行速度和稳定性都会明显提升。
机器配置固定后,再回头调代码里的并发数和资源参数,才有可复现性可言。
最后再分享一个我后来才悟到的经验:并发数的调整不是一劳永逸的。线上系统页面复杂度会随着版本迭代不断变化,页面越来越重,内存消耗也会跟着涨。比较好的做法是每次大版本回归前,跑一小批代表性用例做资源摸底,看看当前并发数下内存峰值是否逼近机器上限,如果接近了就把并发数降一档。我现在的习惯是每两周做一次这个摸底,用监控数据说话,而不是凭感觉调整线程数。
这套优化走下来,我最大的体会是:Playwright的资源问题本质上不是一个“调参技巧”,而是一个“架构取舍”问题——你得先清楚每个组件背后的开销模型,才知道该复用什么东西、该隔离什么东西、该在哪一步做清理。弄明白这些,多线程跑Playwright就不难了。