任务显示「已结束」,Chromium 还占着 11 个进程

7 阅读10分钟

《AI 重塑测试 · 90 天实测日记》第 41 篇 · 结论以本机实跑记录为准

先摆代价。

给一个测试平台加「浏览器任务调度 + 资源回收」,看起来只是加一个队列、一个状态机、一个 finally。真实做下来的代价是:多一套状态要维护,多一套异常要处理,多一套验收要写,而且这些都不会自己暴露问题。

不报错的失败最贵。断言失败会红屏;资源泄漏只会让你的机器慢慢变慢,然后某天早上 CI 全挂,你查到的却是一句「测试超时」。

这一篇讲的是这个复杂度值不值得付,以及什么时候别付。

环境:Windows / Python 3.11.8 / Playwright 1.62.0 / Chromium 151.0.7922.34,全部数字实测。

参考:Playwright Browser API、Playwright BrowserContext。

一、代价一:状态机一旦有五个态,就再也退不回三个

先看最朴素的版本:排队 → 执行 → 结束。三个态,很好懂。

然后真实需求一上来:

  • 用户点了取消,要区分「正在取消」和「已取消」
  • 执行进程失联,要和「正常失败」分开
  • 清理要有自己的状态,因为「任务结束了但资源还没放掉」是真实存在的中间态

于是变成五个态:

QUEUED → RUNNING → FINALIZING → TERMINATED
                   ↘ LOST(失联,不可复用)

这个状态机的代价不在写代码,在维护。 每加一个状态,下面这些都要跟着改:

要改的东西为什么
任务列表展示四种终止原因得显示成四种文案
重试逻辑哪些状态能重试?LOST 能,TIMEOUT 看情况
告警规则LOST 要叫人,TIMEOUT 不用
验收用例每个状态都要一条对应用例
状态回写乱序到达的回报要处理

我自己的经验是:状态超过四个的时候,就该考虑是不是把两个维度拆开了。 比如把「运行状态」和「清理状态」分两列存,各自三态,展示时组合。这样加一个状态只影响一列。

但注意,这个拆分也有代价——展示层要组合,排查时要多看一处。 不是免费的重构。

二、代价二:并发调度带来的不是速度,是不确定性

大家的直觉是「并发开大点,跑得更快」。这个直觉在浏览器任务上基本是错的。

同时打开的页面数 = 浏览器实例数 × 每个实例的上下文数。

举例:2 个浏览器实例 × 每个 4 个上下文 = 同时 8 个页面。3 个实例 × 8 个上下文 = 24 个页面。

我本机的经验是同时最多开 8~12 个页面,再多会明显变慢。

⚠️ 这条是经验值,不是实测数据。它和你的 CPU 核数、内存、页面重量强相关。我的机器只能代表我的机器。

更麻烦的是放大效应:一条平台任务内部可能自己开了 4 个测试 worker。调度器只按「任务数」限流的话,实际浏览器数就是任务数的 4 倍。

def 实际页面数(任务列表):
    return sum(
        任务.配置["上下文数"] * getattr(任务, "内部worker数", 1)
        for 任务 in 任务列表
    )

限流要卡最终页面数,不是任务数。 这一条不讲清楚,你调了半天并发发现瓶颈根本没变。

并发上去了会发生什么

不是「变慢一点」,是行为变得不可预测:

  • 同样一条用例,有时 3 秒有时 30 秒 → 超时阈值只能拍脑袋
  • 失败率随并发上升 → 你以为是用例不稳,其实是机器被压垮
  • 一条慢任务拖垮一批 → 争抢 CPU 和内存

调度系统的复杂度不是为了变快,是为了把不确定性框起来。 如果你的任务量根本填不满机器,那这套东西是纯成本。

三、代价三:资源回收的正确性,你没法靠读代码确认

这是最贵的一块。

大部分人写回收是这么写的:

try:
    ...
finally:
    上下文.close()
    浏览器.close()

看起来很对。但 finally 的覆盖范围是正常返回 + Python 异常。它覆盖不到这三种:

  1. 进程被 kill(不是正常退出,是被强杀)
  2. 机器断线 / 断电
  3. close() 自己抛异常

这三种情况里,前两种根本不会执行到 finally,第三种会让后面的清理全部跳过。

最容易漏的一行

上下文.close()    # 关上下文
浏览器.close()    # 关浏览器进程 —— 这一行才是关键

只写第一行,Chromium 进程会一直在机器上跑。 我自己错过这一行,结果一条用例跑完机器上攒了 11 个 Chromium 进程。

所以验收必须是外部的

import subprocess


def 数浏览器进程():
    输出 = subprocess.run(
        ["tasklist", "/FI", "IMAGENAME eq chrome.exe"],
        capture_output=True, text=True, encoding="utf-8", errors="ignore",
    ).stdout
    return sum(1 for 行 in 输出.splitlines() if "chrome.exe" in 行)

验收标准就一句:任务列表显示「已结束」时,浏览器进程数必须是 0。

代价在这儿:你多了一套「不在单元测试里、在进程表里」的验收。 这套验收没法在 CI 的单元测试阶段跑,只能在集成环境跑,而且它天生有平台差异(Windows 是 tasklist,Linux 是 ps)。

四、代价四:取消和失联是两个完全不同的工程问题

取消是合作式的——用户按下取消,执行进程收到信号,优雅收尾。这条路好做。

失联是没法合作的——心跳断了,你不知道对面在干什么。

处理失联只有两条路:

