# 通过Worker异步计算优化CPU使用
# 背景
在小游戏每一帧中,JS主线程主要做了两件事情:计算和渲染。相对地,每一帧的Frame Time = 计算耗时 + 渲染耗时。根据Chrome 团队提出的用户感知性能模型 RAIL,播放动画时JS执行时间(即计算耗时)要小于16ms,且应该尽量低于10ms,以留足额外的6ms用以渲染。因此,对于小游戏而言,每一帧的16ms十分重要。
然而,在小游戏中,一些复杂的计算(比如物理引擎模拟)所消耗的时间很难保证会低于10ms,这时往往会造成游戏帧率下降,并出现卡顿的情况。尤其是iOS的JavaScriptCore无法开启JIT,可能造成iOS上小游戏的性能表现很差。
# 分帧策略
对于计算性能造成游戏帧率下降或卡顿的场景,大多数开发人员会选择分帧策略,将执行时间较长的函数(long task)拆分成多个执行时间短的子任务,将子任务分配到每一帧中,按计划顺序执行,直到全部子任务执行完毕。分帧策略虽然对部分场景有效果,但是依旧存在如下问题:
- 不是所有计算逻辑都能够被拆分。比如数组排序, 树的递归查找, 图像处理算法等, 执行中需要维护当前状态, 且调用上非线性, 无法轻易地拆分为子任务。
- 可以拆分的逻辑难以把控力度。拆分的子任务在高性能机器上可以控制在 16ms 内, 但在性能落后的机器上表现并不一定理想。 16ms 的用户感知时间, 并不会因为用户手上机器的差别而变化。
- 拆分的子任务并不稳定。计算逻辑可能会随着业务场景发生变化,将同步计算逻辑拆分成子任务,可能会造成每次改动业务都需要review多个子任务的代码。
这个时候,可以使用Worker 的多线程能力, 从宏观上将整个同步 JS 任务异步化。
# 小游戏环境的Worker
小游戏环境下的Worker与Web Worker存在一定的差异,在能力的体现上对比如下:
能力 | 小游戏Worker | Web Worker | 主线程 |
---|---|---|---|
渲染能力 | ❌ | ❌ | ✅ |
JS(wx) API | ❌ | ✅ | ✅ |
网络I/O | ❌ | ✅ | ✅ |
JS 运行时 | ✅ | ✅ | ✅ |
计时器 | ✅ | ✅ | ✅ |
JIT | Android支持 / iOS不支持(开启 useExperimentalWorker 后支持) | - | Android支持 / iOS不支持 |
引擎能力 | ✅ | - | ✅ |
并发数量 | 不能超过1个 | 无限制 | - |
线程通信数据传输方式 | 复制 | 复制 / ArrayBuffer | - |
线程通信-数据传输类型 | 只支持object | object / array | - |
小游戏的Worker的能力与Web Worker还有些实现上的差异,具体能力可参看多线程worker。
需要开发者注意的是:
- 对于JIT能力,在Android下主线程和Worker线程均有JIT能力,而在iOS下默认没有JIT能力,开启 useExperimentalWorker 后支持JIT。
- Worker 线程无法进行渲染,也无法调用JS(wx) API, 只能受主线程控制,适合默默干活。
- 多线程虽然实现了 JS 任务的并行运行, 也带来额外的通信开销。
如下图所示, 从线程A调用 postMessage 发送数据到线程B onMessage 接收到数据有时间差, 这段时间差称为通信消耗。
从流程来看Worker分担主线程计算任务时:
使用Worker线程
提升的性能 = 并行提升的性能 – 通信消耗的性能
。在线程计算能力固定的情况下, 要通过多线程提升更多性能, 需要尽量减少通信消耗。主线程 postMessage 和onMessage会占用主线程同步执行,占用时间与数据规模相关。要避免多线程通信导致的主线程卡顿,需要控制每个渲染周期内的数据传输规模。此外,占用时间与设备系统和设备硬件性能相关。iOS系统下的小游戏运行环境没有JIT,不同档型设备的CPU能力存在差异,这些都会导致线程通信占用时间的差异。
# 小游戏中使用Worker
# Demo 1: 同步
通过一个Demo来看一下”同步计算“的效果。在这个Demo中,使用Cannon.js物理引擎模拟100个小球下落。
“同步计算”时,“物理模拟”(计算)过程和“渲染”过程均在主线程中执行。“物理模拟“的代码如下:
function update() {
sendTime = Date.now()
if (!world) {
// Init World
......
}
world.step(dt); // 物理模拟
// Update rendering meshes
for (var i = 0; i !== world.bodies.length; i++) {
var b = world.bodies[i],
p = b.position,
q = b.quaternion;
meshes[i].position.set(p.x, p.y, p.z);
meshes[i].quaternion.set(q.x,q.y,q.z,q.w);
}
// If the simulate was faster than the time step (dt seconds), we want to delay the next timestep
console.log("total compute:", Date.now() - sendTime)
var delay = dt * 1000 - (Date.now() - sendTime);
if (delay < 0) {
delay = 0;
}
setTimeout(update, delay);
}
看一下演示的效果:
演示设备:iPhone 8,iOS 13.5.1。
同步(gif动图):
从演示效果中可以看到:同步计算时,随着小球的数量越来越多,FPS越来越低;
这是因为同步计算时,Frame Time = 渲染耗时 + 计算耗时
,随着小球数量的增加,计算耗时逐渐增加,导致Frame Time 越来越大,且Frame Time > 16ms,所以同步计算的FPS越来越低,且低于60,最后仅仅只有20。
根据上文所述,这种因为计算瓶颈导致的小游戏帧率抖动、下降的情况,可以使用Worker多线程来优化。
# Demo 2: 同步 Vs. 异步(计算与渲染完全剥离)
“异步计算”时,将“物理模拟”(计算)过程挪到Worker线程中执行,“渲染”过程在主线程中执行。在小游戏开发过程中,为了最大限度地利用CPU资源,以及完全剥离渲染和计算,使两者互不影响,往往采用如下逻辑。
- 首先,预估计算的平均耗时,以此选择合适的计算帧率;渲染帧率则根据渲染耗时和游戏类型综合考虑来设定。
- Worker线程按照计算帧率来持续计算,不由主线程驱动。
- Worker线程的每一次计算结果,通过线程通信返回给主线程。
例如:在本例中,因为当小球变多时,计算耗时已经超过16ms,故将计算帧设置为30,渲染帧保持60。在Worker线程中持续模拟计算,而无需等待主线程驱动。此时,从下图CPU时间片占用中可以看到,同步计算时,物理计算的高计算耗时,导致依旧会出现掉帧、卡顿的情况,如红框区域。
异步计算不仅避免了帧率不稳、卡顿的情况,而且最大限度地利用了CPU。
对游戏逻辑修改后的代码如下:
主线程
:
function UseWorker() {
const worker = wx.createWorker('workers/index.js');
worker.onMessage(e=>{
// Get fresh data from the worker
positions = e.positions;
quaternions = e.quaternions;
// Update rendering meshes
for (var i = 0; i !== meshes.length; i++) {
meshes[i].position.set(positions[3 * i + 0],
positions[3 * i + 1],
positions[3 * i + 2]);
meshes[i].quaternion.set(quaternions[4 * i + 0],
quaternions[4 * i + 1],
quaternions[4 * i + 2],
quaternions[4 * i + 3]);
}
})
}
worker线程
:
function update() {
var simulateTime = Date.now()
if (!world) {
// Init World
......
}
world.step(dt);
var sendTime = Date.now()
for (var i = 0; i !== world.bodies.length; i++) {
var b = world.bodies[i],
p = b.position,
q = b.quaternion;
positions[3 * i + 0] = p.x;
positions[3 * i + 1] = p.y;
positions[3 * i + 2] = p.z;
quaternions[4 * i + 0] = q.x;
quaternions[4 * i + 1] = q.y;
quaternions[4 * i + 2] = q.z;
quaternions[4 * i + 3] = q.w;
}
worker.postMessage({
positions: positions,
quaternions: quaternions
});
// If the worker was faster than the time step (dt seconds), we want to delay the next timestep
var delay = dt * 1000 - (Date.now() - simulateTime);
if (delay < 0) {
delay = 0;
}
setTimeout(update, delay);
}
直观对比效果如下:
可以看到,同步计算时即使将计算帧降到30,FPS依旧会下降到13左右,而异步计算时FPS依旧稳定在60。
这是因为异步计算时,物理模拟过程是在Worker线程中,所以主线程渲染的每一帧的Frame Time = 渲染耗时 + 线程通信耗时
,因为渲染耗时和线程通信耗时都是稳定的,且Frame Time <= 16ms,所以异步计算的FPS能够稳定在60;
同步(gif动图):
异步(gif动图):
这个demo说明:使用Worker多线程异步计算不仅可以避免帧率不稳、卡顿的情况,而且可以更大限度地利用CPU。
# Demo 3: 异步(iOS JIT) Vs. 异步(无JIT)
目前,在iOS的worker线程中已经支持开启JIT (通过 wx.createWorker('workers/index.js', {useExperimentalWorker: true})
开启),在对iOS性能优化的同时,可以进一步提高Worker多线程带来的优势:提高计算速度,减少线程通信耗时。
将小球的数量增加到400,计算耗时会进一步增加,此时直观对比一下使用和不使用JIT后的计算耗时和通信耗时。可以看到,使用JIT后的物理模拟(计算)耗时显著减少,且PostMessage的时间小幅度提升。
异步(iOS 无JIT)
异步(iOS JIT)
使用和不使用JIT的游戏效果如下:
从效果可以看到,不使用JIT的小游戏卡顿明显(计算瓶颈导致物理更新不及时),而使用了JIT的小游戏非常流畅。
异步未使用JIT(gif动图):
异步使用JIT(gif动图):
这个demo说明:在使用Worker多线程时,JIT对计算速度有很高的提升。
# 何时使用Worker
正如上述所说,使用Worker线程提升的性能 = 并行提升的性能 – 通信消耗的性能
。
- 当迁移到Worker线程的计算耗时 等于 通信耗时时,使用Worker线程并不能优化小游戏的表现;
- 当通信数据量很大时,可能会造成通信耗时大于计算耗时,此时使用Worke线程反而会造成负面效果(当然,这种情况也比较难以出现,但并不意味着不会出现)。
因此,虽然使用Worker可以让小游戏在帧率和CPU利用率上有所提升,但是开发者仍然要自己衡量通信耗时所带来的损耗。当通信耗时较大时,开发者可以通过使用JIT,或者减少线程间通信数据量来优化。