按值取消保护



简而言之,UNPROTECT_PTR 很危险,不应使用。本文介绍了原因并介绍了如何替换它,包括已作为按值取消保护真正需要时的替代方案引入的基于 mset 的函数。这可能对编写本机代码以与 R 堆交互的任何人感兴趣,并且肯定对在代码中使用 UNPROTECT_PTR 的所有人感兴趣。

背景

R 提供了几个函数来保护本地 C 变量(类型为 SEXP)持有的 R 对象的指针,使其免受垃圾回收器的影响。如 编写 R 扩展 中所述,有两种结构来保存受保护的指针:指针保护栈和珍贵列表。

指针保护栈 通过 PROTECT/UNPROTECT 访问。通过从栈顶删除指针来取消对指针的保护。还可以使用 PROTECT_WITH_INDEX,然后使用 REPROTECT 替换由其在栈中的位置定义的指针,这可以简化和加速重复更新保存指针的局部变量的代码(在这种情况下,原则上仍然可以使用 PROTECT/UNPROTECT 操作序列)。指针保护栈需要与 C 调用栈内联管理:从函数返回后,栈深度应与调用函数时相同(指针保护平衡)。这些和其他规则在 编写 R 扩展PROTECT 错误的危险 中进行了描述,它们相对容易遵循和检查,无论是通过可视方式还是通过工具(此外,在运行时对指针保护平衡进行了一定程度的检查)。基于栈的保护和取消保护速度很快,不需要额外的分配,并且在 R 错误(长跳转)期间自动处理:长跳转恢复先前的栈深度,取消对跳转设置后但在执行跳转之前由执行后的代码留在栈上的值的保护。

尽管这种情况非常罕见,但有时实现指针保护平衡非常困难,有时会说包代码希望保留一些已分配的空间而不返回指向它的指针(因此在全局变量指向 R 堆并且由于某种原因无法将它们转换为局部变量时,不会让调用者保护它)。这由珍贵列表解决,该列表使用 R_PreserveObject/R_ReleaseObject 访问。它实现为一个链表(是的,R_PreserveObject 分配!),并且对象按值取消保护。错误时没有自动取消保护,用户始终负责取消保护存储在珍贵列表中的对象。为了在 R 错误(长跳转)或回调(例如卸载包)的情况下实现这一点,可能需要分配一个虚拟对象,设置其终结器,并让终结器从珍贵列表中释放所需的物体。R_PreserveObjectR_ReleaseObject 也比 PROTECT/UNPROTECT 慢得多。

该 API 对于非常特殊的应用程序来说仍然不够用,这些应用程序使用从 R 堆分配内存的生成代码,例如由 bison 生成的 R 解析器。解析器代码使用语义值堆栈,它们是 R 堆上对象的指针。值在移位操作期间由标记生成器推送到堆栈上,在减少操作的动作期间同时被推送和移除,并在某些解析错误中被移除。R 错误(长跳转)也可能在解析期间发生。堆栈是解析函数的局部变量。关键问题在于解析器的代码是生成的,并且 bison 无法进行足够的自定义以确保插入 PROTECT/UNPROTECT 操作。将语义值堆栈分配到 R 堆上、保护它以及在局部变量中持有但尚未在语义值堆栈上时保护语义值是自然的,所有这些都使用 PROTECT/UNPROTECT。但是,这是不可能的。原则上,可以使用 R_PreserveObject/R_ReleaseObject,但必须处理错误,最重要的是,性能开销是不可接受的。

为了解决此问题,引入了 UNPROTECT_PTR。它允许对指针保护栈中受保护的语义值执行相对快速的按值取消保护操作。当创建新的语义值时,它们会立即使用 PROTECT 由标记生成器和缩减规则放入保护栈中。值在缩减规则内由 UNPROTECT_PTR 取消保护,并且在某些未导致长跳转的解析错误后恢复指针保护栈深度(也可以在 bison 中为某些标记定义一个 destructor 并使其调用 UNPROTECT_PTR,如在程序包 tools 中的 Rd 解析器中所做)。UNPROTECT_PTR 删除指针的第一个出现(从栈顶开始)并压缩栈,从而减少栈深度。按此方式使用 UNPROTECT_PTR 会按设计导致指针保护不平衡(标记生成器和缩减规则在不同的函数中实现),这会增加代码的认知复杂性。但是,当小心使用时,它比珍贵列表更快,并且使用更少的内存,它适用于 R 长跳转(自动取消保护),并且很可能没有比按值取消保护更好的方法来对解析器进行保护(如果我们不修改生成的解析器代码)。它已在解析器中使用了许多年,但不幸的是,也开始在不必要的情况下在解析器外部使用。

众所周知并已 记录在案,将 UNPROTECT_PTRPROTECT_WITH_INDEX 结合使用是危险的,因为通过 UNPROTECT_PTR 从栈中删除某个对象并压缩栈,保护索引 可能会变得无效/意外(栈上的对象位置发生更改)。然后 REPROTECT 将替换错误的指针,从而导致内存泄漏(打算取消保护的对象保持受保护状态)以及更糟的是过早取消保护(REPROTECT 将替换仍需受保护的对象)。使用 UNPROTECT_PTR 的代码也很难阅读。