选择代价
隔离整台执行机浪费一台机器;恢复要人工确认
直接复用两个任务抢同一份资源;数据串了可能几个月都查不出来

第二条路的代价是不可见的,所以大家倾向于选它。这是错的。

隔离的代价是显性的(少一台机器),复用的代价是隐性的(数据错乱)。工程上应该选显性的那个。

顺带一个便宜的保险:占用令牌

def 申请(执行环境, 任务号):
    if 执行环境.当前占用 is not None:
        raise RuntimeError("槽位已被占用")
    令牌 = uuid.uuid4().hex
    执行环境.当前占用 = {"令牌": 令牌, "任务号": 任务号}
    return 令牌


def 释放(执行环境, 令牌):
    当前 = 执行环境.当前占用
    if 当前 is None or 当前["令牌"] != 令牌:
        return False   # 迟到的释放消息,丢弃
    执行环境.当前占用 = None
    return True

这个只要几十行,能挡住「A 的释放消息迟到,把 B 的槽位误释放」这类问题。

但要清楚它能干什么、不能干什么:它只解决身份核对,不解决进程是否真的停了。拿它当安全网可以,拿它当进程终止的证据不行。

五、一个纯工程账:卡死的 146 秒和 6 秒

这一节跟调度无关,但我想把它放在这里,因为它最能说明「代价藏在哪」。

我写的测试要起一个本地网页服务。单线程写的:

from http.server import HTTPServer
服务器 = HTTPServer(("127.0.0.1", 8000), 处理类)

浏览器打开页面后会保持一条 keep-alive 长连接,这条连接不关,单线程服务器就不会去接下一个请求。于是我那条 requests.get() 永远排不进来。

表现是:测试卡住,一行报错都没有。 本机实测卡到 300 秒工具超时才发现。

改成多线程:

from http.server import ThreadingHTTPServer
服务器 = ThreadingHTTPServer(("127.0.0.1", 8000), 处理类)

27 个用例从 146 秒降到 6 秒。

这个例子说明的不是「要记得用多线程」,是:很多看起来是调度问题、并发问题、资源回收问题的现象,根因在一个不起眼的地方。 你要是不先排除这些,可能会花两周搭一套调度系统,最后发现该加一个 Threading 后缀。

六、什么时候别做这套东西

这是这一篇最重要的一节。

情况一:任务量小,一次跑不完一天的量

判据很简单:把并发限制成 1,队列会不会一直是空的?

如果是空的,调度器、队列、令牌、隔离逻辑全是纯成本。直接串行跑,一个 with 块就完事。

我自己的建议是:先把并发限制成 1 或少量几个槽位,把完整生命周期(申请 → 使用 → 释放 → 验收残留进程)跑通并留下验收记录,再谈扩展。 这条路交付更快,而且那套验收记录以后还用得上。

情况二:只有一个执行机

单机场景下,「调度」退化成「排队」。而排队你用一个列表就能做。

没有多机,就不用设计分布式那套东西。 数据库约束、跨实例原子性、心跳协议——单进程一个字典就够,把复杂度的账省下来。

情况三:任务是一次性的、排障用的

调试一条用例,要什么调度?起一个浏览器,跑,看,退出。

这种场景下最该做的是把退出路径写干净(关上下文、关浏览器),别的都不做。

情况四:页面很轻,且数量少

如果你的用例只是点两下、读一个数,浏览器启动开销可能比用例本身还大。这时候串行 + 复用浏览器实例是最优解,加并发只会抢 CPU。

一句话判据

先问「我的瓶颈是排队还是执行」。 如果瓶颈在执行(用例本身慢),加并发调度帮不上忙,该去优化用例。如果是排队(机器空着但任务在等),那才轮到调度器上场。

七、如果决定做,这几件事别省

按投入产出排序,先做前三件:

  1. 释放路径写干净:上下文.close() → 浏览器.close() → 播放机.stop(),顺序不能反
  2. 验收看残留进程数,不靠读代码确认 finally 存在
  3. 四种终止原因分开:COMPLETED / TIMEOUT / CANCELLED / LOST,别合并成「失败」
  4. 排队原因写具体:带括号写「(当前 8/8 占用)」,别只写「排队中」
  5. 占用令牌,防迟到释放
  6. 失联时隔离执行机,不要复用
  7. 进度拆成排队 / 启动 / 执行 / 清理四段,别只给一个百分比

第 1、2 条是保命的,3~7 条是锦上添花的。 资源不够的时候,先做 1、2、3。

小结

按代价从高到低:

  1. 状态机复杂度——五个态之后,每个状态都要在展示、重试、告警、验收里各付一次钱
  2. 并发不确定性——开大并发不是变快,是让超时阈值和失败率都失去意义
  3. 回收正确性不可读码验证——只能靠外部进程表,验收天然跨平台
  4. 失联处理——隔离是显性成本,复用是隐性成本,选显性的

而最该记住的是第五节那个数字:146 秒 → 6 秒。 很多你以为是架构问题的现象,根因在一个不起眼的地方。

什么时候别做? 任务量小、单执行机、一次性排障、页面轻——这四种情况下,直接串行跑,把退出路径写干净就行。

下一篇是 Day42《两个任务同时跑,B 的登录态为什么被踢回登录页》,讲另一个隔离维度:多个任务同时登录同一个系统,登录态怎么不串。


本文出自《AI 重塑测试 · 90 天实测日记》系列(第 41 篇),作者「楠风测开」。系列按天记录测试平台的实测过程、本机环境与未验证项,结论以实跑记录为准。