驯服资源作用域

Memory Access API 具有一个抽象概念,ResourceScope,用于管理与其关联的资源的时间范围。在本文档中,我们将探讨提高 Foreign Memory Access/Linker API 的安全性和可访问性的方法,首先描述对现有机制的改进,以保持资源作用域处于活动状态(acquire/release API 方法),其次,在将由作用域支持的资源作为参数传递给下调方法句柄时提供基本的安全保障。

获取资源作用域

Memory Access API 具有一个抽象概念,ResourceScope - 有关更多详细信息,请参阅此 撰写内容 - 用于管理与其关联的资源的时间范围(内存段、内存地址、上调存根、valist)。有三种资源作用域

  • 隐式资源作用域:它们不能显式关闭;由 GC 管理(它们在变得不可访问时关闭);
  • 显式受限资源作用域:这些作用域可以显式关闭,但只能由创建它们的线程使用和关闭;
  • 显式共享资源作用域:这些作用域可以显式关闭,并且可以由任何线程使用和关闭。

显式受限作用域可能是最容易处理的:毕竟,它们只能由创建它们的线程访问和关闭;因此,访问与关闭竞争在定义上是不可能的(有趣的是,这并不排除与本机代码交互时出现问题,请参见以下部分)。

隐式作用域有点棘手,但对于 Java 开发人员来说可能并不太令人惊讶;由于它们在变得不可访问时会由垃圾回收器隐式关闭,因此有时有必要使用 try/finally 块包装访问与隐式作用域关联的资源的代码,并插入一个可访问性围栏以确保隐式作用域保持活动状态。也就是说,由于隐式作用域不能显式关闭,因此多个线程可以安全地访问它们。

共享作用域是迄今为止最复杂的情况。这些作用域可以被多个线程访问和关闭,甚至是并发访问和关闭。因此,有可能在一个线程访问与共享作用域关联的资源时同时另一个线程尝试关闭同一个共享作用域。内存访问 API 的核心采用了一种低级机制,该机制基于 线程本地握手,以确保对由共享作用域支持的内存段的访问保持高效(例如无锁)安全。但是对于其他访问用例,用户必须采取额外的预防措施,通常是使共享作用域在一段时间内不可关闭

出于此原因,ResourceScope API 提供了两个方法:acquirerelease,它们分别允许客户端暂时阻止关闭资源作用域的尝试。这些方法不仅确保共享段保持活动状态,还可用于定义临界区,在该临界区中可以发生多次内存访问,而无需担心支持所访问内存的作用域可能被释放,例如

MemorySegment segment = ...
var handle = segment.scope().acquire();
try {
   <critical section>
} finally {
   segment().scope().release(handle);
}

在此示例中,传入段作用域被获取。在 try 块内,该段将不可关闭。获取作用域会生成一个唯一的句柄实例,该实例随后可用于(请参见 finally 块)释放作用域并使其再次可关闭。此代码段无论与传入段关联的作用域如何都起作用(尽管获取共享作用域所涉及的成本较高)。获取/释放机制充当非对称原子引用计数,因为只有增加计数的客户端才能将其递减(使用获取句柄)。

从锁到时间依赖项

虽然上述代码段没有问题,但使作用域在一段时间内不可关闭的能力至关重要,我们认为可以使此 API 更易于使用和理解。如果我们把注意力从负责使资源作用域不可关闭的基元(acquirerelease)移开,转而根据表达不同资源作用域之间的时间依赖项来表述问题,就可以做到这一点。

上述代码定义了一个代码区域,在该区域内不能关闭一个或多个作用域。事实证明,我们已经有一个允许我们表达词法作用域代码区域的构造:ResourceScope 本身!如果我们使用资源作用域来捕获发生关键操作的作用域,会怎样?如果我们这样做,我们可以将上述代码重写如下

MemorySegment segment = ...
try (ResourceScope criticalScope = ResourceScope.ofConfined()) {
    segment.scope().addCloseDependency(criticalScope);
    <critical region>
}

此代码在功能上等同于我们之前展示的代码(基于 acquire/release):我们为关键操作创建一个作用域,并在段作用域和关键作用域之间定义一个关闭依赖项:这意味着段作用域不能在关键作用域之前关闭,从而有效地使段作用域在 try-with-resources 块内不可关闭。

此新公式比用 acquirerelease 表示的公式更高级:关键作用域提供了有关何时发生 acquire 和 release 操作(在底层仍然存在)的自然边界。它允许客户端考虑作用域之间的临时依赖关系,而不是考虑增量/减量计数器;换句话说,客户端可以根据其特定需求设置任意复杂的依赖关系图。

