最近在调一个HarmonyOS游戏项目的性能,被一个现象折腾得不轻:代码里明明到处是async/await、taskpool,一跑起来该卡还是卡。后来定位下来,问题出在一堆“假异步”上——表面上是异步调用,实际上执行的时机和方式还是同步阻塞。这篇文章就围绕我在游戏开发里遇到的这类卡顿,把成因、定位思路和改造方案完整梳理一遍。如果你也在HarmonyOS上做游戏,对“async写了但不流畅”这个现象有困惑,可以直接对照自己的代码排查。
1. 假异步到底是怎么产生的
1.1 HarmonyOS的异步模型和游戏场景的冲突
HarmonyOS应用侧主线程是ArkTS的事件循环模型,UI渲染、触摸事件、动画回调全部跑在同一个主线程上。你说这个模型和浏览器很像,确实,但游戏场景比普通应用更凶险:主线程每一帧要处理输入、更新逻辑、提交渲染指令,任何超过几毫秒的同步计算都会直接反映成掉帧。
ArkTS提供了async/await和Promise这套异步语法,也提供了taskpool这样真正能并行的任务调度器。问题在于,异步语法不等于异步执行。async只是标记了一个函数返回Promise,await只是让出当前执行,但如果函数体内部并没有把耗时操作委托给其他线程,那这段代码不管包多少层async,还是在主线程上同步跑完。
游戏里最容易出现的情况就是:主线程某个逻辑写得比较重,开发同学觉得“加个async就不卡了”,实际上一跑还是卡。原因很简单——async没有让计算离开主线程,它只是改变了代码的书写方式。
1.2 游戏开发里最常见的三类“假异步”写法
我复盘了手头几个项目,发现假异步基本集中在三种写法上。
第一类是用async标记但函数体内没有await。比如加载配置文件:
typescript复制async function loadLevelConfig() {
const text = fs.readTextSync('/level/data.json');
const config = JSON.parse(text);
return config;
}
这个函数被async修饰了,但里面没有任何真正的异步操作,readTextSync是同步读文件,JSON.parse是同步解析。调用它的那一刻,主线程就已经被堵住了,等函数返回Promise时,活已经干完了。这种写法最大的迷惑性在于:看起来是异步函数,实际上和直接同步调用没有区别。
第二类是把耗时计算包进Promise构造器。我曾经在一次碰撞检测优化里见到过这种写法:
typescript复制function checkAllCollisions(): Promise<void> {
return new Promise((resolve) => {
// 遍历所有敌人,逐对计算碰撞
for (let i = 0; i < enemies.length; i++) {
for (let j = i + 1; j < enemies.length; j++) {
// 大量数学运算
}
}
resolve();
});
}
Promise构造器里的代码是立即同步执行的,不是进了微任务队列,更不是开了新线程。把耗时计算塞进Promise构造器,不仅不会解决卡顿,反而会让Promise一直处于pending状态,后续await这个Promise的地方也跟着被拖住。Promise真正能做的“异步”是把回调延后执行,而不是把计算延后执行。
第三类是用了taskpool但把线程池塞满了。TaskPool确实能把任务派发到真正的后台线程,但这不代表线程池是无限的。默认情况下线程池大小跟设备CPU核数有关,游戏本身又是CPU密集型应用。当一批任务里每个任务内部都包含同步等待、轮询或者长时间计算时,线程池很快就会被占满,后续任务全部排队。如果主线程还在等某个任务的结果,那主线程一样卡住。
1.3 为什么“看着异步”比“明显同步”更危险
明显同步的代码卡了,大家第一时间会去看耗时函数,把大计算挪到子线程就完事。假异步的问题在于,它给排查增加了噪音。我见过不止一个开发同学拿着Profiler看调用栈,发现卡顿点在一个标着async的函数里,第一反应是“这不是异步了吗,怎么还卡”,然后就绕过去查别的地方了。
这个“绕过去”的动作就是假异步最浪费时间的部分。所以这篇文章的顺序是先讲原理,再讲定位,最后讲怎么改,核心目的就是让大家在卡顿排查时有一条明确的路:先弄清楚这个异步是真的异步,还是只是语法上的异步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 假异步卡顿的完整链路:从主线程到线程池
2.1 主线程被假异步堵死的两个路径
主线程被堵死,通常有两条路径。第一条是同步方法直接占用主线程。这条比较好理解,就是你调用了同步文件读写、同步数据库查询、同步JSON解析,这些方法无论写在普通函数里还是async函数里,都会在调用线程上同步执行。如果是主线程调用,主线程就停在那儿等它结束。
第二条是错误地同步等待异步结果。有些开发同学不熟悉Promise链式调用,会用一种“轮询结果”的方式来等异步任务:
typescript复制let result: Result | null = null;
taskpool.execute(task).then((data) => {
result = data;
});
while (!result) {
// 等待结果,但这里把主线程占死了
}
这个while循环在Promise resolve之前不可能退出,而Promise的then回调需要主线程事件循环去执行,但主线程卡在while里,事件循环根本转不动。结果就是死锁一样的表现:任务在后台跑完了,结果却永远回不到主线程。这种代码在教科书上不会出现,但我在实际项目里确实见到过类似写法。
2.2 TaskPool线程池排队的真相
TaskPool的任务调度不是来了就立即执行,而是放进一个任务队列,由线程池里的空闲线程领取执行。线程池大小、任务优先级、任务耗时这三个因素决定了队列的拥挤程度。
我有个比较极端的例子:一个关卡里同时投递了几十个加密计算任务,每个任务耗时20ms以上,线程池只有8个线程。结果就是第一批任务跑完要接近100ms,后续所有任务都在排队。如果主线程用await接收最后一个任务的结果,那主线程虽然被让出去了,但整个流程的完成时间被显著拉长,玩家感受到的就是“点了按钮后迟滞很久才有反应”。
更麻烦的是,TaskPool里的任务不是完全隔离的。如果多个任务共享某个锁或者信号量,互相等待,就可能出现线程池内的线程全部阻塞等待的情况,这时候再多的线程池资源都白搭。游戏开发中做加载管理器、资源管理器时尤其容易踩这个坑。
2.3 await不是魔法,只是事件循环的调度点
把await理解成“让出主线程”更准确,它不是“把计算抛到后台”。当你在主线程执行await somePromise时,主线程会暂时回到事件循环去处理别的任务,等somePromise resolve后再回来继续执行。这个机制本身没有问题,问题在于有些耗时操作根本不会产生Promise。
比如你在await之前调用了一个同步工具函数,这个同步函数一秒才返回,那么主线程在这段时间内就是卡着的,跟await没有关系。再比如你在Promise的then回调里又执行了一个同步耗时计算,那么主线程在回调触发时又会被卡住。
所以排查时一定要分清:卡顿是发生在await之前、await之后,还是await等待的那个Promise内部。这三个位置的处理方式完全不同。
2.4 用餐厅出餐来模拟这套调度机制
我用一个生活化的例子来总结这一整套机制。把主线程想象成餐厅前台,一个时间只能接待一个客人。异步操作相当于服务员去后厨下完单就回来继续接客,后厨做好菜再喊服务员端菜。这样的流程下,前台可以同时服务很多客人。
假异步是什么感觉呢?前台对客人说“我去后厨看看菜好了没”,然后他就真站到后厨灶台前,盯着厨师把菜做完再出来。中间有别的客人进门,他完全顾不上。至于TaskPool,相当于餐厅雇佣了几个临时帮厨,但帮厨数量有限。如果每个帮厨都在做一道费时的大菜,新来的单子只能排队等。更麻烦的是,如果前台下单后站在后厨门口等,帮厨们还互相借调料等了半天,那整个餐厅就彻底瘫痪了。
这个类比很好地解释了为什么假异步会导致卡顿:卡顿的本质不是“计算量大”本身,而是计算被放在了错误的位置,这个位置阻塞了关键路径上其他任务的执行。
3. 如何快速定位假异步:我在项目里用的排查方法
3.1 从帧率走势判断是瞬时卡还是持续卡
定位假异步的第一步,不是直接开Profiler,而是先观察卡顿的特征。我在游戏里会做一个简单的帧率统计面板,记录每一帧的耗时,并打上时间戳。如果卡顿表现为偶发性的单帧或连续几帧飙升,比如平时16ms一帧,某几帧突然跳到100ms以上,那大概率是主线程被某个同步操作瞬时堵住。
如果是持续的帧率下降,比如整体从60帧掉到30帧,那可能是TaskPool线程池饱和,或者后台任务长期占用线程资源。还有一种情况是操作后延迟卡顿,比如玩家点击某个按钮后,界面先卡了200ms才有反应,那说明在点击事件的回调链路里存在同步等待。
这些特征组合起来能帮你缩小排查范围。我自己习惯先把卡顿出现的场景记录下来,比如何时触发、界面处于什么状态、有没有伴随资源加载,这些信息在后续看Profiler时非常有价值。
3.2 用CPU Profiler抓住主线程的调用栈
DevEco Studio的CPU Profiler是我最常用的工具。抓取方式很简单:打开Profiler面板,选择CPU,开始录制,复现卡顿场景,停止录制,然后看主线程的调用栈火焰图。
这里有个关键技巧:把重点关注范围缩小到卡顿时间段内,不要太关注整个录制周期。因为游戏代码调用链长、函数多,全周期看很容易看花眼。我一般会在复现卡顿时做一个明显标记,比如同时点击某个按钮并截图,然后在火焰图上定位那个时间点。
在调用栈里,需要注意两类标志。一是看到明显的同步类方法,比如readTextSync、querySync、JSON.parse,这说明主线程上有同步I/O或同步解析。二是看到标着async但下方调用链全是同步方法的函数,这就是典型的假异步——它确实被标记成了async,但执行过程中没有切线程。
3.3 耗时埋点:不依赖工具的原始定位法
在某些场景下不方便跑Profiler,或者问题只在特定设备上复现时,我会直接在可疑函数前后插入耗时统计。ArkTS里可以用Date.now()记录时间戳,也可以直接用performance.now()拿更高精度的时间:
typescript复制function trackTime(name: string, fn: () => void) {
const start = performance.now();
fn();
const cost = performance.now() - start;
if (cost > 2) {
console.info(`[PERF] ${name} took ${cost}ms`);
}
}
这种方法虽然粗暴,但在定位假异步时非常有效。把怀疑有问题的函数用trackTime包一层,跑一遍游戏,看日志里哪个函数耗时超标。一旦发现某个标着async的函数耗时超过预期,就继续在这个函数内部往下埋点,逐层定位到具体的同步调用。
注意埋点本身对性能有影响,所以在定位完成后记得清理。我一般会用条件编译或者开关控制,只在调试版本里开启。
3.4 检查ThreadPool线程状态:看线程是不是被占满了
当怀疑是TaskPool线程池饱和时,需要用Profiler的线程视图查看各线程状态。正常健康的线程池里,任务执行完毕线程会短暂进入空闲状态。如果看到所有TaskPool线程长期处于Running或某一任务的执行时间异常长,基本可以确认线程池被占满了。
还有一种情况是线程之间互相等待。HarmonyOS的TaskPool任务如果涉及锁或信号量,线程状态会显示为Blocked。这种问题比单纯的计算耗时更隐蔽,因为单个任务的计算量看起来不大,但整体卡得很厉害。
我会结合任务内容来分析。如果TaskPool跑的是纯计算任务,Blocked状态基本可以排除锁等待;如果任务里涉及文件读写或网络请求,那就要重点关注是否存在资源竞争。定位到具体任务后,通常的解法是拆小任务粒度,或者把I/O操作从计算任务中分离出去。
4. 改造实操:从假异步到真异步的几个关键场景
4.1 碰撞检测:把纯计算任务拆给TaskPool
碰撞检测是游戏里最常见的CPU密集计算之一。假设有5000个敌人需要对玩家做距离判断,如果在主线程循环遍历,一帧就能耗掉好几毫秒甚至十几毫秒。很多人会直接写成一个async函数,本质上还是主线程计算。
正确的改造方式是把碰撞检测拆成多个子任务,分发给TaskPool并行计算。我在项目中是这样做的:
typescript复制import { taskpool } from '@kit.ArkTS';
@Concurrent
function collisionCheckChunk(enemyList: EnemyData[]): CollisionResult[] {
const results: CollisionResult[] = [];
for (const enemy of enemyList) {
if (checkDistance(enemy, playerPosition)) {
results.push(enemy.id);
}
}
return results;
}
async function updateCollisions() {
const chunkSize = Math.ceil(enemies.length / 8);
const tasks: taskpool.Task[] = [];
for (let i = 0; i < enemies.length; i += chunkSize) {
const chunk = enemies.slice(i, i + chunkSize);
tasks.push(new taskpool.Task(collisionCheckChunk, chunk));
}
const results = await taskpool.execute(tasks);
// 合并所有子任务的结果,更新UI状态
}
这里有个很重要的细节:传给TaskPool的数据必须是可序列化的。像Enemy这种带方法、引用类型的对象不能直接传进去,我在实际项目里会把每个敌人的坐标、半径等关键信息抽成一个纯数据结构,再传入任务。这就牵扯到一次数据拷贝和序列化开销,如果数据量太大,反而可能抵消并行计算的优势。所以我会按场景权衡:数据量小但计算密集的任务收益最大,数据量大但计算简单的任务就不太适合TaskPool。
4.2 对象池的异步构建:避免主线程瞬间创建大量对象
游戏里频繁创建和销毁对象会导致两个问题:一是内存分配的压力,二是如果创建过程涉及大量初始化,主线程会再次被拖住。我见过一种假异步写法是:在async函数里批量new出几百个精灵对象,然后设置纹理、设置位置。new对象本身是同步操作,这整个初始化过程全部在函数第一次执行时完成,跟有没有async没有任何关系。
对于这种场景,常用的方案是分帧创建。把几百个对象的创建分散到多个帧里,每帧只创建一部分,避免单帧负担过重:
typescript复制private pendingObjects = 100;
private createdCount = 0;
function createObjectsInBatch(batchSize: number): void {
for (let i = 0; i < batchSize; i++) {
// 创建单个对象并初始化
this.createdCount++;
if (this.createdCount >= this.pendingObjects) {
return;
}
}
}
然后在每帧的更新循环里调用createObjectsInBatch(10)。这种做法本质上就是“假异步”的反面:不需要任何线程和Promise,但是通过把任务拆小、分时执行,让主线程的每一帧都只需要承担很小的负载。
如果你确实想在后台线程批量化创建数据(不能直接操作UI节点,但可以先把纯数据准备好),TaskPool也是个选择。我会在后台线程先生成好100个对象的坐标、纹理索引等数据,返回给主线程后,主线程只用纯数据创建UI组件,这样主线程的工作量就只剩下“创建壳子”,大部分耗时已经被后台线程消化掉了。
4.3 资源加载与解码:把网络和文件操作放到子线程
文件读取和图片解码是游戏加载阶段的另一个大头。HarmonyOS提供了同步和异步两套API,但有些工具库或者老代码可能用的是同步版本。比如读取关卡数据用的是fs.readTextSync,一旦数据文件比较大,主线程就会卡。
最稳妥的改造是直接换成异步API,比如用fs.readText配合await,或者用TaskPool做解码和解析。以图片为例,如果需要在游戏里加载一张比较大的背景图并解码成PixelMap,可以这样做:
typescript复制import { image } from '@kit.ImageKit';
async function loadBackground() {
const rawFile = await getContext().resourceManager.getRawFileContent('bg/level1.jpg');
const imageSource = image.createImageSource(rawFile.buffer);
const pixelMap = await imageSource.createPixelMap();
return pixelMap;
}
这段代码里用了异步API,主线程不会在图片解码时卡死。但注意createImageSource和createPixelMap这两个方法在不同版本上可能有不同的线程调度行为,稳妥起见我会在TaskPool里做解码,主线程只接收最终的PixelMap。
加载阶段还要注意并发数控制。如果一个关卡需要同时加载几十个资源,全部并发发起,反而可能因为线程池和文件I/O竞争导致卡顿。我会用一个小型任务调度器,把资源加载请求排队,限制同时进行的任务数量,比如控制在4个以内。
4.4 改造时的边界:不是所有代码都需要异步化
如果把上面这些方法理解成“所有函数都要改成async+TaskPool”,那就走偏了。异步化和多线程有它的成本,包括任务调度的开销、数据序列化的开销、上下文切换的开销。如果函数本身耗时很小,比如几千次简单运算,跑完也就不到1ms,那TaskPool的调度开销可能比直接执行还大。
我一般会设定一个基准:主线程上耗时超过2ms的操作才值得做异步化或分帧处理。2ms以下的函数,先考虑优化算法本身,不要急着上线程。这个阈值不是绝对的,实际看游戏的帧率目标和设备性能,但我用它来防止过度设计。
另外,UI相关的操作一定不能放到TaskPool里。ArkTS的并发模型里,TaskPool的任务不能访问UI组件的实例,只能返回纯数据。曾经有同事试图在TaskPool任务里直接改某个组件属性,运行时报错不说,还让整个任务的执行时间暴增。正确做法是任务只计算和准备数据,主线程收到数据后再更新UI。
4.5 主线程任务和后台任务的划分速查
我在项目里整理过一张划分表,很实用,放在这里供参考:
| 任务类型 | 推荐执行位置 | 原因 |
|---|---|---|
| UI节点创建、属性修改 | 主线程 | TaskPool无法访问UI实例 |
| 渲染指令提交 | 主线程 | 依赖ArkUI渲染管线 |
| 触摸事件处理 | 主线程 | 事件分发在主线程 |
| 碰撞检测、寻路计算 | TaskPool | 纯计算,适合并行 |
| 资源文件读取 | 子线程/异步API | 避免同步I/O阻塞 |
| 图片解码 | TaskPool | 解码耗时且数据可序列化 |
| JSON解析(大数据量) | TaskPool | 大JSON解析耗时较长 |
| 小数据量逻辑计算 | 主线程 | 异步调度开销可能更大 |
| 数据库查询(大量) | TaskPool | 避免占用主线程 |
| 加密解密 | TaskPool | 计算密集型 |
这张表不是绝对的,比如图片解码在某些版本上有专门的异步接口,效果类似,但在处理超大图时TaskPool明显更稳。核心原则是:一切能延后计算、能在后台数据化准备的任务,都优先考虑TaskPool;一切UI绑定、事件响应、渲染相关的操作,留在主线程。
5. 实测对比与避坑经验
5.1 改造前后的一组实测数据
我用一个测试工程简单记录了一组数据,供参考。场景是5000个敌人进行碰撞检测,测试设备是中端HarmonyOS手机。
| 方案 | 主线程帧耗时 | 完成时间 | 体验 |
|---|---|---|---|
| 主线程直接计算 | 12ms | 12ms | 明显掉帧 |
| async包裹但同步计算 | 12ms | 12ms | 同样掉帧,代码复杂却没效果 |
| TaskPool拆8个任务并行 | 0.5ms | 3.2ms | 主线程基本无感 |
| TaskPool拆64个任务并行 | 0.8ms | 4.5ms | 调度开销变大,反而变慢 |
第三行和第四行的对比说明一个问题:任务拆得太细不一定更好。8个任务基本上能让8个线程同时干活,64个任务虽然粒度更小,但任务调度和数据切分的开销抵消了并行收益。实际项目中我一般按线程池大小的1.5到2倍来拆任务,既能充分利用线程,又不会让调度器过载。
5.2 我踩过的一些坑和一直保留的排查习惯
第一个坑是忘了处理TaskPool的线程安全。虽然不同任务之间是隔离的,但如果多个任务访问同一个全局状态,依然会有竞争风险。比如一个全局的敌人列表,如果每个任务都往里面push结果,就会出问题。我的做法是让每个任务返回独立的结果,由主线程统一合并,尽量不共享可变状态。
第二个坑是过分相信某个性能工具的数据。不同工具对协程和异步函数的展示方式不同,有时候火焰图里看不出来到底在哪一行卡住。我会结合埋点日志和工具一起看,两边数据互相印证。比如Profiler显示某个async函数耗时很长,但埋点显示这个函数内部没有任何单项超过2ms的操作,那就要考虑是不是线程调度等待,而不是计算本身的问题。
第三个坑是释放线程不及时导致后续任务排队。TaskPool的任务执行完之后线程会自动回收,但如果你在任务内部又创建了新的TaskPool任务并等待它完成,就可能出现“任务套任务”的嵌套阻塞。尽量避免在TaskPool任务里再提交TaskPool任务,要么把逻辑打平,要么用信号量机制在主线程统一协调。
我自己一直保留的排查习惯是,在游戏里内置一个性能统计面板,不需要任何外部工具,直接在界面上显示当前帧率、主线程忙碌占比、TaskPool任务数。主线程忙碌占比是判断假异步的黄金指标:如果占比高,而且耗时分布都集中在标着async的函数里,那基本可以断定是假异步。
5.3 最后再分享一个小技巧
很多卡顿问题不是从一个函数开始的,而是从一次“看起来没问题的重构”开始的。比如把某个循环抽成一个返回Promise的函数,或者给某个同步函数加了async关键字,这些改动看起来都很安全,但实际上可能把假异步带进代码。我的办法是给团队定一条硬规矩:任何async函数里必须至少有一个真正的异步操作,要么是await某个Promise,要么是调用taskpool。如果这个函数不需要异步,就不要标async。
这样保证代码评审的时候一眼能发现问题,不用等到卡顿出现再去排查。排查假异步本质上就是排查“哪些代码被错误地标记/包装成了异步”,这个问题如果能在团队流程层面卡住,比事后性能优化省事得多。
