执行器加了一倍,测试还是跑得一样慢:能力匹配、队头阻塞与瓶颈迁移
一个自动化测试平台,浏览器用例和真机用例共用一个任务队列。大家抱怨排队太久,于是浏览器执行器从 5 台加到 10 台,结果浏览器用例的等待时间一点没变。后来改了调度逻辑,浏览器用例快了,可整批测试的完成时间仍然不变;再后来真机也加了,执行器很多,任务却一个一个慢慢地开始。
测试执行、构建、批处理这类平台,执行器往往不是一样的:有的带 Chrome,有的接着一台安卓设备,有的有 GPU。任务声明自己需要什么能力,调度器把它派给有这种能力的空闲执行器。这里有三个容易被忽略的问题:调度策略本身会让一种任务挡住另一种;扩容的收益取决于瓶颈在哪种能力上;执行器都够了以后,瓶颈可能搬到调度器自己身上。
本文用一个离散时间的确定性模拟把这几种情况算出来:40 个任务同时到达,每 5 个里有 1 个需要 android(耗时 6 刻),其余需要 chrome(耗时 2 刻)。这是一个模拟,不代表任何具体平台的数字。实验见文末。
一、队头阻塞:只看第一个任务
最简单的调度器只看队列里的第一个任务:有匹配的空闲执行器就派发,没有就等。
if policy == "head":
pick = 0 if free[queue[0][1]] > 0 else None
else: # 按能力匹配:在队列里找第一个有空闲执行器的任务
pick = next((i for i, (_, cap) in enumerate(queue) if free[cap] > 0), None)只看队头,chrome 5 台、android 1 台:全部完成 48 刻,chrome 任务全部完成 44 刻、平均等待 21.0 刻
按能力匹配,chrome 5 台、android 1 台:全部完成 48 刻,chrome 任务全部完成 14 刻、平均等待 5.4 刻
只看队头,chrome 加到 10 台:全部完成 48 刻,chrome 任务全部完成 44 刻、平均等待 21.0 刻(被排在前面的 android 任务挡住)只看队头时,唯一的 android 执行器一忙,排在队头的 android 任务就让整个队列停下,后面的 chrome 任务明明有空闲执行器也开不了。所以加 chrome 执行器毫无作用,开头那次扩容就是这样。改成按能力匹配后,chrome 任务的平均等待从 21 刻降到 5.4 刻。
按能力匹配也有代价:总是跳过当前做不了的任务,需要稀缺能力的任务可能一直被插队。实际的调度器会按能力分队列、给等待太久的任务加优先级,或者为稀缺能力预留配额。
二、扩容的收益取决于瓶颈在哪
按能力匹配,chrome 加到 10 台:全部完成 48 刻,chrome 任务全部完成 8 刻、平均等待 2.2 刻(android 才是瓶颈)
按能力匹配,chrome 5 台、android 加到 2 台:全部完成 24 刻,chrome 任务全部完成 14 刻、平均等待 5.4 刻8 个 android 任务、每个 6 刻、只有 1 台设备,整批最少就要 48 刻。chrome 加再多,只能让 chrome 任务完成得更早,整批的完成时间不变;android 加 1 台,整批时间减半。扩容前先问:要缩短的是哪一类任务的等待,那类任务的瓶颈是哪种能力。看总的排队长度是看不出来的。
三、执行器够了,瓶颈搬到调度器
按能力匹配,chrome 10 台、android 4 台,调度器每刻只能派发 1 个:全部完成 41 刻,chrome 任务全部完成 41 刻、平均等待 20.0 刻,利用率 chrome 16%、android 29%
同样的执行器,调度器不限速:全部完成 12 刻,chrome 任务全部完成 8 刻、平均等待 2.2 刻调度器每次派发都要做事:分配环境、准备数据、拉起执行器、记录状态。它的速率是有限的。执行器足够多时,这个速率就成了新的瓶颈:40 个任务至少要 40 刻才能全部开始,执行器的利用率只有一到三成。开头最后那个「执行器很多,任务却一个一个慢慢开始」就是这种情况。
每解决一个瓶颈,都要重新找下一个:排队发生在哪里、哪种资源的利用率接近 100%。这与依赖图执行里「并发上限决定能不能接近关键路径」是同一个道理。
四、执行器崩溃:任务要有租约
android 2 台、其中一台第 3 刻崩溃、没有租约:完成 39 个,丢失 1 个
同样崩溃、租约 10 刻:完成 40 个,丢失 0 个,全部完成 48 刻(只剩 1 台 android,比不崩溃时的 24 刻慢一倍)执行器在运行中崩溃,它手上的任务如果只是标记为「运行中」,就再也不会有人处理。派发时给任务一个租约,执行器定期续租;租约到期没有续,任务回到队列。代价是同一个任务可能被执行两次(原来的执行器也许只是网络断了),所以任务要能安全重跑。租约、重新认领和旧持有者的写入怎样处理,见用数据库表当队列。
五、检查清单
| 问题 | 做法 |
|---|---|
| 一种任务挡住另一种 | 按能力匹配或按能力分队列;为稀缺能力的任务防止饥饿 |
| 扩容没效果 | 先确定要缩短哪类任务的等待,看那类能力的利用率 |
| 执行器空闲而任务在排队 | 检查调度器自身的派发速率、环境准备与数据准备 |
| 执行器崩溃 | 任务租约与续租;任务可安全重跑 |
| 观测 | 按能力分别统计排队时长、利用率与派发速率,而不是只看总队列长度 |
配套实验
- codesphere-labs/system-design/capability-scheduling:Python 3.14.8 标准库的离散时间模拟:只看队头与按能力匹配、不同能力的扩容、调度器派发速率、执行器崩溃与租约(验证记录)
参考资料