1. 为什么原生线程不能直接回调 ArkTS
1.1 一次崩溃暴露出来的线程归属问题
模拟项目里有个数据采集模块,后台工作线程每 20ms 采一次样本,采完想在 ArkTS 侧更新界面上的实时曲线。最开始的做法很直觉:在start函数里把 ArkTS 传进来的回调用napi_create_reference存住,再把napi_env和napi_value一起塞进一个全局结构体,然后std::thread里每 20ms 直接napi_call_function调一次。单次点按钮测试完全正常,日志也刷得欢,但真机连续跑三分钟,变量还没画出来,进程直接退出,崩溃日志指向原生层一个莫名其妙的内存地址。
这不是运气问题。napi_env是绑定线程的上下文对象,napi_value更像是handle scope 里的一张临时凭证,它只在创建它的那个线程、那个 scope 内有效。换一个线程去读它,读到的是什么,取决于内存那一刻被谁踩过。我在几次压测里见过三种表现:第一次没有任何异常,回调静默丢失;第二次拿到的是别的对象,属性读取全是undefined;第三次最干脆,信号 11 直接进来。所以核心结论很明确——原生侧另外开的工作线程,不管它是std::thread、pthread_create还是线程池里的任务,都不能碰 JS 侧的对象。手里能拿的只能是一个不透明的句柄,真正的干活步骤必须交还给创建napi_env的那个线程去执行。
1.2 napi_call_threadsafe_function 解决的是投递,不是调用
很多人第一次看这个名字,会误以为它能让原生线程“直接调用” JS 函数。不是。napi_call_threadsafe_function的作用是把一份数据投递到队列,由 JS 主线程的事件循环在合适时机取出来,再执行真正的回调。它和 V8 里的 Isolate 锁、和uv_async_send是同一类思路:跨线程老老实实排一条异步线路。
这个接口出现在 Node-API 规范里,SDK 中native_api.h能搜到对应声明,就说明当前版本支持;我在较新的 API 版本上验证过,行为一致。整套接口一共五个函数,分工很清楚:
| 接口 | 调用方所在线程 | 职责 |
|---|---|---|
napi_create_threadsafe_function | JS 主线程 | 创建句柄,登记回调与清理逻辑 |
napi_acquire_threadsafe_function | 工作线程 | 增加引用计数,声明“我还在用” |
napi_call_threadsafe_function | 工作线程 | 把一份数据投进队列 |
napi_release_threadsafe_function | 工作线程/主线程 | 减少引用计数,归零则销毁 |
napi_ref_threadsafe_function/napi_unref_threadsafe_function | JS 主线程 | 控制是否阻止事件循环退出 |
看这张表就知道,真正涉及“跨线程”的只有acquire、call、release三个动作,而它们操作的都是一个纯 C 结构体级别的句柄,跟 JS 堆里那些对象没有任何直接关系。这也是它安全的前提。
1.3 三种跨线程方案的取舍
在动手之前,我对比了常见的三条路径,最终选threadsafe function不是因为它新,而是因为它最贴合“高频、多次、方向单一”的场景。
| 方案 | 适用场景 | 单次开销 | 主要短板 |
|---|---|---|---|
napi_create_async_work | 一次性耗时任务,完成后回主线程 | 每次都要创建/销毁任务对象 | 任务数量多时管理成本高,难以持续推送 |
napi_call_threadsafe_function | 工作线程持续产生数据、需要多次回传 | 投递一次就是一次入队 | 队列和引用计数要自己算清楚 |
| 原生共享内存 + ArkTS 轮询 | 数据量极大、容忍延迟 | 几乎没有调用开销 | 轮询时机难把握,容易白跑 |
第三条路在数据量特别大的解码场景里确实有人用,但放到“每 20ms 推一条”的需求上,轮询反而更浪费。async_work更适合“点一下按钮、跑一次解析、给个结果”的节奏,一旦你要连续推几百上千条,反复创建任务对象纯属自找麻烦。threadsafe function的优势就是创建一次、投递无数次,代价是需要自己维护生命周期,这一点后面会用一整节来算账。
2. threadsafe function 的运行机制拆解
2.1 一个句柄、一个队列、一个引用计数
拆开看,napi_threadsafe_function这个句柄在内部挂了三样东西:投递队列、引用计数、以及一组回调指针(业务回调func、转换回调call_js_cb、销毁回调thread_finalize_cb)。工作线程调napi_call_threadsafe_function时,接口做两件事:把void* data追加到队列尾部,然后唤醒 JS 线程的事件循环。JS 线程空下来,就按先进先出的顺序把队列里的数据逐个取出来,交给call_js_cb处理。
这里有个细节很关键:唤醒是“合并”的。工作线程连续投递 50 条,主线程可能只被唤醒一次,然后一口气处理完队列里所有的数据。所以如果你的生产速度远大于消费速度,队列就会持续膨胀,内存也跟着涨。max_queue_size就是用来兜住这种情况的闸门。
引用计数则是另一条独立的线。它不关心队列里有没有数据,只关心“还有多少个线程可能继续使用这个句柄”。计数归零并且队列已经清空,句柄才会被销毁,thread_finalize_cb才会被调用。两者的顺序不能颠倒,否则就是要么提前销毁、要么永远不销毁。
2.2 func 与 call_js_cb 这两条回调路径
napi_create_threadsafe_function同时接受func和call_js_cb两个回调参数,很多人搞不清该填哪个。
func是你在 ArkTS 侧传进来的那个函数对象的引用,框架默认会把队列里的数据当成“什么都不传”来调用它——也就是说,如果你的call_js_cb传NULL,回调会被触发,但拿不到任何参数,data指针也没人管。在“我只是通知一下主线程有事发生了”的场景里,这招够用;但只要你想传数据,就必须提供call_js_cb。
call_js_cb才是真正的桥。它在 JS 线程上被调用,签名是void (*)(napi_env env, napi_value js_callback, void* context, void* data)。由于它运行在 JS 线程,env和js_callback都是合法可用的,你可以在里面把data拆成napi_value,再用napi_call_function去调js_callback。我自己的习惯是:只要涉及数据传递,就一律自己写call_js_cb,默认行为只在“纯信号通知”这种极简场景下用。
还有一点容易忽略——call_js_cb里env可能为NULL。这不是 bug,而是环境正在销毁(比如页面已经卸载、Ability 退出)时的正常状态。此时你要做的只有一件事:把data释放掉,然后直接返回,别再调用任何 napi 接口。
2.3 阻塞投递与非阻塞投递的差别
napi_call_threadsafe_function的第三个参数是napi_threadsafe_function_call_mode,取值只有两个:napi_tsfn_nonblocking和napi_tsfn_blocking。
非阻塞模式在队列已满时立刻返回napi_queue_full,数据不入队,控制权马上还给调用方。阻塞模式则会挂住工作线程,直到队列腾出空位。听起来阻塞模式更“稳”,但它有个致命前提——只能在非 JS 线程使用。如果主线程自己调了阻塞投递,而队列又是满的,那就成了自己等自己,直接死锁。
我踩过一次这个坑:某个调试按钮的点击处理里,顺手写了个自测函数,在主线程调了napi_call_threadsafe_function(..., napi_tsfn_blocking),队列刚好被压满,界面瞬间冻住,日志也没了。排查了半天才反应过来是自锁。所以我的建议很直接:工作线程侧一律用napi_tsfn_nonblocking,遇到队列满就丢数据并记日志。数据采集这类场景丢一帧不影响整体观感,卡死线程才是灾难。
3. 从零实现一个最小可用的跨线程数据回传
3.1 工程骨架与构建脚本
先把目录结构摆清楚,后面代码都往这里放:
entry/src/main/cpp/ ├── CMakeLists.txt ├── napi_init.cpp └── types/libentry/Index.d.tsCMakeLists.txt的关键是链接libace_napi.z.so,日志用libhilog_ndk.z.so:
cmake_minimum_required(VERSION 3.5.0) project(nativeproducer) set(NATIVERENDER_ROOT_PATH ${CMAKE_CURRENT_SOURCE_DIR}) include_directories(${NATIVERENDER_ROOT_PATH} ${NATIVERENDER_ROOT_PATH}/include) add_library(entry SHARED napi_init.cpp) target_link_libraries(entry PUBLIC libace_napi.z.so libhilog_ndk.z.so)模块注册沿用标准写法,nm_modname要和 ArkTS 侧import的名字对上:
#include "napi/native_api.h" static napi_value Init(napi_env env, napi_value exports); static napi_module g_producerModule = { .nm_version = 1, .nm_flags = 0, .nm_filename = nullptr, .nm_register_func = Init, .nm_modname = "entry", .nm_priv = nullptr, .reserved = {0}, }; extern "C" __attribute__((constructor)) void RegisterProducerModule(void) { napi_module_register(&g_producerModule); }3.2 创建 tsfn 时五个参数的作用
napi_create_threadsafe_function的参数列表看着长,其实只有五个位置需要你真正动脑子:
static napi_value StartProduce(napi_env env, napi_callback_info info) { size_t argc = 1; napi_value argv[1] = {nullptr}; napi_get_cb_info(env, info, &argc, argv, nullptr, nullptr); napi_valuetype type; napi_typeof(env, argv[0], &type); if (type != napi_function) { napi_throw_type_error(env, nullptr, "callback must be a function"); return nullptr; } ProducerContext* ctx = new ProducerContext(); napi_value resourceName = nullptr; napi_create_string_utf8(env, "DataProducerTSFN", NAPI_AUTO_LENGTH, &resourceName); napi_status status = napi_create_threadsafe_function( env, argv[0], // func:ArkTS 侧回调 nullptr, // async_resource,一般传空 resourceName, // async_resource_name,用于调试定位 64, // max_queue_size 1, // initial_thread_count ctx, // thread_finalize_data FinalizeCb, // thread_finalize_cb ctx, // context,回调里原样带回来 CallJs, // call_js_cb &ctx->tsfn); if (status != napi_ok) { delete ctx; napi_throw_error(env, nullptr, "create tsfn failed"); return nullptr; } // 后续启动工作线程... }max_queue_size我给了 64。这个数值不是随便拍的:工作线程 20ms 一条,主线程正常情况下 16ms 内必然能处理一轮,64 条相当于留了三倍以上的缓冲。给 0 表示无界,理论上更“安全”,实际上是把内存风险敞口开到最大,一旦主线程被长任务卡住,队列只涨不消,最后 OOM。所以我宁愿设一个明确上限,满了就丢。
initial_thread_count传 1,代表“创建者这一个线程持有引用”。工作线程启动时再acquire一次,退出时release一次,这样账才对得上。
context这个参数特别顺手,它会原封不动地传回call_js_cb和thread_finalize_cb,等于给你一条把 C++ 上下文带进回调的通道,不用再搞全局变量。
3.3 工作线程侧的三步骨架
工作线程的逻辑可以压缩成一句话:进场 acquire,循环 call,出场 release。
struct Payload { int32_t seq; double value; std::string tag; }; static void CallJs(napi_env env, napi_value jsCb, void* context, void* data) { Payload* p = static_cast<Payload*>(data); if (env == nullptr || jsCb == nullptr) { delete p; // 环境已销毁,只负责清理 return; } napi_value undefined = nullptr; napi_get_undefined(env, &undefined); napi_value argv[3] = {nullptr}; napi_create_int32(env, p->seq, &argv[0]); napi_create_double(env, p->value, &argv[1]); napi_create_string_utf8(env, p->tag.c_str(), NAPI_AUTO_LENGTH, &argv[2]); napi_value ret = nullptr; napi_call_function(env, undefined, jsCb, 3, argv, &ret); delete p; // data 由回调负责释放 }工作线程本身这样写:
void ProducerLoop(ProducerContext* ctx) { napi_acquire_threadsafe_function(ctx->tsfn); int32_t seq = 0; while (ctx->running.load()) { Payload* p = new Payload{++seq, SimulateSample(), "sensor"}; napi_status st = napi_call_threadsafe_function( ctx->tsfn, p, napi_tsfn_nonblocking); if (st == napi_queue_full) { delete p; // 没进队列,必须自己回收 OH_LOG_Print(LOG_APP, LOG_WARN, LOG_DOMAIN, LOG_TAG, "queue full, drop %{public}d", seq); } else if (st != napi_ok) { delete p; break; // napi_closing 等情况,直接退出 } std::this_thread::sleep_for(std::chrono::milliseconds(20)); } napi_release_threadsafe_function(ctx->tsfn, napi_tsfn_release); }这里有两个必须记住的点。第一,data的所有权在调用napi_call_threadsafe_function成功那一刻就转移给了框架,之后由call_js_cb负责delete;一旦失败,所有权还在你手里,必须自己释放,否则每丢一条就漏一份内存。第二,acquire和release必须严格成对,中间任何一条return或throw路径漏掉release,句柄就永远销毁不了。
3.4 销毁回调:把 context 收干净
static void FinalizeCb(napi_env env, void* finalizeData, void* hint) { ProducerContext* ctx = static_cast<ProducerContext*>(finalizeData); if (ctx->worker.joinable()) { ctx->worker.join(); } delete ctx; }FinalizeCb是唯一能确定“这个 tsfn 彻底不用了”的地方。env在这里同样可能是NULL,所以里面不要调用任何依赖环境的接口,只做内存回收和线程收尾。
3.5 ArkTS 侧的声明与调用
类型声明放在src/main/cpp/types/libentry/Index.d.ts:
export const startProduce: ( callback: (seq: number, value: number, tag: string) => void ) => void; export const stopProduce: () => void;页面里直接调用,回调在 JS 主线程执行,改@State是安全的:
import nativeProducer from 'libentry.so'; @Entry @Component struct Index { @State latest: string = '--'; @State count: number = 0; build() { Column({ space: 12 }) { Text(`最新采样:${this.latest}`).fontSize(18) Text(`累计条数:${this.count}`).fontSize(16) Button('开始采集').onClick(() => { nativeProducer.startProduce((seq: number, value: number, tag: string) => { this.count = seq; this.latest = `${tag} ${value.toFixed(2)}`; }); }) Button('停止采集').onClick(() => { nativeProducer.stopProduce(); }) } .padding(24) } }3.6 编译运行与日志验证
跑起来之后看两处日志:原生侧每次投递打一条OH_LOG_Print,回调里再打一条,两条的 seq 应该能对上。如果只看到投递没有回调,八成是max_queue_size设小了又在瞬间灌爆,或者主线程被长任务堵住了。如果进程在退出时崩,重点查FinalizeCb里有没有在 join 一个还在跑的死循环线程——running标志必须在stopProduce里先置false,再触发释放。
4. 生命周期账本:引用计数对不齐就会泄漏或崩溃
4.1 initial_thread_count 与 acquire/release 的配对规则
这套接口最容易翻车的地方不是投递,而是账。规则本身很简单:创建时给一个初始值,之后每多一个可能调用call的线程就acquire一次,线程退出就release一次,计数归零且队列清空时句柄销毁。但实际写代码时,各种提前return、异常分支、线程创建失败,都会让某一侧漏掉。
我整理了一份对照,出问题时就按这张表查:
| 写法 | 现象 | 根因 |
|---|---|---|
initial_thread_count传 1,工作线程不acquire | 线程还在跑,句柄被销毁,投递返回napi_closing | 框架认为没人用了 |
工作线程acquire了但退出时不release | 停止采集后内存不释放,反复启停越来越卡 | 计数永远不归零 |
release次数多于acquire | 偶发崩溃,尤其在快速点击开始/停止时 | 计数被减穿 |
initial_thread_count传 0 | 创建直接失败 | 规范要求至少为 1 |
最稳的写法是给ProducerContext加一个原子标志acquired,acquire成功后置位,release前检查并翻转,保证一个线程生命周期内只减一次。
4.2 队列里残留数据由谁释放
环境销毁时,队列里可能还压着一堆Payload。框架会逐个把它们交给call_js_cb,此时env是NULL,你只要delete p就行。这就是前面CallJs里第一段判空的意义,它不是防御性冗余,而是正常路径。
反过来说,如果你在call_js_cb里判了空却忘了delete,这些残留数据就成了永久泄漏。我在一个反复进出页面的测试里观察到过这种情况:进出二十次,内存涨了十几兆,最后定位就是判空分支少了一句delete。
还有一种更隐蔽的情况:用napi_tsfn_abort模式释放时,框架会丢弃队列里的剩余任务,不再逐个回调。这时候没人替你释放,泄漏量更大。所以除非你在做“立刻中止、数据全丢”的紧急退出,否则一律用napi_tsfn_release,让残留数据走完清理流程。
4.3 ref 与 unref 对事件循环的影响
napi_ref_threadsafe_function和napi_unref_threadsafe_function这两个接口容易被忽略,但它们决定了一个很实际的问题:主线程还有没有别的事要干。
默认情况下,只要句柄存在,就会阻止事件循环因为“没有任务”而退出。如果你的采集线程是常驻的,这个行为没问题。但如果你希望页面进入后台、没有其他活跃任务时可以让主线程休眠,就需要在合适时机unref,等回到前台再ref。
需要强调的是,这两个接口必须在 JS 主线程调用,也就是在napi_create_threadsafe_function所在的线程。在工作线程里调,行为和预期完全不一致。我在一次后台采集的调试里,把unref放进了工作线程的初始化里,结果主线程该睡的时候没睡,耗电明显上升,挪回主线程才恢复正常。
5. 高频数据场景下的性能取舍
5.1 队列长度、丢帧策略与背压
队列长度的本质是在“内存占用”和“数据完整度”之间做交换。我给几个实测过的参考值:
| 生产频率 | 建议 max_queue_size | 说明 |
|---|---|---|
| 低于 10 条/秒 | 16 | 主线程随便处理得过来 |
| 20 到 60 条/秒 | 64 | 留三倍缓冲,够用且不浪费 |
| 100 条/秒以上 | 128,并考虑合并 | 单条推送意义不大,建议批量打包 |
高频场景下我最推荐的做法不是加大队列,而是在原生侧做聚合:工作线程内部维护一个定长缓冲,攒够 10 条或者攒够 50ms 才投递一次,Payload里装一个数组。这样队列压力直接降一个数量级,ArkTS 侧一次回调批量处理,UI 刷新也更顺。代价是延迟略微增加,但对界面展示来说完全是可接受的。
5.2 结构体、字符串与二进制数据的传递方式
data是void*,理论上什么都能装,但装什么、怎么装,直接决定了回调里的开销。
- 纯数值:直接塞进一个小结构体,
new一次delete一次,开销可以忽略。 - 字符串:结构体里放
std::string,回调里用napi_create_string_utf8转。注意c_str()的生命周期要活到转换完成,所以delete p必须放在转换之后。 - 二进制块:不要逐字节转数组。用
napi_create_arraybuffer配合napi_create_typedarray,把原生缓冲区的数据memcpy进去一次完成。如果数据量特别大(比如几百 KB 一帧),更推荐原生侧持有缓冲区、只把句柄和长度传过去,ArkTS 侧按需读取,避免每次投递都复制一遍。
我在一次图像数据回传的优化里对比过:同样是 200KB 的数据,逐元素napi_set_element要 40ms 以上,换成arraybuffer一次拷贝降到 2ms 以内,差距非常明显。
5.3 回调里不要做重活
call_js_cb和最终执行的 ArkTS 回调都跑在 JS 主线程上。这意味着你在回调里多花 10ms,界面就少 10ms 的渲染时间。几个明确的禁忌:
- 不要在回调里做大量字符串拼接、正则匹配;
- 不要在回调里同步读写文件或调用耗时同步接口;
- 不要在回调里再创建新的 tsfn,除非你真的清楚自己在干什么。
正确姿势是回调里只做“存值 + 标记”,把真正的处理放到setTimeout或任务队列里延迟执行,让主线程先把这一帧渲染完。
6. 问题速查与踩坑记录
6.1 症状对照表
| 症状 | 大概率原因 | 处理方向 |
|---|---|---|
| 回调一次都不来 | 工作线程没 acquire 就 call,句柄已被销毁 | 检查 acquire 是否在 call 之前 |
| 回调断断续续,日志里出现大量 drop | 队列满,非阻塞投递丢数据 | 加大队列或做批量聚合 |
| 停止后进程崩溃 | 线程还在跑时 context 被 delete | stopProduce里先置标志再 join |
| 反复启停内存上涨 | release 缺失或残留 data 未释放 | 检查每条路径的 delete 与 release |
| 界面卡顿、投递全返回 full | 主线程被长任务堵住 | 排查 ArkTS 侧同步耗时调用 |
| 退出页面时崩在 native | env 为 NULL 时仍调用 napi 接口 | CallJs里先判空再处理 |
6.2 几个印象深刻的坑
第一个坑是停止函数没等线程退出就释放了 context。stopProduce里我把running置false之后直接调了napi_release_threadsafe_function,工作线程可能刚好还在while里准备下一轮,读到的是已经被释放的ctx->running。修复办法很简单,release之前先join,让线程彻底走完再释放。
第二个坑是napi_call_function抛出的异常没人管。ArkTS 侧回调如果抛了异常,napi_call_function返回napi_pending_exception,异常对象会挂起。如果在call_js_cb里不管它继续下一轮,异常会越积越多,最终在某个不相干的时刻爆出来。稳妥做法是每次调用后用napi_is_exception_pending检查,有异常就napi_get_and_clear_last_exception取出来打个日志,避免污染后续调用。
第三个坑是在 Ability 切换时没有处理环境销毁。页面被销毁的瞬间,环境进入 teardown,此时队列里可能还有十几条数据。如果没有在CallJs里判env == nullptr直接清理,就会在销毁流程里调用 napi 接口,触发不可恢复的错误。这个坑最坑的地方在于它只在特定时序下出现,本地跑一小时都未必复现,只有反复快速切换页面才暴露。
第四个坑是把data传成了栈上变量的地址。曾有个同事图省事写了Payload p; napi_call_threadsafe_function(tsfn, &p, ...),函数一返回栈帧就没了,回调里读到的是已经被覆盖的内存,表现为数值乱跳,看起来像传感器坏了。这个错误的迷惑性在于它大部分时候“看着还能用”,因为栈内存还没被重用,等到某次调用稍微深一点就立刻出错。
最后再分享一个排查习惯。遇到回调不来的问题,我会在三个位置各打一条带时间戳的日志:acquire之后、call返回之后、CallJs进来之后。三条日志的时序关系基本能定位问题出在哪一环——没有第一条说明线程根本没启动,有第一条没第二条说明卡在投递,有前两条没第三条说明主线程没在消费。这比一行行读代码快得多,尤其是接手别人写的模块时。