Duktape 内存管理深度解析:分配函数、引用计数与标记-清除回收算法

  • 语言运行时
  • 嵌入式
  • 解释器

【免费下载链接】duktape

Duktape - embeddable Javascript engine with a focus on portability and compact footprint

项目地址: https://gitcode.com/gh_mirrors/du/duktape
点击查看 免费下载

导读

Duktape 是一款以可移植性和紧凑体积为设计重点的可嵌入 JavaScript 引擎,其内存管理机制直接决定了它在嵌入式设备上的可用性。本文以 doc/memory-management.rst 为主体,系统梳理 Duktape 内存管理的三大支柱——分配函数、堆元素跟踪、引用计数与标记-清除(mark-and-sweep)垃圾回收,并结合 src-input/duk_heaphdr.h、src-input/duk_heap.h 等源码与 config/config-options 下的构建选项,讲解三种可选内存管理模型、DUK_ALLOC/DUK_REALLOC_INDIRECT 等内部宏的微妙行为、字符串驻留表、终结器(finalizer)语义以及 GC 触发策略。读完本文,你将理解 Duktape 内存从分配到回收的完整生命周期,并掌握为嵌入式环境选择与调优内存管理方案的实战依据。

一、内存管理的总体架构

1.1 三个基本概念

Duktape 的内存管理建立在以下三个基本概念之上:

  1. 分配函数(Allocation functions):用户提供一组用于分配、重分配、释放内存块的函数。这些"原始"函数可直接使用,但实现同时也提供外部行为相同、却在分配因内存不足而失败时强制触发一次垃圾回收的变体。两类变体在引擎内部都会被使用,外部代码也可调用。
  2. 堆元素跟踪(Heap element tracking):真正的内存管理发生在堆(heap)层面。堆元素在分配后被跟踪,从而让不可达元素能够被引用计数或标记-清除垃圾回收释放。释放堆时,无论引用计数或可达性如何,所有相关分配都会被释放。
  3. 引用计数与标记-清除(Reference counting and mark-and-sweep):这两个算法用于判定哪些堆元素可以被释放。当元素即将被引用计数或标记-清除释放时,可能会执行一个终结器方法。

1.2 三种内存管理模型

在构建期间需要从三种基本的内存管理模型中选择一种(对应 config/config-options/DUK_USE_REFERENCE_COUNTING.yaml 与 config/config-options/DUK_USE_MARK_AND_SWEEP.yaml):

模型引用环回收特点
引用计数 + 标记-清除有默认组合,兼顾内存波动与循环回收,见 DUK_USE_REFERENCE_COUNTING(默认 true)
仅引用计数无减小代码体积、消除 GC 停顿,但不可达的引用环直到堆销毁才会被回收;函数实例因默认与其原型对象构成引用环而不会被回收,除非手动断开循环,见 DUK_USE_MARK_AND_SWEEP(该选项在 Duktape 2.0 中已移除,标记-清除不再可选)
仅标记-清除有能回收引用环,但两次回收之间的内存占用波动较大

从 DUK_USE_MARK_AND_SWEEP.yaml 的注释可以确认:当只使用引用计数时,对象只有在手动断开引用环后才会被回收,且总是在堆销毁时被释放。这一约束意味着"仅引用计数"模型更适合内存极紧张、且脚本生命周期短的场景。

1.3 实现层面的硬性要求

在高层次上,实现代码必须保证:只要引用关系发生变化,引用计数与堆元素可达性就要被一致且正确地更新。特别是,每当一个操作可能导致 longjmp() 或触发垃圾回收时,可达性与引用计数必须已经正确。这在实践中非常棘手,因此 Duktape 提供了一个 "GC torture" 编译选项(config/config-options/DUK_USE_GC_TORTURE.yaml,默认 false)用于在每次分配及其它选定位置强制完整标记-清除,以压力测试内存管理、抖出相关 bug。该选项的说明还建议配合较小的 DUK_USE_MARK_AND_SWEEP_RECLIMIT(如 3)使用。

二、关键术语

2.1 堆元素(Heap element)

"堆分配元素"或"堆元素"泛指所有被自动跟踪的内存分配。之所以不叫"堆分配对象",是为了避免与 ECMAScript 中"对象"的概念混淆——所有 ECMAScript 对象都是堆元素,但堆元素还有其他类型。受内存管理约束的堆分配元素有:

  • duk_hstring
  • duk_hobject 及其子类型
  • duk_hbuffer

只有 duk_hobject 包含指向其它堆元素的内部引用,这些引用保存在对象属性表和对象内部原型指针中。目前也只有 duk_hobject 及其子类型可以拥有终结器。

堆元素具有稳定指针(stable pointer):主堆元素在其生命周期内不会被搬移;被堆元素引用的辅助分配(如对象属性表)可以被重分配/搬移。

2.2 引用(Reference)

从源堆元素指向目标堆元素的指针。创建引用时必须使目标引用计数加一,移除引用时减一。目前只有 duk_hobject 包含引用,要么通过对象属性(键和值),要么通过对象内部原型引用。

2.3 借用引用(Borrowed reference)

指向目标堆元素、但不反映在目标引用计数中的指针。只有当"某处确实存在一个真实引用"这一前提能被保证时,才可使用借用引用;若无法保证,产生的 bug 将极难诊断。

2.4 弱引用(Weak reference)

同样不反映在目标引用计数中的指针。即使没有其它引用指向目标,弱引用也可以存在,且不阻止目标被回收;但目标一旦被回收,弱引用必须被删除以避免悬垂指针。目前没有用户可见的弱引用支持,但存在内部弱引用,例如"字符串访问缓存"(string access cache)弱引用堆字符串,字符串被回收时必须更新该缓存(对应 src-input/duk_heap.h 中的 duk_strcache_entry,其 h 字段注释明确标注为 weak pointer)。