UNPROTECT_PTR 很危险

在对解析器进行一些改进时,我意识到 UNPROTECT_PTRPROTECT/UNPROTECT 结合使用时也不安全。当同一个指针在保护栈中存储多次时,就会出现问题。人们可能会意外地使用 UNPROTECT_PTR 来取消保护对象的意外实例,而该实例原本打算通过 UNPROTECT 取消保护。在 UNPROTECT_PTR 时,还没有发生什么不好的事情,但是,当稍后进入 UNPROTECT 时,错误的对象会取消保护,导致过早取消保护(保护错误)。不幸的是,在解析器中,同一个指针被保护多次(R_NilValue、符号)的情况非常普遍。

为了说明这一点,请想象栈上的这个指针序列(3 最后被保护,A 和 A’ 是同一个指针,A 打算通过值取消保护)

1A2A'3

在 UNPROTECT_PTR(A) 之后,我们得到

1A23

而不是封闭代码预期的

12A'3

深度是正确的,假设代码稍后执行 UNPROTECT(1),打算取消保护 3 并实际执行,所以仍然正确。但是,然后它调用 UNPROTECT(1),打算取消保护 A',但实际上取消保护 2。结果,A'A)仍将保持活动状态(内存泄漏,可能是临时的,所以没有那么糟糕),但 2 将过早地取消保护,从而导致保护错误(并且可能很难调试)。

原则上,R_NilValue 和符号根本不需要保护,但它们确实需要,有时在没有做出区分时,这会使代码更具可读性。此外,任何返回指针的函数有时可能会返回一个新指针,有时可能会返回一个已经存在的指针(包括在解析器中,其中一些列表操作函数以这种方式工作)。因此,这似乎是一个真正的危险。此外,像在解析器中那样使用 UNPROTECT_PTR 会使其他纯基于栈的 PROTECT/UNPROTECT 操作的验证变得更加困难,无论是手动还是通过工具,因为没有明确说明哪些指针打算通过 UNPROTECT_PTR 取消保护,哪些指针打算通过 R_ReleaseObject 取消保护。

逐步淘汰 UNPROTECT_PTR

因此,我已从所有 R 基础代码中删除了 UNPROTECT_PTR 的使用。在解析器外部使用时,在少数情况下相对容易,我只是使用基于栈的保护函数重写了代码。我认为在所有情况下,这实际上简化了代码。

为了在解析器中使用(R 解析器和软件包 tools 中的两个解析器),我在指针保护栈外部引入了基于值的取消保护的 API。这些函数使用 precious multi-set 来保护这些对象;multi-set 分配在 R 堆上,需要由调用者保护(例如,使用 PROTECT)。因此,它在长跳转时会自动取消保护,因此 mset 中保护的所有指针也会间接取消保护。当前实现使用(向量)列表而不是对列表,因此也比 R_PreserveObject/R_ReleaseObject 更快,但这只是一个可以更改的实现细节,当然,如果在实践中证明它是一个瓶颈,取消保护可以变得更快。主要好处是这些函数使用单独的结构来取消保护值,不会污染指针保护栈。

SEXP R_NewPreciousMSet(int initialSize);
void R_PreserveInMSet(SEXP x, SEXP mset);
void R_ReleaseFromMSet(SEXP x, SEXP mset);
void R_ReleaseMSet(SEXP mset, int keepSize);

要使用此 API,首先需要使用 R_NewPreciousMSet 创建一个新的 mset 并 PROTECT 它。mset 会根据需要自动扩展(R_PreserveInMSet 可能分配)。对象通过 R_ReleaseFromMSet 按值释放,使用与 UNPROTECT_PTR 中使用的相同(朴素)算法,因此不会出现性能下降(原则上,操作可能会更快,因为它们不必处理用于基于堆栈的保护的对象)。不必显式释放对象,当 mset 被垃圾回收时,所有对象都将被释放(例如,在会取消保护 mset 的长跳转上)。出于性能原因,不过,如果已分配的大小不超过给定的元素数,可以使用 R_ReleaseMSet 清除 mset 但保留其分配(例如,在未实现为长跳转的错误上可以使用此方法)。与 R-devel 代码库中的任何内容一样,API 仍可能发生更改。

UNPROTECT_PTR 切换到新 API 比最初看起来要难,因为必须识别要按值取消保护的 PROTECT 操作(并在某些代码路径以一种方式取消保护同一“变量”而其他代码路径以另一种方式取消保护时重写代码)。

选择正确的 API

我认为对于内存保护,应始终使用 PROTECT/UNPROTECT,在性能关键代码中可能与 PROTECT_WITH_INDEX/REPROTECT 一起使用。如果我们有保存 R 内存的全局变量,R_PreserveObject/R_ReleaseObject 会有所帮助,但无论如何也应避免使用全局变量,因此这种情况应该非常罕见。此外,安排在出错时取消保护有点繁琐。R_PreserveInMSet/R_ReleaseFromMSet 应仅在 bison/yacc 解析器中使用,并且应从所有代码中逐步淘汰 UNPROTECT_PTR