事实证明,此高级解决方案提供了一种更自然的方法来解决以前用 acquire/release 解决的一些问题:支持 NIO 异步操作池化分配器。在前者中,必须编写大量非平凡代码来模拟资源作用域所做的工作 - 跟踪作用域依赖关系,并允许在异步操作终止后对所有依赖段调用 release 方法。使用上面提出的 API 时,无需所有这些代码:我们可以简单地为整个异步操作创建一个作用域,并在异步操作作用域和异步操作涉及的段的作用域之间设置临时依赖关系。在池化分配器的情况下,我们有一个与作用域 S 关联的段池,以及一个使用作用域 R 请求分配器(由池支持)的用户。因此,有必要在 RS 之间设置临时依赖关系。同样,此用例由此处描述的 API 自然处理。

作用域和本机调用

与下调方法句柄交互可能会带来一些问题,尤其是在涉及按引用传递的参数(例如指针)时。虽然 CLinker 会将大多数传入参数(例如 MemorySegment)解构成一堆基本字(然后在寄存器或堆栈槽中传递),但某些参数(例如 MemoryAddress)会直接传递。这会产生风险:如果与内存地址参数关联的作用域在本机调用完成之前关闭,则本机代码可能会尝试取消引用已释放的内存位置。更糟糕的是:这可能发生在所有类型的作用域中

  • 另一个线程可以同时关闭显式共享作用域
  • 如果下调需要上调回 Java,则同一线程可以关闭显式受限作用域
  • 在执行本机函数时,隐式作用域可能变得不可达(因为 CLinker 会将 MemoryAddress 降低为原始 long,从而丢失对地址所支持作用域的跟踪)

当前 CLinker 实现为传递到下调方法句柄的所有 Addressable 参数插入了一些可达性围栏 - 这至少应该防止隐式作用域过早关闭。但在使用显式作用域时,它不提供任何保护。

本机调用作为关键区域

调用具有由显式范围支持的地址参数的下调方法的问题与前面显示的临界区域问题有很多共同点。事实上,为了在显式段过早关闭时实现安全性,我们可以将下调方法句柄调用本身视为临界区域。CLinker 实现可以插入一些逻辑,以便在本地调用的范围和传递给本地代码的参数的范围之间添加时间依赖关系。

这种机制的开销有多大?下面我们展示了一些我们通过使用原型获得的数字,该原型支持此说明中描述的增强功能。下面的微基准调用了许多本地函数,其中包含不同的参数(一个基元、一个内存地址、一个内存段)和不同的元数(一个或三个)。此基准中涉及的所有本地函数的实现都很简单,因为它仅仅返回传递的一个参数 - 因此,它代表了一种衡量与下调方法句柄机制相关的开销的公平方式。

如果我们只保留隐式范围(类似于当前实现所做的那样),则数字如下

Benchmark                                                       Mode  Cnt   Score   Error  Units
CallOverheadConstant.panama_identity                            avgt   30  10.108 ? 0.055  ns/op
CallOverheadConstant.panama_identity_memory_address_confined    avgt   30  10.032 ? 0.113  ns/op
CallOverheadConstant.panama_identity_memory_address_confined_3  avgt   30   9.973 ? 0.129  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit    avgt   30   9.751 ? 0.108  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit_3  avgt   30   9.745 ? 0.123  ns/op
CallOverheadConstant.panama_identity_memory_address_shared      avgt   30   9.944 ? 0.123  ns/op
CallOverheadConstant.panama_identity_memory_address_shared_3    avgt   30  10.083 ? 0.114  ns/op
CallOverheadConstant.panama_identity_struct_confined            avgt   30  12.342 ? 0.160  ns/op
CallOverheadConstant.panama_identity_struct_confined_3          avgt   30  12.592 ? 0.155  ns/op
CallOverheadConstant.panama_identity_struct_implicit            avgt   30  12.263 ? 0.208  ns/op
CallOverheadConstant.panama_identity_struct_implicit_3          avgt   30  12.226 ? 0.198  ns/op
CallOverheadConstant.panama_identity_struct_shared              avgt   30  12.338 ? 0.106  ns/op
CallOverheadConstant.panama_identity_struct_shared_3            avgt   30  12.515 ? 0.186  ns/op

现在,让我们看看启用时间依赖关系相关的成本

Benchmark                                                       Mode  Cnt   Score   Error  Units
CallOverheadConstant.panama_identity                            avgt   30   9.861 ? 0.131  ns/op
CallOverheadConstant.panama_identity_memory_address_confined    avgt   30  12.891 ? 0.092  ns/op
CallOverheadConstant.panama_identity_memory_address_confined_3  avgt   30  12.703 ? 0.101  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit    avgt   30  12.025 ? 0.071  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit_3  avgt   30  12.551 ? 0.360  ns/op
CallOverheadConstant.panama_identity_memory_address_shared      avgt   30  19.167 ? 0.164  ns/op
CallOverheadConstant.panama_identity_memory_address_shared_3    avgt   30  19.323 ? 0.206  ns/op
CallOverheadConstant.panama_identity_struct_confined            avgt   30  12.361 ? 0.198  ns/op
CallOverheadConstant.panama_identity_struct_confined_3          avgt   30  12.428 ? 0.178  ns/op
CallOverheadConstant.panama_identity_struct_implicit            avgt   30  12.195 ? 0.338  ns/op
CallOverheadConstant.panama_identity_struct_implicit_3          avgt   30  12.185 ? 0.208  ns/op
CallOverheadConstant.panama_identity_struct_shared              avgt   30  12.137 ? 0.192  ns/op
CallOverheadConstant.panama_identity_struct_shared_3            avgt   30  12.356 ? 0.130  ns/op