2.5 终结器(Finalizer)

存放在堆中的对象(duk_hobject 及其子类型)可以有终结器,在对象即将被释放时调用,用于让用户代码释放与对象相关的原生资源(如关闭原生 socket、释放 Duktape 跟踪之外分配的内存)。终结器不是 E5 标准要求或支持的。它需要引用计数与标记-清除两套独立的实现机制,且这两套实现必须和平共存(对应构建选项 config/config-options/DUK_USE_FINALIZER_SUPPORT.yaml,默认 true)。

三、分配函数

3.1 原始函数(Raw functions)及其语义

创建 duk_heap 时,三个与内存分配相关的函数被关联到堆:alloc、realloc、free。相关 typedef 为:

typedef void *(*duk_alloc_function) (void *udata, size_t size);
typedef void *(*duk_realloc_function) (void *udata, void *ptr, size_t size);
typedef void (*duk_free_function) (void *udata, void *ptr);

这些函数的语义与 ANSI C 对应函数本质相同:

  • 零尺寸分配:alloc 和 realloc 返回 NULL 或某个非 NULL 的唯一指针值均可;无论返回什么,都必须能被 realloc 和 free 接受。
  • realloc(NULL, size) 等价于 malloc(size)。
  • realloc(ptr, 0) 等价于 free(ptr)(假设 ptr 非 NULL),并且必须返回 NULL 或某个可被 realloc/free 接受的非 NULL 唯一指针值。
  • free(NULL) 是空操作。

默认实现直接映射到对应的 ANSI C 函数(忽略 udata)。如果平台分配器不满足 ANSI C 要求,用户代码必须提供替换函数。

分配函数返回的内存必须正确对齐以支撑 Duktape 数据结构:在返回内存的开头必须能够存放 double 或 int64_t(从对齐角度而言)。这并不总是意味着 8 字节对齐:x86 通常没有对齐要求,ARM 上通常 4 字节对齐即可。即便非严格必需,一定的对齐通常也有利于性能。(严格来说这些对齐要求与 ANSI C 要求不同,尤其是分配尺寸小于 8 字节时,但对 Duktape 而言这些情况无关紧要。)

3.2 内部宏:raw 变体与 GC 触发变体

以下内部宏使用原始分配函数,不会触发垃圾回收或任何其它副作用:

  • DUK_ALLOC_RAW
  • DUK_REALLOC_RAW
  • DUK_FREE_RAW

它们的天然缺点是:即使触发垃圾回收后会有可用内存,分配/重分配也可能失败。

以下内部宏可能触发垃圾回收(即使并非严格内存不足):

  • DUK_ALLOC
  • DUK_ALLOC_ZEROED
  • DUK_REALLOC
  • DUK_REALLOC_INDIRECT
  • DUK_FREE

在 src-input/duk_heap.h 中可以看到这些宏的真实定义:DUK_ALLOC_RAW 等直接调用 heap->alloc_func,而 DUK_ALLOC 等则委托给 duk_heap_mem_alloc、duk_heap_mem_realloc、duk_heap_mem_realloc_indirect、duk_heap_mem_free 等带 GC 交互的实现函数。触发垃圾回收会带来广泛的副作用:如果执行了终结器,可能运行任意的 ECMAScript 甚至原生代码。

需要注意:

  • 即便用户代码在分配自动内存管理之外的缓冲区,通常也应优先使用触发 GC 的变体,这样内存压力才能在用户分配与 Duktape 管理的分配之间得到恰当传达。只有当触发垃圾回收会带来损害时才使用 raw 变体——这对用户代码而言很少见。
  • 由于非 raw 的内存分配/重分配可能触发垃圾回收,任何直接或间接分配内存的函数或宏调用都可能带此类副作用;任何直接或间接的"checked"内存分配还可能抛出内存不足错误(最终导致 longjmp())。

3.3 公共 API:直接使用分配函数

堆关联的内存分配函数也可通过公开 API 由用户代码调用,对例如需要临时缓冲区的 C 函数很有用。但注意这些分配不受自动管理,必须小心避免由用户代码及其调用函数中的错误(longjmp())引起的内存泄漏。

  • raw API 调用行为上基本是注册到堆的内存管理函数的直接包装。
  • 提供垃圾回收的 API 调用是 unchecked 的,出错时直接返回 NULL——只有垃圾回收后仍无法满足分配请求才返回 NULL。除致命错误外,这些 API 保证返回,并会隐藏终结器抛出的错误等。

公共 API 原型:

/* no garbage collection */
void *duk_alloc_raw(duk_context *ctx, size_t size);
void duk_free_raw(duk_context *ctx, void *ptr);
void *duk_realloc_raw(duk_context *ctx, void *ptr, size_t size);

/* may cause garbage collection, doesn't longjmp() */
void *duk_alloc(duk_context *ctx, size_t size);
void duk_free(duk_context *ctx, void *ptr);
void *duk_realloc(duk_context *ctx, void *ptr, size_t size);

另一个可能更稳健的选择是向值栈压入 buffer 对象:缓冲区会被自动内存管理;若是固定大小的缓冲区,分配后可获得稳定指针并在用户代码中随意传递而无需进一步检查,只要缓冲区仍可达(即未被推出值栈、也未存于任何可达对象或变量中)它就一直有效。

3.4 DUK_REALLOC() 与标记-清除的陷阱:DUK_REALLOC_INDIRECT()

使用 DUK_REALLOC() 有一个微妙陷阱:如果初次重分配失败,DUK_REALLOC() 实现会触发一次标记-清除,然后重试重分配。如果标记-清除可能影响正在被重分配的原始指针,那么第二次重试将使用无效的"原始指针"!

