外观
Stage06|JSB & Native Bridge
Lesson079|主线程、渲染、网络、音频与平台线程边界
Tags: #Creator2.x #Stage06 #Thread #MainThread #CallbackDifficulty: ⭐⭐⭐⭐⭐
真实问题:Native 网络回调里直接改 Label,为什么偶发崩溃
后台线程收到下载完成,回调直接执行:
ts
this.label.string = 'done';有时看起来正常,有时崩溃、黑屏或下一帧状态错乱。原因不是网络结果本身,而是回调线程不一定是 Creator 主线程;Node、Renderer、JS Engine 和 UI 状态通常有线程归属。
一、本课目标
本课建立线程边界的安全模型:
后台线程可以做耗时工作,但不能假设可以直接操作 JavaScript、Node 或 Renderer。
二、为什么需要多个线程
主线程要维持游戏帧:
text
输入
→ 脚本
→ 场景更新
→ 渲染提交耗时操作可能放到其他线程:
- 网络请求。
- 文件读取。
- 解压和部分解码。
- 音频播放。
- 平台 SDK。
- 后台任务。
目的不是“线程越多越快”,而是避免阻塞关键帧路径。
三、主线程通常负责什么
在 Creator 2.x 项目中,通常应把以下操作视为主线程敏感:
text
执行大部分 JavaScript
修改 Node / Component
生命周期和事件
场景切换
渲染对象状态
调用非线程安全的引擎 API具体线程模型以当前平台和引擎实现为准,但业务代码不应从任意 Native 线程直接修改场景树。
四、后台任务如何把结果交回来
安全模式:
text
主线程发起任务
→ 后台线程执行 IO / 计算
→ 生成不可变或独立结果
→ 投递到主线程队列
→ 主线程检查对象和请求是否有效
→ 更新 JavaScript / Node / UI关键点是“投递结果”,而不是后台线程直接碰场景对象。
五、为什么 C++ 不能任意线程调用 JS
JavaScript Engine 通常要求在特定线程或持有正确上下文时执行。错误线程调用可能导致:
- 崩溃。
- GC 状态损坏。
- 随机数据竞争。
- 回调偶发丢失。
- 只在部分设备复现。
原生回调应通过引擎或平台提供的主线程/脚本线程调度机制返回。
六、渲染线程和 GPU
CPU 提交和 GPU 执行可能异步:
text
主线程准备状态
→ 渲染系统提交命令
→ GPU 稍后执行某些平台还有独立渲染线程。即使如此,业务 Node 状态通常仍需在引擎规定线程修改。错误同步会导致:
- CPU/GPU 等待。
- 资源正在使用时被释放。
- 画面撕裂或状态不一致。
七、网络和音频线程
网络回调可能来自后台线程,音频系统也可能维护独立线程。正确做法是区分:
text
后台线程:接收、解码、排队
主线程:修改游戏状态和 UI
音频线程:播放和混音不要在网络回调中直接遍历和修改场景树,尤其是在 Native SDK 自己管理线程的情况下。
八、共享数据和竞态
text
后台线程写玩家数据
主线程同时读取玩家数据可能发生:
- 读取一半更新的数据。
- 容器扩容时崩溃。
- 回调顺序错乱。
- 状态覆盖。
解决方向:
- 消息队列。
- 不可变快照。
- 锁或原子操作。
- 单线程所有权。
- 明确任务序号和取消策略。
优先使用简单、可验证的所有权模型,而不是在业务层到处加锁。
九、案例:Native SDK 回调更新 UI
错误模型:
text
SDK 工作线程回调
→ 直接调用 JS
→ 直接修改 Label安全模型:
text
SDK 工作线程回调
→ 复制必要结果
→ 投递主线程
→ 检查当前 Scene 和 Component
→ 调用 JS 回调
→ 更新 Label页面关闭时还需要取消请求或让回调失效。
十、常见误区
误区一:后台线程计算完就可以直接改 Node
Node 和场景树通常不是任意线程安全的。
误区二:加一个 mutex 就解决所有问题
锁可能造成主线程等待,还需要正确的所有权、生命周期和回调顺序。
误区三:网络回调一定在主线程
取决于实现和 SDK,必须确认文档或源码。
误区四:GPU 独立执行,所以释放纹理不需要同步
资源可能仍被渲染队列使用,引擎需要正确延迟或同步释放。
十一、练习与答案
- 为什么后台线程不应直接修改 Node?
- 后台任务结果如何安全返回游戏逻辑?
- C++ 从任意线程调用 JS 有什么风险?
- 共享数据除了加锁还有哪些设计方式?
答案:
- 场景树和多数引擎 API 有主线程约束,不保证线程安全。
- 复制结果、投递主线程、检查生命周期后再更新。
- JS Engine 上下文和 GC 可能被破坏,导致崩溃或竞态。
- 消息队列、不可变快照、单线程所有权、任务序号和原子状态。
先定义线程所有权
text
主线程:JS/Node/大部分 UI 与游戏逻辑
渲染相关线程:提交/图形上下文(实现依平台)
网络线程:I/O 与解析
音频线程:混音/设备输出
平台回调线程:SDK/系统决定不要用“现在没崩”证明线程安全。必须知道回调在哪条线程,以及如何切回主线程。
Creator 2.4.x 实验:后台结果回主线程提交
ThreadBoundaryProbe.ts
ts
const { ccclass, property } = cc._decorator;
@ccclass
export default class ThreadBoundaryProbe extends cc.Component {
@property(cc.Label)
label: cc.Label = null;
private generation = 0;
beginTask() {
const id = ++this.generation;
// 使用项目已有异步/Native API,不在课程中伪造线程。
this.scheduleOnce(() => {
if (id !== this.generation
|| !cc.isValid(this.node)) {
return;
}
this.label.string = 'completed on main loop';
}, 0);
}
cancel() {
this.generation++;
}
}这段示例表达“结果排队到游戏循环并验证代次”,不代表所有 Native API 会自动切线程。真实平台回调必须使用项目提供的主线程调度器。
实验步骤
- 使用已有 Native 网络/SDK 回调,在原生侧输出线程标识(若项目已有能力)。
- 回调只写入线程安全数据或投递主线程任务。
- 主线程任务更新 Node/Label。
- 页面关闭后确认 generation 拒绝结果。
- 用高频回调测试队列积压和取消。
共享数据的竞态
text
后台线程写 data
主线程同时读 data
→ 可能读到半更新状态安全策略:
- 消息队列传不可变快照;
- 原子状态/锁(Native 层);
- 主线程只接收完整结果;
- 明确队列生命周期;
- 丢弃过期 session。
不要把普通 JavaScript 对象当作跨线程共享安全容器。
真实项目故障:网络峰值时 UI 消息队列越积越多
SDK 每秒回调数千条进度,主线程每条都创建对象并刷新 Label。网络线程没阻塞,主线程却被回调消费拖垮。
修复:
- 后台聚合进度,按显示频率采样;
- 只提交最后值或关键变化;
- 队列有上限和丢弃策略;
- UI 不在每条底层消息上刷新;
- 退出页面清空队列并增加 session。
线程诊断
偶发崩溃
记录线程、回调时序、对象有效性和 Native 栈;不要只加 try/catch,C++ 线程错误可能已破坏状态。
UI 偶尔少一条
检查队列丢弃/竞态/批量聚合是否符合产品语义,区分 bug 与有意采样。
音频回调卡住主线程
确认回调是否做了文件、网络、日志或 JSB 重工作;音频线程应尽快返回。
版本边界与练习答案
线程划分不是 Creator 2.4.x 对所有平台的固定表;以目标平台、原生引擎和 SDK 文档为准。
- 为什么后台线程不能直接改 Node? Node/JS/渲染对象通常由主线程所有,跨线程访问缺少安全契约。
- 主线程队列也会卡吗? 会。大量回调/消息仍在主线程执行,需要聚合和限频。
- 如何传递后台结果? 传完整快照/消息,主线程验证 session 和对象有效性后提交。
- 音频线程为何不能做重 JSB? 需要实时返回,阻塞会造成杂音/断音,并可能违反线程归属。
十二、本课总结
text
后台线程负责耗时工作,主线程负责游戏状态和大部分引擎对象。
跨线程交付要经过队列、生命周期检查和明确的数据所有权。十三、下一课预告
text
Lesson080|阶段复习:从 Creator API 定位到原生实现