关于 ArkTS 异步任务泄漏的一次机制溯源

——从"闭包持有 this"这个流行解释,追溯到协程栈帧锚定


引言:一个被传抄了太多次的解释

打开任何一篇讲 ArkTS 内存泄漏的中文技术文章,你几乎都会看到同一个句式:

“闭包形成了循环引用,导致 GC 无法回收,于是内存泄漏。”

然后配一张 A 持有 B、B 持有 A 的图,再加一句"要把引用置为 null"。

这个解释听起来自洽,传播得也足够广,但它在 ArkTS 的运行时模型里是错的——不是细节不准,而是因果搞反了。

本文要做的事只有一件:把"为什么错"和"错的背后真正发生了什么"讲清楚。因为一旦这个前提是错的,后面所有的修复手段都会失焦——你会花时间去"断引用",而真正需要处理的东西根本没有被触碰。


第一章 一个被忽略的前提:循环引用在 ArkTS 里不会泄漏

1.1 两种 GC 的路线分歧

垃圾回收有两条经典路线,它们对"循环引用"的态度完全相反。

引用计数(Reference Counting):给每个对象记一个"被引用次数",归零即回收。它的致命缺陷正是循环引用——parent.child = child; child.parent = parent; 的两个对象,引用计数各为 1,永远不会归零,即使外部已经完全不用它们了。

对象追踪(Tracing GC):从一组"根对象"出发遍历引用图,能到达的标记为存活,其余全部回收。它天然免疫循环引用——因为判断标准是"能否从根到达",而不是"自己有没有人指着"。

官方在讲 GC 算法选型时,写得非常直接:

由于引用计数存在内存泄漏问题,ArkTS 运行时选择基于**对象追踪(即 Tracing GC)**算法设计 GC。

而《开发态快速定位 ArkTS 泄漏》开篇也把机制交代得很清楚:

ArkTS 运行时采用 HPP GC(即高性能部分垃圾回收),将对象按生命周期划分为新生代和老年代。使用标记-清除(mark-and-sweep)算法回收内存,可达对象被标记为存活,其余对象将被回收。

结论:ArkTS 用的是对象追踪。所以纯粹的循环引用,在 ArkTS 里根本不是泄漏。

// 这段代码不会泄漏
class A { b: B | null = null }
class B { a: A | null = null }

function createCycle(): void {
  const a = new A();
  const b = new B();
  a.b = b;
  b.a = a;
  // 函数退栈 → a、b 从任何 Root 都不可达
  // 下一轮 GC 时,a 和 b 会被**一起**回收
}

从 C++、Python(引用计数派系)转过来的开发者,会本能地认为"这个环是隐患"。在 ArkTS 里不是。 环的两端同时不可达时,GC 会把它们作为一个整体回收掉。

1.2 那么,为什么大家都说有环就泄漏?

因为漏掉了一个词:根。

回看官方文档里那句被反复引用、却很少被真正读懂的话:

GC 仅回收那些从 GC Root 不可达的对象。 换言之,只要一个对象仍处于引用树的路径之上,即便它已被程序逻辑遗忘、不再被实际需要,GC 也无力将其回收。内存泄漏的本质不是 GC 失效,而是开发者留下了不该留的引用链。

注意最后这半句——“不该留的引用链”。它说的不是"环",而是"链"。链必须有起点,那个起点就是根。

于是可以给出一个精确得多的判据:

结构是否泄漏原因
纯环(A↔B,无外部引用)❌ 不泄漏环整体从 Root 不可达,一起回收
有根的环(Root → A → B → 闭包 → A)✅ 泄漏从 Root 可达,永不被回收
无根的长链❌ 不泄漏链头不可达,整条链一起回收

泄漏的充分条件是"根可达",不是"存在环"。 环只是一个让引用关系变得"看起来甩不掉"的形态,真正把对象钉在内存里的是那个根。

那些文章里画的图,其实画的是"有根的环"——只是他们省略了根,读者便误以为问题出在环上。于是产生了一个流传极广的错误动作:去断环。

而断环在 ArkTS 里往往无效,因为——

只要根还在,断掉环的一端,GC 依然能从根走到底。

打个比方:环像是一根拴住船的缆绳打成的结。你解不开的是船和岸之间的缆绳,不是绳上的那个结。 把结解开(断环),缆绳依然连着,船依然走不了。

1.3 这个纠正带来什么实际差别

差别很大,因为它改变了你的排查起点。

错误前提下的排查正确前提下的排查
“哪里形成了循环引用?”“谁在引用它?”
逐个 xxx = null 去断环顺着引用链向上找根
关注对象之间的相互指向关注从根出发的路径