文档给出了重分配值栈(thr->valstack)的具体示例:

  1. 调用代码执行 DUK_REALLOC(thr, thr->valstack, new_size),此时 thr->valstack 的值为 P1。
  2. DUK_REALLOC() 实现尝试使用 raw realloc,以 P1 作为指针参数,该尝试失败。
  3. 触发标记-清除;标记-清除调用若干终结器方法,这些方法导致同一个值栈被调整大小,调整成功,thr->valstack 指针被更新为 P2。
  4. DUK_REALLOC() 实现重试 raw realloc,再次以 P1 作为指针参数。此时 P1 已是垃圾指针,realloc 调用为未定义行为。

第二次 realloc 的正确指针应是 P2,但宏背后的 helper 并不知道指针来自何处。文档曾考虑过一种朴素方案——用间接 realloc 函数接收被重分配指针的存储位置(如 (void **) &thr->valstack),每次重分配前重新读取当前指针。这在 GC 改变指针后依然正确,且堆头指针稳定、存储位置本身不会改变;但该方案存在 C 类型双关(type-punning)与严格别名(strict aliasing)问题,修复需要把所有基础指针改成 union,侵入性太强。

当前的解决方案:使用一个间接 realloc 函数,它接收一个带 userdata 指针的回调函数参数,回调用于请求被重分配指针的当前值。这虽然让代码变臃肿,却是严格别名兼容且最可移植的方式(对应 src-input/duk_heap.h 中的 typedef void *(*duk_mem_getptr)(duk_heap *heap, void *ud); 及 DUK_REALLOC_INDIRECT(heap, cb, ud, newsize) 宏)。

由此得到三条实践结论:

  • DUK_REALLOC_RAW() 可可靠地用于任何指针,但不保证成功(即使 GC 后存在可用内存)。
  • DUK_REALLOC() 只能可靠地用于保证不受标记-清除影响的指针——考虑到标记-清除会运行任意代码(包括作为对象终结化一部分的任意原生函数)。
  • 对跨标记-清除不稳定的指针,应使用 DUK_REALLOC_INDIRECT()(或 DUK_ALLOC() + DUK_FREE());此类指针的存储位置必须稳定,例如位于堆对象的主分配中。

四、堆结构(Heap structure)

4.1 总体布局

所有堆分配元素都必须记录在 duk_heap 中,要么作为字符串表的一部分(duk_hstring),要么作为"堆已分配"列表(heap allocated list)或临时工作队列的一部分。这样所有已分配元素总能被枚举并释放,无论其引用计数或可达性如何。

正在使用的堆元素必须具有正的引用计数,并且必须通过从 duk_heap 结构出发的实际可达性根可达。从 GC 视角看,它们构成实际的可达性图;任何被堆跟踪但不属于可达性图的都是垃圾、可被释放。假设引用计数正确,这类对象要么引用计数为零,要么属于引用环。

非字符串堆元素驻留在三类对象列表之一:

  • "heap allocated"(堆已分配)
  • "refzero work list"(引用计数归零工作列表)
  • "finalization work list"(终结化工作列表)

字符串则全部驻留在字符串表(string table)中,经字符串 intern 表指向各 duk_hstring;内置字符串(built-in strings)单独由 strs 数组引用;字符串访问缓存以弱引用方式指向字符串。

可达性图根包括:

  • 堆结构本身:heap_thread(内部线程,也用于部分终结化)、curr_thread(当前运行线程)、以及由线程引出的 builtins(内建对象)、对象属性、值栈、调用栈(duk_activations 链表及其 duk_catchers)、resumer(另一个 duk_hthread 或 NULL)。

需要特别指出:堆元素指针是稳定指针,而指向可调整大小位置(如值栈)的 duk_tval 指针可能失效;CPU 寄存器与 C 调用栈栈帧中还有指向堆分配与非堆分配内存的临时引用,这些引用必须极其小心地维护——一次 longjmp() 会展开 C 栈并丢失这些引用。

4.2 堆元素(Heap elements)与共享头结构

所有堆跟踪元素共享头结构 duk_heaphdr,定义于 src-input/duk_heaphdr.h。字符串使用更小的 duk_heaphdr_string 头,它是 duk_heaphdr 的前缀;区别在于 duk_heaphdr_string 不包含维护堆已分配对象的单/双链表所需的 next/prev 链接——字符串始终保存在堆级字符串 intern 表中,因此可以通过 intern 哈希表枚举(与可达性无关)。

duk_heaphdr 的关键字段(以配置项为条件编译):

  • h_flags:堆类型(HTYPE)字段 + 标志位;标志位包括 DUK_HEAPHDR_FLAG_TEMPROOT、DUK_HEAPHDR_FLAG_REACHABLE、DUK_HEAPHDR_FLAG_FINALIZABLE、DUK_HEAPHDR_FLAG_FINALIZED、DUK_HEAPHDR_FLAG_READONLY。
  • h_refcount:仅在 DUK_USE_REFERENCE_COUNTING 下存在;宽度由 DUK_USE_REFCOUNT16(低内存 16 位)、DUK_USE_REFCOUNT32(默认,32 位)或 size_t 决定。头文件注释说明:默认 32 位字段在 64 位系统上理论上有回绕风险,但假设 16 字节 duk_tval,Duktape 堆需大于 64GB 才会真正回绕(对应 config/config-options/DUK_USE_REFCOUNT32.yaml)。
  • h_next/h_prev:链表指针;DUK_USE_DOUBLE_LINKED_HEAP(默认 true)下才有 prev,且引用计数开启时必须使用双链表(见 config/config-options/DUK_USE_DOUBLE_LINKED_HEAP.yaml 的注释:"Required when reference counting is enabled")。
  • 低内存模式下可用 DUK_USE_HEAPPTR16 压缩指针。

按 duk_heaphdr.h 头文件注释:32 位平台上堆头大小在无引用计数时为 8 字节,开启引用计数时为 16 字节。

