news 2026/10/2 5:56:40

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 任务停止继续生产;页面销毁后,即使晚到回调仍在队列里,也不能再写 UI,NativeHandle 还必须释放一次且只释放一次。

一、最开始只有一个“快”的 Demo

第三方分词库是 Rust 写的,词典加载和批量切词都很快。第一版桥接直接暴露tokenize(text),ArkTS 传入整段文本,Rust 返回完整数组。小样本没有问题,换到 1.8 MB 文件后,主线程需要等待巨大结果对象创建,内存峰值也明显上升。

我们随后改成流式回调:native 每生成一批 token 就把 chunk 推给 ArkTS,页面更新进度并写入本地索引。吞吐改善以后,新的问题出现了。页面退出只是把isLoading改成 false,Rust 线程并不知道;它继续切词并投递回调。下一次进入页面时,旧任务与新任务共用同一个进度接收器,数字会突然从 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:Arc<AtomicBool>,tokenizer:Arc<Tokenizer>,}implTokenizeJob{pubfnrun<F>(&self,chunks:Vec<String>,mutemit:F)->Result<usize,LexiError>whereF:FnMut(usize,Vec<Token>)->Result<(),LexiError>{letmuttotal=0usize;for(index,chunk)inchunks.into_iter().enumerate(){ifself.cancel.load(Ordering::Acquire){returnErr(LexiError::Cancelled);}lettokens=self.tokenizer.tokenize(&chunk)?;total+=tokens.len();emit(index+1,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_ptr<TokenizeJob>job;napi_threadsafe_function tsfn{nullptr};std::atomic<bool>closing{false};};voidEmitChunk(BridgeContext*ctx,ChunkResult chunk){if(ctx->closing.load(std::memory_order_acquire))return;auto*heapChunk=newChunkResult(std::move(chunk));napi_status status=napi_call_threadsafe_function(ctx->tsfn,heapChunk,napi_tsfn_nonblocking);if(status!=napi_ok)deleteheapChunk;}voidFinalizeBridge(napi_env,void*data,void*){auto*ctx=static_cast<BridgeContext*>(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)保证多个关闭来源里只有一个真正执行 release:ArkTS 主动 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 生成新的 generation,dispose 先使代次失效,再通知 native 取消。

exportclassTokenizerSession{privategeneration:number=0privatehandle:number=0privatedisposed:boolean=falsestart(path:string,onChunk:(chunk:TokenChunk)=>void):void{constcurrent=++this.generationthis.disposed=falsethis.handle=lexiNative.tokenizeStream(path,(chunk:TokenChunk)=>{if(this.disposed||current!==this.generation)returnonChunk(chunk)})}dispose():void{if(this.disposed)returnthis.disposed=true++this.generationif(this.handle!==0){lexiNative.cancel(this.handle)lexiNative.release(this.handle)this.handle=0}}}

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 保持 0,dispose 仍可安全执行。这个顺序避免了“ArkTS 认为任务已开始,native 实际没有句柄”的半初始化状态。

DevEco Studio 图中,左侧目录分为native / bridge / session / model,中间打开TokenizerSession.ets,右侧模拟器显示LexiBridge Lab。底部 HiLog 依次给出job=tokenize_20261001_05、chunks=64/64、tokens=128460、p95=23ms、cancel=7ms、peak=46MB、liveHandles=0 state=RELEASED。这组数据把性能与释放放在同一条链里,不再只看“跑得快”。

五、异常测试比成功跑完更有价值

我给桥接层做了四组破坏性测试。第一组在第 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 不影响 B,B 的回调也不能误用 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 MB,64/64 chunk,128,460 token,p95 23 ms,取消 7 ms,峰值 46 MB,live 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 对象说明
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 5:56:26

聚信天下(DAV数字音视工程网)海外GEO口碑好吗

深圳市聚信天下网络科技有限公司&#xff0c;简称聚信天下(DAV数字音视工程网)&#xff0c;是一家深耕AI搜索优化与智能体交互领域的B2B出海增长服务商&#xff0c;核心业务聚焦于GEO全域推广系统与AI Agent企业智能体的研发与落地交付&#xff0c;致力于帮助全球出海企业在大模…

作者头像 李华
网站建设 2026/10/2 5:55:43

分析是否需要对已经关注用户设定50% 评论概率

如果有些简单的算法推送的关注用户占比>50%那么就必须要设置只有50%的对已经关注用户的评论概率&#xff0c;因为这会导致严重的资源浪费-----------------其实用不着------------因为我关注的人已经达到了几千人&#xff0c;例如5000人&#xff0c;而我每天的评论只有200个…

作者头像 李华
网站建设 2026/10/2 5:55:11

GitHub技能地图:从自我盘点到高效学习的完整路径

我最近整理年度学习计划&#xff0c;又把GitHub上一个叫 skills 的项目从头到尾过了一遍。这个项目没有炫目的技术栈&#xff0c;也没有精致的交互界面&#xff0c;本质上就是有人把"到底该学哪些东西"这个问题摊开&#xff0c;整理成了一个大而全的资源地图。面对满…

作者头像 李华