所以正确的第一个动作不是"断引用",而是把引用链打印出来,看它的顶端是什么。

而这一步,官方在文档里已经给出了标准动作:

在 Snapshot 快照的对象引用链中找到异常存活对象(如本该销毁的 Component 实例),通过 “Shortest Paths” 分析引用链情况。

看顶端是什么——这正是下一章要讲的坐标系。


第二章 真正的坐标系:三类 GC Root

2.1 为什么必须按"根的类型"分类

既然泄漏的判据是"从某类根可达",那么根的类型就直接决定了修复手段。

不同类型的根,需要完全不同的动作去切断:

  • 如果根是全局单例 → 你需要清理那个单例
  • 如果根是 Native 句柄 → 你需要补一个释放调用
  • 如果根是栈帧 → 断引用完全没用,你需要让函数退栈

这就是官方给出 GC Root 分类的真正价值——它不是知识点的罗列,而是一张"按根类型选修复手段"的决策表。

2.2 三类根,三种病,三种药

官方文档把 ArkTS 的泄漏场景明确归为三类:

#根的类型典型成因修复动作引用链特征
①VMRoot模块导出对象、globalThis.xxx、修改内置原型链清理静态属性 / 全局变量顶端为 VMRoot / SourceTextModule
②Local / Global HandleNAPI 的 napi_value / napi_ref 未成对释放补 napi_close_handle_scope / napi_delete_referenceDistance = 1(被根直接持有)
③FrameRoot函数不退栈、闭包捕获栈帧变量被外部长期持有让函数退栈 / 切断外部持有既非 VMRoot,也非 Handle

这张表最有价值的一栏是"引用链特征" —— 它把"看起来都一样"的内存泄漏,变成了一次看图判断:

看引用链顶端是 VMRoot ?        → ① 全局/模块问题
看泄漏对象 Distance 是否为 1 ?  → ② Native 句柄问题
两者都不是 ?                   → ③ 栈帧问题

2.3 为什么 ③ 是异步泄漏的主战场

前面两类(全局单例、NAPI 句柄)都是"对象被某个长期存在的东西指着",符合大家对"引用"的直觉。

但第三类不是。官方对 FrameRoot 的定义是:

FrameRoot 是函数调用栈帧在 GC 遍历过程中的根节点。 当函数被调用时,其局部变量和入参对象会被当前栈帧引用,从而成为 GC 的"可达"起点。

关键在这句:

然而,若函数长期不退栈,局部变量 / 参数所引用的对象将持续被 FrameRoot 锚定,即便业务逻辑已不再需要它们,GC 也无法回收。

也就是说——这些对象不是"被谁引用了",而是"所在的那个函数还没结束"。

这一句话,把异步泄漏的性质彻底改变了:

常见理解实际机制
回调闭包持有 this,形成引用函数没退栈,栈帧里的所有局部变量被整体锚定
修复 = 断开引用修复 = 让函数尽快结束

请注意这个差别带来的后果:

栈帧锚定是"整体"的,不是"逐条"的。 一个挂着不结束的函数里,所有局部变量、所有入参、以及它们能间接到达的整个对象子图,都活着。你手工去 xxx = null 断掉其中一个,等于在一间被焊死的房子里挪走一把椅子——房子还在,人还是出不去。

这就是为什么"断引用"在异步泄漏上经常无效。

而官方列出的四种"函数不退栈"的情形里,第四条正是我们关心的:

常见导致栈帧滞留的情形包括:

  • 死循环或无限递归,函数永不返回;
  • 在函数内启动了一个长期运行的同步阻塞操作(如同步网络请求、大文件同步读写);
  • 函数内部创建了闭包并被外部长期持有,且闭包捕获了该函数栈帧中的变量(导致整个栈帧无法释放);
  • 使用了生成器(Generator)或 async/await 但未正确消费,导致协程挂起,栈帧保留。

最后一条是本文的核心。它需要我们重新理解一件事——

在 ArkTS 里,async 函数不是一个"回调注册",它是一个协程。


第三章 async 不是回调,是协程

3.1 一个被简化掉的模型

绝大多数教程把 async/await 解释成"Promise 的语法糖"——从语法层面看没错,但从运行时层面看,这个说法丢掉了一个关键事实:

await 会让函数"挂起",而挂起的函数,它的栈帧是"保留"的,不是"销毁"的。

普通函数调用是这样的:

调用 f() → 建立栈帧 → 执行 → 返回 → 栈帧销毁 → 局部变量失去 FrameRoot 锚定 → 可回收