堆分配元素总是以固定尺寸分配,生命周期内从不 realloc(也从不搬移),从而保证所有堆元素都可用稳定指针指向。非固定部分(如对象属性表、动态缓冲区数据)单独分配、由主堆元素"拥有",主元素释放时自动释放。稳定指针的优点是简单且与现有分配器兼容;缺点是堆无法压缩,长期运行可能面临内存碎片问题,且分配时就必须知道固定部分的完整尺寸。

堆元素的类型通常由持有堆指针的 tagged value(duk_tval)或 C 结构体字段类型确定;但堆元素在 h_flags 中也有"堆类型"字段,通常不使用,却为引用计数等所需。当前具体堆元素类型:

  • duk_hstring(DUK_HTYPE_STRING):固定尺寸分配,头部后紧跟字符串数据;头只含 next 指针(用 duk_heaphdr_string);不引用其它堆元素。
  • duk_hobject(DUK_HTYPE_OBJECT):固定尺寸分配,大小取决于对象类型(duk_hobject、duk_hthread、duk_hcompfunc、duk_hnatfunc 等)。子类型可通过宏判定:DUK_HOBJECT_IS_COMPFUNC、DUK_HOBJECT_IS_NATFUNC、DUK_HOBJECT_IS_THREAD 等;均不成立则为普通对象。属性存于独立动态分配中并含指向其它堆元素的引用;duk_hcompfunc 的字节码、常量与内部函数引用存放在由 duk_hcompfunc 头引用的固定 duk_hbuffer 中;duk_hthread 的头引用值栈、调用栈等。
  • duk_hbuffer(DUK_HTYPE_BUFFER):固定缓冲区(DUK_HBUFFER_HAS_DYNAMIC() 为假)为头后紧跟数据的固定分配;动态缓冲区(DUK_HBUFFER_HAS_DYNAMIC() 为真)为头后紧跟当前缓冲区数据指针的固定分配,数据单独分配、可调整大小且地址在 resize 时可能改变;均不引用其它堆元素。

五、字符串表(String table)

5.1 字符串驻留(String interning)

所有字符串都被驻留到堆级字符串表:任一特定字符串在某一时刻只保存一份不可变副本。构造新字符串(如字符串拼接)时先检查该字符串是否已驻留:是则复用现有字符串,否则加入字符串表。无论哪种情况,字符串都通过 duk_hstring 指针表示,该指针在字符串生命周期内稳定。

字符串驻留的好处:

  • 驻留时可预计算并保存信息(如字符串哈希),供哈希表等复用;其它预计算(如数值转换)也可行(目前未使用)。
  • 字符串可直接用指针比较代替数据比较,因为任一给定字符串同时只有唯一的、地址稳定的 duk_hstring 实例。
  • 多次出现的字符串可节省内存(同名对象属性只需引用同一个字符串指针)。

代价:

  • 字符串操作变慢,因为任何可被引用的中间结果都需要驻留(涉及字符串哈希、查表等),可通过原子化地拼接多个部分来缓解。
  • 对只出现一两次的小字符串,驻留的 duk_hstring 堆元素相比直接把字符串存进对象属性表有额外开销。
  • 把字符串当作持续被操纵(追加/前插/切片等)的"数据缓冲区"使用效率极低、造成大量 GC 抖动;应改用 buffer 对象(但 buffer 不是 ECMAScript 标准的一部分)。

5.2 字符串的内存管理与表算法

驻留字符串在不再需要时被正常 GC;之后若再次需要会重新驻留,此时通常得到不同的指针。

字符串表结构与 duk_hobject 属性分配的 "entry part" 类似:

  • 闭哈希表(探测序列):初始索引基于字符串哈希值,探测步长从用哈希值索引的预计算步长表中查得。
  • 哈希表大小向上取整到预计算素数序列中的素数;通过必要时 resize 将负载因子保持在特定范围内。
  • 删除项显式标记为 DELETED 以避免破坏探测链;DELETED 项在 rehash 时消除,并在 resize 前计为"已使用"项,以确保字符串表中总有 NULL 项来切断探测序列。

文档进一步指向 doc/hstring-design.rst(字符串哈希算法讨论)与 doc/hobject-design.rst(闭哈希基本结构讨论)。

六、引用计数(Reference counting)

6.1 基本原理与取舍

基本引用计数中,每个堆对象有一个引用计数字段,记录堆中有多少对象指向它。创建引用时目标引用计数加一,销毁引用时减一;减到零则对象可被立即释放。释放时,对象引用的堆对象需要被减计数,这可能递归地触发任意长度的释放链。存在把待释放对象放进工作列表稍后释放的变体,但 Duktape 选择尽快释放(否则直接用标记-清除即可);也存在无需标记-清除就能正确解决引用环的变体,但对小型解释器而言过于复杂。

引用计数的代价:增大代码体积、因计数更新降低性能、为每个对象增大堆头。收益:最小化内存使用波动(对比纯标记-清除,甚至增量式)、对小脚本且无预分配堆的场景非常有用;它还减轻了堆元素不可搬移的影响——碎片依然发生,但可与普通 C 代码遇到的碎片相提并论。

6.2 引用计数字段

引用计数字段嵌入 duk_heaphdr 结构中,其布局随构建时选择的内存管理模型变化。字段作用于所有堆分配元素(含字符串),因此在管理非字符串堆元素所需的 next/prev 指针之前出现。结构定义位于 src-input/duk_heaphdr.h:

  • duk_heaphdr:适用于除字符串外的所有堆元素。
  • duk_heaphdr_string:适用于字符串,结构开头与 duk_heaphdr 一致。

计数字段必须有足够的位数以保证永不溢出。文档指出当前用 size_t 在技术上并不严谨(例如可能存在最大分配 32 位而地址空间 64 位的平台);DUK_USE_REFCOUNT32 的引入(config/config-options/DUK_USE_REFCOUNT32.yaml)已让默认配置改为 32 位字段,注释给出了"堆需大于 64GB 才可能回绕"的量化结论。

