ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS 7 Rust + Node-API:流式分词回调的线程归属、取消令牌与 NativeHandle 回收【鸿蒙心迹】

HarmonyOS 7 Rust + Node-API:流式分词回调的线程归属、取消令牌与 NativeHandle 回收【鸿蒙心迹】 1.8 MB 的词库能在两秒内处理完页面退出后却偶尔还会跳一次进度连续进入十几次NativeHandle 数量也不再回到零。性能已经达标生命周期反而成了上线前真正的阻塞点。我把问题缩成了一个独立工程LexiBridge Lab。本次任务号tokenize_20261001_05输入文件 1.8 MB共拆成 64 个 chunk产出 128,460 个 token。11:18 的正常任务已处理 64/64回调 p95 为 23 ms取消测试延迟 7 ms峰值内存 46 MB最终 live handles 为 0状态RELEASED。工程里真正的三条边界是Rust 工作线程不能直接操作 ArkTS 值取消不是把页面上的进度条藏起来而要让 native 任务停止继续生产页面销毁后即使晚到回调仍在队列里也不能再写 UINativeHandle 还必须释放一次且只释放一次。一、最开始只有一个“快”的 Demo第三方分词库是 Rust 写的词典加载和批量切词都很快。第一版桥接直接暴露tokenize(text)ArkTS 传入整段文本Rust 返回完整数组。小样本没有问题换到 1.8 MB 文件后主线程需要等待巨大结果对象创建内存峰值也明显上升。我们随后改成流式回调native 每生成一批 token 就把 chunk 推给 ArkTS页面更新进度并写入本地索引。吞吐改善以后新的问题出现了。页面退出只是把isLoading改成 falseRust 线程并不知道它继续切词并投递回调。下一次进入页面时旧任务与新任务共用同一个进度接收器数字会突然从 8% 跳到 73%。更难发现的是 handle 泄漏。成功路径会释放词典与回调句柄取消路径只设置一个布尔值就 return异常路径又可能同时触发 Rust Drop 和 C finalize。结果有时漏一次有时释放两次。这个问题不能靠页面加判断修补必须重新划清 Rust、Node-API 和 ArkTS 三层的所有权。我先把旧实现的生命周期画成时间线才发现“任务结束”在三层里含义不同。Rust 认为最后一个 chunk 计算完成就是结束C 认为线程安全回调释放才结束ArkTS 则在页面不再需要结果时就认为结束。三个时点没有错只是缺少共同协议。新接口把任务状态与资源状态分开业务可以先进入CANCELLED资源仍处于RELEASING直到 handle、回调队列和词典引用都归零才进入RELEASED。另一个改动是禁止复用全局回调。第一版为了少创建对象所有任务共享一个 listener结果 sessionId 只能靠闭包推断。现在每次 start 都创建独立 BridgeContext回调数据显式携带 jobId 和 chunkIndex。多开页面或快速重试时即使消息交错ArkTS 也能知道它属于哪一代会话。二、Rust 任务自己持有取消状态和资源计数下面这段 Rust 代码解决的是“ArkTS 发出取消后工作线程仍然继续产出 chunk”。每个任务有独立的原子取消令牌循环只在 chunk 边界读取一次Drop负责把 live handle 计数归还。pubstructTokenizeJob{id:String,cancel:ArcAtomicBool,tokenizer:ArcTokenizer,}implTokenizeJob{pubfnrunF(self,chunks:VecString,mutemit:F)-Resultusize,LexiErrorwhereF:FnMut(usize,VecToken)-Result(),LexiError{letmuttotal0usize;for(index,chunk)inchunks.into_iter().enumerate(){ifself.cancel.load(Ordering::Acquire){returnErr(LexiError::Cancelled);}lettokensself.tokenizer.tokenize(chunk)?;totaltokens.len();emit(index1,tokens)?;}Ok(total)}}implDropforTokenizeJob{fndrop(mutself){LIVE_HANDLES.fetch_sub(1,Ordering::AcqRel);}}取消检查放在 chunk 边界而不是每个字符上。64 个 chunk 下本机取消延迟为 7 ms已经足够响应页面退出同时不会让热循环反复读取原子变量。正式项目要根据单 chunk 最坏耗时调整粒度如果一个 chunk 可能计算几百毫秒就应继续拆小。Rust 的Drop是资源释放的最终兜底不代表 C 可以随意 delete。桥接层只持有一个拥有者指针转交或销毁时必须把原指针置空。live handle 是调试指标不参与业务判断它帮助我们验证成功、失败、取消和页面销毁四条路径最终都回到零。三、跨线程回调只传普通数据不传 ArkTS 对象Node-API 的环境与 ArkTS 值都有线程归属。工作线程不能保存一个napi_value然后直接调用。桥接层使用线程安全回调/异步工作机制把 native chunk 复制成普通结构再由 JS 线程创建数组和对象。下面这段 C 代码解决的是“Rust 工作线程直接触碰 JS 环境以及取消时回调通道无法关闭”。示例省略参数校验但保留所有权和 finalize 边界。structBridgeContext{std::shared_ptrTokenizeJobjob;napi_threadsafe_function tsfn{nullptr};std::atomicboolclosing{false};};voidEmitChunk(BridgeContext*ctx,ChunkResult chunk){if(ctx-closing.load(std::memory_order_acquire))return;auto*heapChunknewChunkResult(std::move(chunk));napi_status statusnapi_call_threadsafe_function(ctx-tsfn,heapChunk,napi_tsfn_nonblocking);if(status!napi_ok)deleteheapChunk;}voidFinalizeBridge(napi_env,void*data,void*){auto*ctxstatic_castBridgeContext*(data);if(!ctx-closing.exchange(true)){ctx-job-cancel();napi_release_threadsafe_function(ctx-tsfn,napi_tsfn_abort);}deletectx;}ChunkResult只有字符串、序号和 token 列表不引用 ArkTS 页面。非阻塞投递失败时立即删除 heapChunk不能假设 finalize 会替它处理。closing.exchange(true)保证多个关闭来源里只有一个真正执行 releaseArkTS 主动 dispose、native 错误和 GC finalize 都可以到达但资源只收一次。回调队列也要有上限。Rust 生产速度如果持续大于 ArkTS 消费速度无限排队只是把主线程卡顿换成内存膨胀。LexiBridge Lab 将同时在途的 chunk 限制为 4超过后工作线程短暂等待取消令牌会唤醒等待避免页面已经离开线程还堵在背压条件变量上。背压并不是固定 sleep。桥接层维护可用槽位JS 线程消费一个 chunk 后归还一个槽位工作线程等待的是条件变量并同时监听 cancel。这样负载高时不会空转占 CPU取消时又能立即被唤醒。页面如果在后台降低消费频率队列仍然只保留 4 个 chunk峰值内存不会随着停留时间继续上涨。为了避免一次回调创建过多小对象我把 token 结果按列组织为文本缓冲、offset 数组和类型数组ArkTS 需要展示时才组装可见部分。Demo 为便于阅读仍展示 TokenChunk 模型正式索引路径使用紧凑结构。这个优化改变数据布局不改变所有权跨线程边界前仍然完成复制JS 不持有 Rust 可变内存。四、ArkTS 会话用 generation 丢弃晚到结果Native 停止需要时间队列里也可能已有一个回调。下面这段 ArkTS 代码解决的是“页面销毁后晚到回调仍然写状态以及旧任务覆盖新任务”。每次 start 生成新的 generationdispose 先使代次失效再通知 native 取消。exportclassTokenizerSession{privategeneration:number0privatehandle:number0privatedisposed:booleanfalsestart(path:string,onChunk:(chunk:TokenChunk)void):void{constcurrentthis.generationthis.disposedfalsethis.handlelexiNative.tokenizeStream(path,(chunk:TokenChunk){if(this.disposed||current!this.generation)returnonChunk(chunk)})}dispose():void{if(this.disposed)returnthis.disposedtruethis.generationif(this.handle!0){lexiNative.cancel(this.handle)lexiNative.release(this.handle)this.handle0}}}generation 解决的是 UI 可见性不替代 native 取消。晚到 chunk 被丢弃以后Rust 仍要停止回调通道仍要关闭handle 仍要回收。反过来只做 native cancel 也不够因为取消信号到达工作线程以前队列中的消息可能已经排好。dispose()可重复调用是页面生命周期里很实用的约束。返回、异常弹窗关闭和组件析构可能走不同路径调用方不需要判断谁先释放。正式工程应在aboutToDisappear或明确会话结束点调用同时避免把短暂前后台切换误判成销毁是否继续任务由产品场景决定。页面只订阅聚合进度不按每个 token 重绘。64 个 chunk 最多触发 64 次进度变化UI 层又按一帧一次合并避免 native 已经变快以后渲染反而成为瓶颈。写索引失败时session 会取消剩余 native 任务并记录最后成功 chunk不能继续计算后假装整体成功。重新进入页面时不会复用旧 handle。新的TokenizerSession先生成 generation再创建 native 任务若 native 创建失败handle 保持 0dispose 仍可安全执行。这个顺序避免了“ArkTS 认为任务已开始native 实际没有句柄”的半初始化状态。DevEco Studio 图中左侧目录分为native / bridge / session / model中间打开TokenizerSession.ets右侧模拟器显示LexiBridge Lab。底部 HiLog 依次给出jobtokenize_20261001_05、chunks64/64、tokens128460、p9523ms、cancel7ms、peak46MB、liveHandles0 stateRELEASED。这组数据把性能与释放放在同一条链里不再只看“跑得快”。五、异常测试比成功跑完更有价值我给桥接层做了四组破坏性测试。第一组在第 17 个 chunk 主动取消确认取消延迟小于一个 chunk 的计算时间第二组让 ArkTS 回调故意抛错native 仍然能关闭通道第三组在 40% 时退出页面再立即进入新任务从 0 开始旧回调不会跳进新页面第四组让词典加载失败handle 计数仍回到零。还做了一次高频进出页面测试连续创建并销毁 100 个 session。修复前 live handles 在 6 到 9 之间波动修复后每轮结束都回到 0峰值内存也从不断抬高变成稳定的 46 MB。内存数字受设备与输入影响最重要的是曲线不再随着会话次数持续增长。我还把进程切后台、低内存回收和动态库加载失败加入测试。后台策略选择继续当前 chunk 后暂停恢复时从下一 chunk 继续进程被回收则不承诺恢复 native 内存只依赖上层已提交索引的游标重建任务。动态库加载失败时页面给出可诊断错误不进入RUNNING更不会创建一个无法释放的伪 handle。线程竞争测试使用两个并发 session它们共享只读词典映射但取消令牌、回调队列和 handle 完全独立。取消 A 不影响 BB 的回调也不能误用 A 的 generation。共享词典最后由引用计数释放只有两个任务都结束时才解除映射这能减少重复内存同时保持任务隔离。RELEASED不是业务成功状态而是资源状态。任务可以是COMPLETED、CANCELLED或FAILED最后都必须进入RELEASED。把这两个维度拆开以后日志能明确区分“任务失败但资源已回收”和“结果成功但句柄仍泄漏”排查不再依赖猜测。实际联调里还有一个容易被平均值遮住的问题回调 p95 达到 23 ms 时平均耗时仍只有 8 ms。原因不是分词本身变慢而是主线程在切换页面动画期间短暂来不及取队列。于是监控同时记录队列深度、入队时间和消费时间超过阈值只合并进度事件token 数据仍按顺序完整交付。这样既没有用丢数据换流畅也能判断瓶颈究竟在 Rust 计算、跨线程投递还是 ArkTS 消费。正式产品还应把阈值做成设备分级参数低内存设备缩小队列并提前施加背压而不是等到内存告警后再粗暴取消整个任务。六、手机页展示的是一次完整会话运行页没有把 128,460 个 token 堆出来只展示本次输入、chunk 进度、回调延迟、取消响应、峰值内存和 handle 收口。点击“取消测试”会启动一份短任务并在固定阶段发出 cancel结果只用于验证生命周期不污染正常索引。11:18 的手机页与正文一致任务tokenize_20261001_05文件 1.8 MB64/64 chunk128,460 tokenp95 23 ms取消 7 ms峰值 46 MBlive handles 0最终RELEASED。红色批注只指向“取消响应 7 ms”和“句柄已归零”正好对应这次工程改造的两条验收线。七、三方库接进来以后生命周期就是接口的一部分这次问题不是 Rust 不安全也不是 Node-API 太复杂而是第一版接口只设计了输入和结果没有设计取消、背压、晚到回调和释放。跨语言以后默认析构时机更难推断任何“系统会帮我回收”的假设都会在异常路径暴露。正式项目还要处理词典版本切换、ABI 兼容、符号裁剪和 native 崩溃诊断如果 chunk 包含敏感文本日志只记录数量与摘要不打印内容。发布构建应保留必要符号表并在多 ABI 设备上验证动态库加载。性能优化不能以牺牲取消响应为代价背压也不能把工作线程永久阻塞。版本升级时新旧liblexibridge.so不能共享未声明的内存布局。ArkTS 先查询 native 的 ABI version 和 feature flags不匹配就拒绝启动并降级到纯 ArkTS 分词而不是冒险调用。第三方库升级也要跑成功、取消、异常与释放四套基线因为最容易回归的往往不是分词结果而是 finalize 时机和错误码映射。LexiBridge Lab最终形成了一条可解释链路Rust 负责计算与取消检查Node-API 负责线程切换和队列收口ArkTS 负责会话代次与页面生命周期。64 个 chunk 全部完成只是功能结果live handles 回到 0才算这次调用真正结束。参考资料HarmonyOS Node-API 开发指导HarmonyOS Native C API 参考HarmonyOS Transferable 对象说明
返回列表