而带 await 的异步函数是这样的:

调用 f() → 建立栈帧 → 执行 → 遇到 await → 挂起(栈帧保留!)→ ... → 恢复 → 执行完 → 栈帧销毁
                    ↑
              这中间的时间,栈帧里的所有东西都被 FrameRoot 锚定

挂起期间,栈帧没有销毁。 官方原文用的词是"栈帧保留"。

这解释了一个很多人碰到过的现象:一个 await 了慢接口的函数,即使你早就"不需要"它了,它捕获的那些大对象在接口返回前一直活着。

3.2 官方那条"反直觉建议"的真正原因

现在可以解释那条被无数人困惑的建议了。

官方在讲自定义组件生命周期时明确说:

如果在生命周期的 aboutToDisappear 使用异步操作(Promise 或者回调方法),自定义组件将被保留在 Promise 的闭包中,直到回调方法被执行完,这个行为阻止了自定义组件的垃圾回收。

流行的解读是:“因为闭包捕获了 this,形成了引用。”

这个解读方向对了,但层次浅了。它的表述方式会让你以为这是个"引用问题",于是你会去"断引用"。但真正的机制在更下面一层:

异步函数挂起时,栈帧保留。而那个栈帧里的 this(指向组件实例)是被 FrameRoot 直接锚定的。

换句话说,泄漏的不是"this 被闭包指着",而是"this 待在一个不肯结束的函数栈帧里"。

这个区别很关键,因为它决定了修复动作:

如果病因是"闭包引用"如果病因是"栈帧锚定"
断掉引用即可必须让函数结束
可以在别处补置 null无法从外部干预,只能不写(或让它尽快返回)

而"不写"恰恰就是官方的建议——不要在销毁回调里写 async。官方给的替代动作是"只做同步清理;需要异步收尾的,把数据传给独立服务,不要传 this"。

现在你明白这句话为什么这么说了:把数据传出去,等于把清理逻辑搬到一个不锚定组件的栈帧里执行。 那个独立的服务结束时干净退栈,你的组件本来就已经自由了。

3.3 更硬的一层证据:协程的生命周期规范

如果你觉得"栈帧保留"还只是个形象说法,那么 ArkTS 并发规范的原文会把它钉死:

For async functions lifetime is limited to the lifetime of root coroutine for coroutine from which this async function created. When root coroutine is finished - it is no guarantee that any code will be executed in async function.

(async 函数的生命周期受限于创建它的根协程的生命周期。当根协程结束时,不保证 async 函数中任何代码会被执行。)

这句话透露了两件事:

  1. async 函数挂在协程树上的——每个协程都有父协程,最终追溯到根协程。这不是"语法糖",这是有管辖关系的运行时对象。
  2. 根协程一结束,内部代码就不保证执行了——这等于官方承认了:协程的存活范围由外部界定,语言本身不给你精细的控制权。

顺带一提,规范里还定义了域的划分(Main / EA / General),以及不同协程类型(Jcoroutine / Acoroutine / AJcoroutine)的调度亲和性。这套体系的存在本身就在说明:ArkTS 的异步不是"回调",而是一套和线程、域绑定的调度模型。

3.4 和主流平台比,ArkTS 缺了哪一环

把三个平台放在一起看,问题会变得非常清楚。

平台异步并发模型取消机制生命周期绑定
Kotlin协程CoroutineScope.cancel()语言机制(CoroutineScope)
Swift结构化并发Task.cancel()语言机制(Task 树)
ArkTS协程 + TaskPool无内建取消(async/await 层面)无

Kotlin 里你写 scope.launch { ... },scope 被销毁时子协程自动取消——这是结构化并发的定义性特征:父作用域负责回收子任务。

ArkTS 呢?官方文档里老老实实列出了一堆"不会随页面销毁而自动停止"的东西:

HTTP 请求、Promise.then/catch、setTimeout/setInterval、Worker、TaskPool、事件监听、WebSocket。

语言层没有给你 CoroutineScope。 async/await 发起后,它是一个"自由"的协程,挂在协程树上,但没有任何人负责在页面销毁时把它摘下来。

而且,唯一的显式取消手段是有边界的。官方对 taskpool.cancel 的说明:

When the task is in the taskpool waiting queue, after the task is canceled, it will no longer be executed… When the task is already being executed in a taskpool worker thread, canceling the task does not affect the continued execution of the task.

翻译过来:

  • 任务在排队中 → 取消立刻生效
  • 任务已经在跑 → 取消不影响它继续执行