6.3 引用计数宏及其语义

相关宏包括:

  • DUK_TVAL_INCREF / DUK_TVAL_DECREF
  • DUK_HEAPHDR_INCREF / DUK_HEAPHDR_DECREF
  • 以及定义在 heaphdr.h 中的一批堆元素类型专属 INCREF/DECREF 宏与 helper。

宏语义要点:

  • 宏为性能优化,默认不容忍 NULL 指针;确有 NULL 可能的场景使用 _ALLOWNULL 变体。
  • INCREF 保证无副作用。
  • DECREF 可能有广泛副作用:可能释放目标及由此连锁归零的任意数量对象;若调用终结器则执行任意 C/ECMAScript 代码(可能触发标记-清除);refzero 处理结束时也可能主动触发标记-清除;任何指向动态结构(如值栈)的 duk_tval 指针可能失效(堆元素指针不受影响,因为指针稳定)。

6.4 更新引用计数的规则

更新引用计数很微妙,几条重要规则:

  • 每当可能发生 longjmp() 或垃圾回收时,可达性与引用计数必须正确。
  • 引用计数一旦(哪怕暂时)降到零,目标会立即被释放;若不想如此,可能需要调整 INCREF/DECREF 顺序。
  • DECREF 调用可能使任何指向可调整大小位置的 duk_tval 指针失效(如值栈),也可能在属性分配 resize 时使对象属性结构索引失效,因此 DECREF 必须极其小心地调用。

特别警告:人为抬高目标引用计数不足以防止对象被释放(至少当标记-清除也启用时)。标记-清除极易被触发,而且会无视引用计数释放不可达对象——它必须收集引用计数非零的不可达对象以处理纯引用计数无法收集的引用环。即便设法避开标记-清除(如用标志禁止收集),人为抬高计数而实际无堆内引用时,必须保证稍后能减回,这又需要 setjmp() 捕获点。另一个具体注意事项:对同一目标先 DECREF 后 INCREF 是危险的,两次调用间若计数降到零对象即被释放;通常应改为 INCREF + DECREF。

6.5 INCREF 算法

INCREF 算法非常简单:

  1. 若目标引用为 NULL 或目标不是堆元素,返回。
  2. 目标引用计数加一。

实际实现取决于 INCREF 作用于 tagged value 指针还是堆元素指针。

6.6 DECREF 算法

DECREF 算法稍复杂:

  1. 若目标引用为 NULL 或目标不是堆元素,返回。
  2. 目标引用计数减一。
  3. 若计数降到零:
    • a. 若标记-清除正在运行,忽略并返回(标记-清除会做完整可达性分析并在结束时有正确计数)。
    • b. 目标是字符串:从字符串表移除,移除弱引用(如字符串访问缓存),然后释放字符串结构。
    • c. 目标是缓冲区:从 "heap allocated" 列表移除,释放相关分配(动态缓冲区则释放单独分配的缓冲区),然后释放缓冲区结构。
    • d. 目标是对象(细节见源码):
      • 对象无终结器:放入 "refzero list"。若当前无人处理 refzero_list,则处理直到其为空(处理过程中可能加入新对象);列表空后运行期间排队的待执行终结器。若已有调用在处理,则仅入队并返回。
      • 对象有终结器:放入 finalize_list。若当前无人处理 refzero_list 或 finalize_list,则直接处理 finalize_list;否则仅入队并返回。

6.7 REFZERO 算法与 C 递归深度限制

DECREF 算法保证任意时刻最多只有一个 "refzero" 算法实例在运行。"refzero" 工作列表模型用于避免引用计数连锁归零导致的无界 C 调用栈深度:某些嵌入式环境的可用栈可能不足 64KB,引用计数归零处理必须把 C 递归级别控制在固定水平。

当前实现用"工作列表"模型:引用计数归零的堆元素被放入 "to be freed" 工作列表(duk_heap 结构的 refzero_list 成员,见 src-input/duk_heap.h),然后用循环逐个释放直到列表为空;迭代期间可加入新元素,C 递归级别固定。duk_heaphdr 的 h_prev/h_next 字段(平时用于 "heap allocated" 列表)在此复用为 "refzero" 工作列表链接;由于 duk_hstring 不含内嵌引用,字符串在计数归零时直接释放。

对象在 refcount 归零时被终结化对释放原生资源/句柄(如 socket、文件句柄)非常有用。终结器是与 duk_hobject 关联的内部方法,在对象被引用计数或标记-清除释放前调用;它获得目标对象引用,并且可能"救回(rescue)"该引用。更多副作用讨论见 doc/side-effects.rst。

七、标记-清除(Mark-and-sweep)

7.1 总览与特性

Duktape 使用 "stop the world"(停止世界)标记-清除收集器,而非增量式,以换取简单与小体积。与引用计数组合时,标记-清除只负责处理引用环,具体变体并不重要。stop-the-world 的明确缺点是引入应用停顿,而引用计数本可避免。该算法支持:

  • 对象终结化(需要两轮收集)
  • 对象压缩(紧急模式下)
  • 字符串表 resize

紧急模式(emergency mode):用于分配在几次普通标记-清除尝试后仍反复失败的情形,收集器会尝试更昂贵的手段(如强制压缩对象属性分配)来寻找内存。

控制标志用于限制标记-清除副作用,这是实现几个关键算法(字符串表 resize、对象属性分配 resize)所必需的——这些操作期间标记-清除必须避免干扰正在被 resize 的对象。

7.2 标记-清除标志