正如数字所示,成本非常低。对于不需要范围依赖关系的调用(例如涉及基元或按值传递的结构的调用),没有增加的开销。对于按引用传递参数的调用,与受限范围关联的参数的成本约为 2 ns/op,与共享范围关联的参数的成本约为 9 ns/op(这是可以预期的,因为通过更复杂的原子操作获取共享范围)。也就是说,调用由相同共享范围支持的参数的下调方法句柄不会产生任何额外的开销。

换句话说,虽然安全性是有代价的,但对于隐式和受限范围来说,这个代价相对较低;对于显式范围,这个代价较高,但可以在多个参数共享相同范围的(常见)情况下摊销。

当然,虽然我们希望这种模式成为未来的默认调用模式(因为它导致更直接和可预测的编程模型),但我们不希望这些成本在所有用例中都是可以接受的。出于这个原因,我们调整了CLinker API 以接受位掩码,该掩码可用于指定需要为该链接器实例生成的 downcall 和 upcall 启用哪些安全特性。当然,取消的安全带越多,客户端就越有可能目睹低级故障,例如 VM 崩溃,或更糟(如果客户端选择退出线程状态转换)

上调问题

虽然上面显示的机制适用于所有下调,甚至那些触发一个或多个上调到 Java 的下调,但上调可能会带来一些额外且独特的挑战:由于上调可以将内存地址返回给调用它的本地函数,因此再次出现一个问题,即与返回地址关联的范围如何在执行离开上调 Java 代码并返回本地代码后保持活动状态。如果该范围过早关闭(隐式范围的情况就是这样),则本地函数可能会尝试解除引用已关闭的内存位置。

不幸的是,这种情况并不容易处理。理想情况下,我们希望在 upcall 返回的范围和封闭范围(downcall 方法句柄调用发生的范围)之间添加一个依赖项。现在,即使抛开实现挑战(封闭范围被埋在调用 upcall 代码的本机代码下方的 Java 框架中!),我们仍然面临一个可扩展性问题:upcall 可以被调用多次:想想 qsort,它调用其比较器函数多次以对给定数组进行排序。如果我们必须跟踪 upcall 每次调用的每个返回范围,我们最终可能会在封闭 downcall 范围上添加大量依赖项(每个添加的依赖项都有内存成本 - 尽管很小)。这似乎是不可取的。

现在,返回由 upcall 内部创建的内存支持的内存地址的 upcall 比较少见:在这种情况下,upcall 将内存区域传回调用它的本机函数,并且,据推测,该本机函数将负责在完成后释放内存,这似乎是一个奇怪的用例。在这种情况下,在本地函数本身中进行分配会更安全,因为无法保证本地函数知道如何安全地释放该区域 - 例如,如果 upcall 使用的内存分配器与本地函数期望的不同。

基于此观察,我们认为可能有空间做出一个简化假设:当某些 upcall Java 代码返回由某个范围支持的内存地址时,Foreign Linker 运行时会插入一个附加检查,以确保与返回地址关联的范围确实是全局范围(这意味着与地址关联的内存不受 Foreign Memory Access 运行时管理)。此限制仍将支持常见用例,例如

  • 返回指向由普通 malloc 支持的内存区域的指针的 upcall,因为 CLinker.allocateMemory 返回由全局范围支持的内存地址;
  • 返回作为参数接收的内存地址之一的 upcall(可能添加了一些偏移量) - 同样,传递给 upcall 的所有地址都由全局范围支持。

与之前一样,此限制虽然默认启用,但如果在某些情况下过于严格,则可以选择禁用它 - 尽管我们确实相信,在实践中,此类情况应该很少见。

结论

使用由显式可关闭范围支持的资源对与 Foreign Memory Access/Linker API 交互的客户端提出了额外的挑战。在 API 的当前迭代中,可以通过使用低级 acquire/release 方法来解决此类挑战,这些方法允许将资源范围暂时设为不可关闭。在此说明中,我们展示了通过对资源范围之间的临时依赖关系进行建模,如何出现更自然的编程模型。然后,我们展示了如何将此概念应用于 downcall 方法句柄,特别是,以确保传递给 downcall 方法句柄的指针关联的内存不会过早释放。通过增强 downcall 方法句柄调用的安全性,我们不仅减少了 JVM 意外崩溃的可能性,而且还为安全支持进一步增强铺平了道路,例如围绕 dlopen/LoadLibrary 的简单 包装器,该包装器可用于在给定范围内加载(和卸载)本机库,而无需通常与 JNI 库加载相关的限制。

~