原因也不难理解:TaskPool 是内存隔离的(Actor 模型),强杀一个正在操作自己内存的线程,会留下不可预测的破损状态。系统宁可让它跑完,也不肯冒险中断。

于是我们在 ArkTS 里面对的局面是:

发起:随便发          ← 没有作用域约束
取消:语言层没有      ← 只有 TaskPool 的有限取消
兜底:没有            ← 只能自己建

这就是"三层防线"存在的根本原因——它不是一套最佳实践锦囊,它是在给一个缺失的能力做补丁。


第四章 三层防线的本质:重建责任归属

4.1 换个角度看那三层

通常"三层防线"被介绍成三个技术手段。但如果把它们放在"ArkTS 缺失责任模型"这个背景里,你会发现它们各自在补一个具体的缺失位:

层技术手段它在补什么
① 主动取消AbortController / clearTimeout / task.cancel() / terminate()补"终止权"——让任务有机会被提前叫停
② 标志位兜底isActive 校验补"接受权"——决定结果还要不要采纳
③ 契约约束统一封装基类补"归属权"——让每个任务都有明确的责任人

三层不是"三重保险"(重复做同一件事),而是三个不同的权能。少任何一层,都会留下一个具体的能力空洞。

4.2 为什么"标志位"在 ArkTS 里不是可选项

对照第 3.4 节的结论:既然 cancel 对"已在执行"的任务无效,那么——

“取消"本质上是"尽力而为”,不是"保证成功"。

一个 cancel() 返回了,不代表你的回调不会被执行。它只是"可能不会被执行"。

于是必须有一个与取消机制正交的、绝对可靠的判断点。这就是标志位:

async load(): Promise<void> {
  const data = await fetchData();
  // 回来第一件事:问"我还该管这个结果吗"
  if (!this.isActive) { return; }
  this.data = data;
}

这个 if 看起来朴素,但它承担了一个重要职责:它是唯一不依赖"取消是否成功"的保证。

从"栈帧锚定"的角度看,它还有第二重意义——它让函数尽快走到 return,从而尽快退栈。 这不只是"不写脏数据",它同时缩短了栈帧的存活时间。

两层收益:一层是数据正确性,一层是内存。

这也解释了为什么标志位要放在回来的第一行,而不是放在赋值之前随便某个位置:越早 return,栈帧越早销毁。

4.3 "任务归属"才是那个真正的缺口

把三层合起来看,它们其实在回答同一个问题:

这个任务,归谁管?什么时候算结束?

  • 取消:谁有权叫停它
  • 标志位:谁有权接受它的结果
  • 契约:谁负责给它建立上述两件事

而这正是 ArkTS 语言层没有回答的问题。Kotlin 用 CoroutineScope 回答了,Swift 用 Task 树回答了,ArkTS 把这个问题留给了开发者。

所以,抽象到方法论层面——

在 ArkTS 里写异步代码,实质工作不是"写异步逻辑",而是"给每个异步任务指定一个负责人"。

一个可操作的自检清单:

检查项问题
发起点这个任务由哪个对象发起?它的生命周期是多长?
终止权发起者销毁时,谁去叫停这个任务?靠什么机制?
接受权任务返回时,用什么判断"结果是否还该生效"?
退栈最坏情况下,这个函数多久能走到 return?

第四条最容易被忽略,但它是 FrameRoot 类泄漏的唯一出口。


第五章 两个必须纠正的流行说法

5.1 “用 WeakRef 打破循环引用防泄漏”——在 ArkTS 里是误诊

大量文章推荐用 WeakRef / WeakMap 来"打破循环引用,防止内存泄漏"。

这个建议的前提(环会泄漏)在 ArkTS 里不成立——第 1.1 节已经论证过。既然纯环本身不泄漏,那"打破环"就没有解决任何问题。

⚠️ 顺带澄清一处矛盾:网上有文章称"ArkTS 严格模式不支持 WeakRef/WeakMap/WeakSet",这与官方白皮书不符。官方在讲强弱引用时明确列出:

JS 实现:WeakMap / WeakSet(ES6);WeakRef(ES2021) 直接创建对单个对象的弱引用。使用 .deref() 方法获取原对象。

也就是说,这三者都是官方承认的能力。以本地 SDK 的 .d.ts 为准即可。

那 WeakRef 在 ArkTS 里还有用吗?有,但用途不同。

它的真正价值在于第 1.2 节的那个判据——它防止的是"建立新的根可达路径",而不是"打破环"。

典型场景是"长期存在的容器索引短生命周期对象":

// ❌ 全局缓存表成了一个"根"
const cache = new Map<string, Component>();