定义于 src-input/duk_heap.h,例如:

  • DUK_MS_FLAG_EMERGENCY:紧急标记-清除,即使牺牲性能也要尽力。
  • DUK_MS_FLAG_POSTPONE_RESCUE:推迟对已设置 FINALIZED 的可达对象的救回决策,用于 finalize_list 处理期间避免因 finalize_list 作为可达性根而做出错误救回决策。
  • DUK_MS_FLAG_NO_OBJECT_COMPACTION:不压缩对象;在对象属性表 resize 期间防止递归 resize。

除显式请求的标志外,duk_heap 中的 ms_base_flags 会按位 OR 进请求标志形成有效标志;加入 "base flags" 的标志限制标记-清除副作用,用于某些关键段。

关键算法对副作用的防护流程:

  1. 保存 heap->ms_base_flags 原值。
  2. 把合适的限制标志设置进 heap->ms_base_flags。
  3. 尝试分配/重分配操作,且不抛错。
  4. 恢复 heap->ms_base_flags 到原值。
  5. 检查分配结果并相应处理。

文档强调:在恢复 base flags 字段之前绝不应抛出错误。标志细节见 src-input/duk_heap.h。

7.3 堆头标志(Heap header flags)

用于控制标记-清除的堆元素头标志(定义于 src-input/duk_heaphdr.h,下文简称 REACHABLE、TEMPROOT、FINALIZABLE、FINALIZED):

  • DUK_HEAPHDR_FLAG_REACHABLE:元素通过可达性图可达。
  • DUK_HEAPHDR_FLAG_TEMPROOT:元素可达性已标记,但子节点尚未处理;这是限制 C 递归级别所必需的(作为"临时根"留待后续多轮处理)。
  • DUK_HEAPHDR_FLAG_FINALIZABLE:第一轮标记后元素不可达、有终结器、且上一轮标记-清除未调用过终结器;对象将被移入终结化工作列表并(临时)视为可达性根。
  • DUK_HEAPHDR_FLAG_FINALIZED:终结器已执行;若仍不可达则可收集。在标志清除前不会再次调用终结器,防止对象被明确救回并清除标志前终结器意外重入。

所有标志在堆元素首次分配时均为清除状态。实现通过精心处理标志(而非显式"清除轮")保证标记-清除开始与结束时标志总处于已知状态。

7.4 基本算法

标记-清除由失败的内存分配在"普通"或"紧急"模式下触发。紧急模式用于普通标记-清除未能解决分配失败的情形,是更激进的释放内存尝试。算法如下:

0. 前置条件:假设此刻所有堆元素的 REACHABLE 与 TEMPROOT 标志均已清除(无论元素位于字符串表、"heap allocated" 列表、"refzero" 工作列表或其它任何位置)。

1. 标记阶段(Mark phase):递归遍历可达性图,为所有可达元素设置 REACHABLE。难点在于必须限制最大 C 递归深度:

  • a. 开始时断言堆级标志 DUK_HEAP_FLAG_MARKANDSWEEP_RECLIMIT_REACHED(见 src-input/duk_heap.h)为清除状态。
  • b. 用深度优先算法遍历堆的可达性图:
    1. 从可达性根开始标记:堆结构自身(含当前线程、其 resumer 线程等)、以及供引用计数使用的 "refzero_list"。
    2. 若遍历到某堆元素 E 时命中 C 递归限制(堆的 mark_and_sweep_recursion_limit 成员):设置 DUK_HEAP_HAS_MARKANDSWEEP_RECLIMIT_REACHED;更新 E 的可达性状态但不处理其内部引用(避免进一步递归);为 E 设置 TEMPROOT 标志表示稍后处理。
    3. 需要终结化但上一轮未执行终结器的不可达对象被标记 FINALIZABLE,并用普通递归标记算法标记为可达。
    4. 重复步骤 2 的算法(处理 TEMPROOT 标记),确保可达性图(包括刚标记 FINALIZABLE 的对象)被完整处理。
  • c. 只要堆的 DUK_HEAP_HAS_MARKANDSWEEP_RECLIMIT_REACHED 标志被设置:
    1. 清除该堆标志。
    2. 扫描 "heap allocated" 或 "refzero work list" 中所有元素(注意:refzero work list 必须在此包含,但不在清扫阶段包含)。对每个设置了 TEMPROOT 的元素:清除 TEMPROOT;递归处理其内部引用(施加同样的递归限制,即再次设置堆标志等)。

2. 清扫阶段 1(refcount 调整):检查 "heap allocated" 列表中所有堆元素(字符串表无需考虑,字符串无内部引用)。若元素会在清扫阶段 2 被释放(不可达、且无需运行终结器):对其指向的堆元素减少引用计数,但不执行 "refzero" 入队或 "refzero" 算法——计数归零的元素交由标记-清除处理,refzero 列表中的对象由引用计数处理。

3. 清扫阶段 2(实际释放):检查 "heap allocated" 列表与字符串表中所有堆元素(注意:refzero 工作列表中的对象不处理、绝不在此处释放):

  • a. 若元素 REACHABLE:若设置了 FINALIZED,说明对象已被终结器救回(无需操作,可调试日志);清除 REACHABLE 与 FINALIZED 标志;继续下一元素。
  • b. 否则元素不可达:
    1. 若元素是 duk_hobject(堆类型为 DUK_HTYPE_OBJECT)、有终结器(内部属性 _Finalizer)且 FINALIZED 未设置:将其从 "heap allocated" 移到 "to be finalized" 工作列表;继续下一元素。
    2. 释放元素及其"辅助分配";继续下一元素。

4. refzero 工作列表处理:对列表中每个元素清除 REACHABLE 标志(这看似多余实则必要,见下)。

5. 紧急标记-清除且堆标志未显式禁止对象压缩时:压缩对象的属性分配,以期腾出内存。

6. 字符串表 resize 未被堆标志显式禁止时:压缩并 rehash 字符串表(可由构建标志控制,因为在某些环境可能不合适)。

7. 结束:

  • a. 此刻所有 TEMPROOT 与 REACHABLE 标志均已清除。
  • b. 所有 "heap allocated" 元素要么 (a) 可达且引用计数非零,要么 (b) 已被终结化且可达性状态未知。
  • c. "to be finalized" 列表为空。
  • d. "refzero" 工作列表中的对象未被释放。

8. 执行待处理终结器,除非终结器执行被阻止或已有调用点正在终结化对象。终结器执行位于标记-清除预防锁之外,因此处理终结器期间标记-清除仍可能运行;但救回决策会推迟到 finalize_list 为空,以避免因 finalize_list 被当作可达性根而产生错误的救回决策。

重要设计注释:

  • refzero 列表元素被视为可达性根(需要同时保留对象自身及其子节点);由于 Duktape 2.1 起 refzero_list 在标记-清除运行时恒为 NULL,这一点已不再相关。
  • 标记为 FINALIZABLE 的元素被视为可达性根,以保证其子节点(如属性值)在清扫阶段不被扫除——否则无论对象是否被救回,运行终结器都会出问题。
  • 标记-清除运行期间:另一个标记-清除不能执行;导致计数归零的 DECREF 除更新计数外完全不被处理(不放入 refzero 工作列表),因为标记-清除被认为是完整一轮(含运行终结器)。
  • 终结器在清扫阶段之后执行,以保证终结器拥有尽可能多的可用内存。Duktape 2.1 起标记-清除运行于算法之外,处理终结器期间标记-清除仍可运行,但救回决策推迟到 finalize_list 为空。
  • 清扫分两次独立扫描(一次调整 refcount、一次实际释放)。若合并为一次堆扫描,refcount 调整可能引用已释放的堆元素(悬垂指针)——即便没有引用计数 bug,这在不可达引用环中也可能发生。
  • 必须显式清除 refzero 列表中对象的 REACHABLE 标志:refzero 工作列表在清扫阶段不被处理,但标记阶段理论上可能标记其中的对象;由于清扫阶段是唯一清除 REACHABLE 的地方,某些 refzero 列表对象可能残留 REACHABLE 标志。表面看引用计数正确时不可能发生(refzero 列表对象按定义均不可达),但对带终结器的对象并非如此。(Duktape 2.1 起 refzero_list 无副作用地内联释放,标记-清除运行时恒为 NULL。)

八、引用计数与标记-清除的交互

若标记-清除被例如内存不足条件触发,引用计数在标记-清除阶段基本被"禁用":

  • 引用计数正常更新;事实上标记-清除在释放元素时使用同一套 refcount 宏。
  • 若 DECREF 后计数归零,对象不会被释放也不会放入 refzero 工作列表,因为标记-清除预期直接处理该对象。

若 "refzero" 算法先被触发(refzero 工作列表中有对象),自 Duktape 2.1 起,处理 refzero_list 期间不会触发标记-清除,因为 refzero_list 处理是无副作用的。

从源码角度,src-input/duk_heap.h 中的 ms_running 字段明确说明:1 表示标记-清除运行中,2 表示堆销毁或调试器激活(阻止标记-清除与 refzero 处理);refzero_list 注释也强调 DECREF 现在是无副作用处理(仅内存释放调用),refzero_list 在 DECREF 之外恒为 NULL。

九、终结器行为

一般性说明:

  • 若使用引用计数,终结器在引用计数降到零或标记-清除想收集该对象时调用(后者对循环引用是必需的,也可能发生在引用计数被错误更新时)。
  • 若使用标记-清除,终结器仅在标记-清除想收集对象时调用。
  • 无论使用哪种模型,堆被释放时对所有对象(包括可达对象)都会执行终结器。
  • 终结器可以重新建立对目标对象的引用,此时对象被"救回",若再次不可达其终结器可能再次调用。无论是否救回,良好实践是让终结器可重入(允许多次调用)。
  • 终结器保证在对象被收集或堆被强制销毁时运行。duk_destroy_heap() 会运行几轮标记-清除以让不可达对象的终结器正常执行,然后对 heap_allocated 列表上所有对象(无论可达性)运行终结器,使用户代码即使对可达对象也能较可靠地释放原生资源。
  • 终结器返回值被忽略;终结器抛错仅记入调试日志,被视为终结化成功。
  • Duktape 2.1 起终结器始终用 heap->heap_thread 执行;2.0 之前所用线程取决于对象经由 refcount 还是标记-清除终结化。
  • 终结器技术上可启动其它线程并做任意事,但良好实践是让终结器非常简单、无侵入,理想情况下只操作目标对象及其属性。
  • 终结器不应终止活动调用栈中的任何线程,尤其是触发终结化的线程或终结器线程(若两者不同)。

十、自愿标记-清除的触发间隔

决定何时做一次自愿标记-清除有许多方式:按字节数、按对象数、概率式等。当前做法:在标记-清除结束时统计保留的堆对象与字符串数量,以此初始化自愿清扫触发计数(实际用定点算术计算):

trigger_count = ((kept_objects + kept_strings) * MULT) + ADD

// MULT 和 ADD 为调优参数

触发计数在每次内存(重)分配和释放时递减,以大致衡量分配活动。若在即将(重)分配内存时触发计数已为负,则执行一次自愿标记-清除。当 MULT 为 1、ADD 为 0 时,自愿清扫在分配/释放操作数匹配上次堆对象/字符串数时发生。

调优要点:

  • 启用引用计数时 MULT 可以较大(如 10),因为只需清扫循环引用。
  • 未启用引用计数时 MULT 应接近 1(甚至更低)。
  • ADD 不太重要,目的是避免极小堆上清扫过频,并抵消定点算术的轻微不精确。