// ✅ 弱引用:容器不会阻止对象被回收
const cache = new WeakMap<object, Component>();

Map 强引用键值,于是这个全局 cache 就成了一个根——凡是被它存过的组件,都再也不会被回收。这才是泄漏。而 WeakMap 的键是弱引用,“键被回收后,整条记录自动消失”。

结论:WeakRef 的正确定位是"避免让容器变成根",而不是"打破环"。 前者是真实需求,后者在 ArkTS 里是个伪命题。

5.2 "循环引用"这个说法本身该被淘汰

更彻底一点:在 ArkTS 语境下,"循环引用导致内存泄漏"这个说法应该停止使用。

它把两个不同的事实压缩成了一个错误的因果:

说法实际
“循环引用导致泄漏”循环引用只是形态,不构成泄漏
——真正的病因是"从根可达"

替换成一个准确的表述:

一个不可达的环,是垃圾;一个可达的环,是泄漏。

差别只在那个"根"。

用这个表述去检查你的代码,排查动作会立刻变得有方向:不是"哪有环",而是"谁在引用它,那个人的生命周期合理吗"。


结语:当"断引用"无效时,说明你找错了层次

回到最初那个流行解释。它错不在"提到了闭包和引用"——那些确实存在;它错在把两个层次混成了一个。

现象层:  对象无法回收
         ↓
引用层:  闭包持有 this        ← 流行解释停在这里,于是建议"断引用"
         ↓
栈帧层:  挂起的协程不销毁栈帧  ← 真正的机制,修复动作是"让函数结束"
         ↓
模型层:  ArkTS 没有作用域约束  ← 根本缺口,需要自建责任模型

每往下一层,修复手段就完全不同:

  • 停在引用层 → 你会去 xxx = null,在异步泄漏上常常无效
  • 看清栈帧层 → 你会去让函数尽早 return,这才对得上病
  • 理解模型层 → 你会去建封装基类、统一治理,这才是治本

而三层防线的意义也在这里浮现出来:它不是三个技巧的堆叠,它是把缺失的第四层——“任务责任模型”——手工补上。

所以,下次面对"越用越卡"时,与其继续在代码里找环,不如先做这两件事:

  1. 打开 Snapshot,看那个异常存活对象的最短引用链,顶端是什么 —— 是 VMRoot、Handle,还是都不是?
  2. 如果不是前两类,就别再断引用了 —— 去看那个函数为什么还没结束。

内存泄漏的本质,从来不是"引用没断",而是"该结束的还没结束"。


附:核心结论速查

#结论依据
1ArkTS 用 Tracing GC,纯循环引用不会泄漏官方:「引用计数存在内存泄漏问题,ArkTS 运行时选择基于对象追踪算法」
2泄漏的充分条件是根可达,不是存在环官方:「GC 仅回收那些从 GC Root 不可达的对象」
3GC Root 分三类:VMRoot / Handle / FrameRoot官方《开发态快速定位 ArkTS 泄漏》
4看引用链顶端即可判断病型,对应三种修复手段同上:VMRoot / Distance=1 / 其余
5FrameRoot 是异步泄漏主战场,栈帧锚定是整体的官方:「若函数长期不退栈…GC 也无法回收」
6await 使函数挂起,栈帧保留而非销毁官方:「async/await 但未正确消费,导致协程挂起,栈帧保留」
7async 函数是协程,生命周期绑根协程ArkTS 并发规范 §1.2.5
8ArkTS 缺 CoroutineScope,取消机制有边界官方列出"不会自动停止"的清单;cancel 对已执行任务无效
9三层防线的本质是补终止权 / 接受权 / 归属权本文推导
10WeakRef 的用途是"避免容器成为根",不是"打破环"官方强弱引用说明

参考文档

文档用途
开发态快速定位 ArkTS 泄漏本文核心依据:GC Root 三分类、FrameRoot 定义、栈帧滞留四情形、标准化排查流程
GC 垃圾回收(HPP GC)分代模型、混合算法、GC 触发阈值
鸿蒙编程语言白皮书 · GC 机制强引用 / 弱引用、WeakMap / WeakSet / WeakRef 能力
ArkTS Concurrency Specificationasync 函数生命周期、协程域与类型、根协程
@ohos.taskpool API 参考cancel 的能力边界、isCanceled 检查点
自定义组件生命周期aboutToDisappear 中不建议使用 async 的原文依据

本文为 ArkTS 内存泄漏系列的机制溯源篇。配套实现见同目录 LifecycleSafeAsync.ets 与 README.md。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