从 src-input/duk_heap.h 可以看到真实默认值(定点 .8 乘数):启用引用计数时 DUK_HEAP_MARK_AND_SWEEP_TRIGGER_MULT 为 12800(约 50 倍堆大小),ADD 为 1024,另有 SKIP 为 256(表示初始化阶段无线程可做 GC 时跳过多少次分配再重试);未启用时为 256(约 1 倍堆大小),ADD 1024。触发计数由 ms_trigger_counter 字段承载(DUK_USE_VOLUNTARY_GC 下)。

十一、实现问题

11.1 错误处理与 longjmp

longjmp() 发生时 C 栈被展开,指向被展开部分的引用全部丢失。为避免内存泄漏与正确性问题,必须:

  • 进入可能 longjmp() 的代码前,保证每个堆分配元素的引用计数正确。
  • 进入可能 longjmp() 的代码前,保证所有应受自动 GC 管理的堆分配对象可达。
  • 需要在控制回归时做清理,就用 setjmp() 捕获点。

为避免 setjmp() 捕获点,许多最内层 helper 函数返回错误码而非抛出错误,使错误处理稍简单。内存管理的副作用详见 doc/side-effects.rst。

11.2 值栈的垃圾回收

ECMAScript 函数运行期间,为其分配的值栈帧最小尺寸匹配函数的"寄存器数"。从标记-清除视角,所有这些寄存器都可达,即使寄存器持有的值从未被函数字节码引用。例如表达式求值产生的临时量可能留下未用但技术上可达的值:

function f(x,y,z) {
  var w = (x + y) + z;
}

其字节码会把 x + y 算入临时寄存器 T,再把 T + z 算入 w 的寄存器。函数退出前 T 对标记-清除可达;若 T 是堆元素(如字符串),其引用计数为正。函数退出或 T 被后续表达式复用时问题消除,因此通常不构成实际问题。但:

  • 若函数运行在无限循环中(如永不退出的主事件循环),这类引用可能永远无法被收集。
  • 即使函数最终退出,这类引用也可能在退出前造成内存不足;此时 GC 无法恢复,因为值在退出前技术上可达。

目前对此没有实际解决方案,但任何含无限循环的代码应避免"悬垂值",例如用辅助函数承载计算:

function stuff() {
  // ...
}

function infloop() {
  for (;;) {
    stuff();
  }
}

技术上的修复思路(文档提及):让函数用真正的值栈而非直接寄存器引用(会使求值变慢);或新增字节码指令"擦除"高于某索引的寄存器以清除伪引用(可在表达式求值后或循环头发出,但会膨胀字节码)。

11.3 函数闭包默认是引用环

函数闭包默认包含一个引用环:

var f = function() {};
print(f.prototype.constructor === f);  // --> true

除非用户代码显式设置不同的 f.prototype,否则每个函数闭包都需要一次标记-清除才能被收集,这使大量函数临时量的场景下纯引用计数失去吸引力——这些临时量将保持可达,只在堆销毁时释放。文档将此列为待修复事项。

11.4 跟踪堆分配对象的需求

  • 标记-清除只需要单(前向)链表跟踪对象:对象插在表头,标记与清扫时线性扫描;清扫阶段通过记录前驱即可移除对象。工作列表同理。
  • 引用计数需要能从 "heap allocated" 列表中移除任意选定对象:为此需要双链表以避免从头扫描列表。

这正是 config/config-options/DUK_USE_DOUBLE_LINKED_HEAP.yaml 中"引用计数启用时必须使用双链表"约束的原因。

十二、未来工作

文档在结尾列出若干未来方向(部分已在源码或后续版本演进):

  • 对象属性分配 resize 期间,不要阻止标记-清除对其它对象的压缩。
  • 为内置字符串与对象做特殊处理,使其能从连续缓冲区分配、仅在堆释放时释放(与 ROM 化方向相关)。
  • 至少在半实时环境中提供增量标记-清除选项。
  • 优化性能关键代码段的引用计数处理,例如:提供一次 INCREF 一段 tagged value 的原始操作;对 INCREF 目标假定非 NULL 的快速路径宏(断言而不检查)。
  • 修复函数临时寄存器可达性问题。
  • 修复函数实例原型引用环问题。
  • 补充一张说明对象可能位于何处(字符串表、heap allocated、refzero 工作列表、标记-清除 to-be-finalized 工作列表)的图。

总结

Duktape 的内存管理是一个围绕"稳定指针堆元素 + 分配函数抽象 + 双算法协作回收"精心构造的系统:分配函数层用 raw 与 GC 触发两种宏变体平衡实时性与内存压力传导;引用计数提供低波动、即时的回收,依靠 refzero 工作列表限制 C 递归深度;标记-清除以 stop-the-world 方式兜底引用环,通过多轮标记(TEMPROOT)、两阶段清扫、紧急模式与 base flags 临界区保护兼顾正确性、递归限制与关键 resize 操作的安全。理解 DUK_REALLOC_INDIRECT 的指针稳定性约束、终结器与 longjmp() 的副作用边界、以及 DUK_USE_REFERENCE_COUNTING/DUK_USE_REFCOUNT16/DUK_USE_REFCOUNT32/DUK_USE_DOUBLE_LINKED_HEAP/DUK_USE_GC_TORTURE 等构建选项的取舍,是安全地在嵌入式环境中集成与调优 Duktape 的前提。若需更深入地排查具体场景,可进一步阅读 src-input/duk_heap_markandsweep.c(duk_heap_mark_and_sweep 实现)、src-input/duk_heap_refcount.c、src-input/duk_heap_memory.c 以及 doc/side-effects.rst。

  • 语言运行时
  • 嵌入式
  • 解释器

【免费下载链接】duktape

Duktape - embeddable Javascript engine with a focus on portability and compact footprint

项目地址: https://gitcode.com/gh_mirrors/du/duktape
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值