资源范围依赖项
Maurizio Cimadamore,2021 年 10 月 12 日使用 Foreign Memory API 时,客户端可能需要阻止其他客户端关闭与他们正在积极处理的资源(例如内存段)关联的范围。 ResourceScope
API 提供了一种实现此目的的方法;在 Java 17 中,添加了一对方法 — ResourceScope::acquire
和 ResourceScope::release
— 以实现该目标。在 API 的后续版本中,这些方法已演变为一个单一方法,即 ResourceScope::keepAlive
,可以以类似的方式使用。在本文档中,我们将更详细地讨论范围依赖项,展示它们最常见的用法,并提出一种以更明确的方式对范围依赖项进行建模的新策略。
用例
在本节中,我们将介绍需要范围依赖项的一组参考示例。这组示例将帮助读者了解范围依赖项的当前用法,还将作为评估可能的替代方法的一种方式。
临界区
范围依赖项最常见的用例可能是临界区 — 即在许多资源处于活动状态时需要执行的代码块。临界区的两个相关实例是
- 外部函数调用:按引用传递的值需要在整个调用期间保持活动状态;
- 异步缓冲区读/写:当将段传递给异步 IO 操作时,我们需要确保在整个 IO 操作期间(跨多个线程)保持段处于活动状态。
资源范围依赖项允许客户端对临界区进行建模,如下所示
void m(MemorySegment m1, MemorySegment m2, MemorySegment m3) {
try (ResourceScope criticalScope = ResourceScope.newConfinedScope()) {
criticalScope.keepAlive(m1.scope());
criticalScope.keepAlive(m2.scope());
criticalScope.keepAlive(m3.scope());
// critical section
}
}
上面我们有一个方法 m
,它需要三个内存段。该方法创建一个新的受限范围 (criticalScope
),然后使用该范围来保持段的范围处于活动状态。在临界操作之后,将关闭 criticalScope
,这会使段的范围再次可关闭。
不可关闭的范围
范围依赖项的另一个常见用例是创建外部客户端无法关闭的资源。例如,一个库可能正在管理一个段,并将该段的切片返回给其客户端。一个客户端可能决定对段范围调用 close()
方法,这将作为副作用,同时关闭库生成的所有其他段。使用范围依赖项,客户端可以创建不可关闭段,如下所示
ResourceScope libScope = ReosurceScope.newConfinedScope();
MemorySegment libSegment = MemorySegment.allocateNative(..., libScope);
ResourceScope privateScope = ResourceScope.newConfinedScope();
privateScope.keepAlive(libScope);
...
MemorySegment getSlice() {
return libSegment.asSlice(...);
}
在此,该库创建一个本机段 (libSegment
),由某个范围 (libScope
) 支持。在将切片返回给客户端之前,该库会创建一个另一个私有范围 (privateScope
),用于保持 libScope
的活动状态。这样,客户端通过返回的切片关闭 libScope
的任何尝试都将因 IllegalStateException
而失败。当该库准备好释放该段时,它可以首先关闭 privateScope
,然后继续关闭 libScope
。由于外部客户端无权访问 privateScope
,因此该库以这种方式返回的所有段对客户端来说都将显示为不可关闭。
池化分配器
一些 分配器可能会尝试通过内存池回收内存分配,以加快分配速度。池化分配器通常按如下方式使用
MemoryPool pool = MemoryPool.create(ResourceScope.newSharedScope()); // creates memory pool
...
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
SegmentAllocator allocator = pool.pooledAllocator(scope); // obtains pooled allocator for 'scope'
...
} // memory returned to the pool
如上所示,内存池本身具有一个范围,以便客户端可以在需要时释放池内存。客户端通过首先创建一个范围,然后使用给定的范围从内存池请求一个池化分配器,与内存池进行交互。当客户端范围关闭时,通过池化分配器分配的内存将被返回到池中,并且可以被不同的客户端重用。
上述代码中有一个微妙的不变性:内存池范围不能在客户端范围之前关闭。如果未能强制执行此不变性,可能会导致崩溃,因为客户端会尝试访问池中不再存在的内存。
事实上,pooledAllocator
的实现利用了范围依赖性,以防止池范围过早关闭
ResourceScope poolScope = ...
SegmentAllocator pooledAllocator(ResourceScope scope) {
scope.keepAlive(poolScope);
...
}
由于客户端范围保持 poolScope
的活动状态,因此在客户端范围仍然处于活动状态时,poolScope
无法关闭。
意大利面条范围
范围依赖性是管理复杂资源生命周期的绝佳工具。上面显示的 ResourceScope::keepAlive
方法是朝着正确方向迈出的一步,因为它允许客户端推理范围之间的依赖性,而不是强迫客户端通过 ResourceScope::addCloseAction
设置复杂的获取/释放链。
也就是说,很难可视化范围依赖性的完整图形,因为本质上,所述依赖性是完全动态的,并且可以随时添加或删除。因此,由于在多线程环境中,尤其存在一个谓词允许客户端查询两个范围是否相关,因此存在问题。在大多数情况下,这意味着(如上所示的分配器示例中)客户端必须采取防御性措施,即始终在客户端范围和池范围之间添加范围依赖性。
由于我们无法检查是否有两个范围相关,因此也无法确定添加新依赖性是否会形成循环。考虑以下代码
ResourceScope scope1 = ResourceScope.newConfinedScope();
ResourceScope scope2 = ResourceScope.newConfinedScope();
ResourceScope scope3 = ResourceScope.newConfinedScope();
scope1.keepAlive(scope2);
scope2.keepAlive(scope3);
scope3.keepAlive(scope1); // whoops
scope1.close(); // error
scope2.close(); // error
scope3.close(); // error
上述代码创建了三个范围,然后设置了一个依赖性循环。添加第三个依赖性后,所有范围都将变为不可关闭,并且将无法再释放与它们关联的内存(至少无法显式释放)。更糟糕的是,客户端无法检测到该问题;添加有问题的依赖性时不会发生错误,因为检查动态更新的范围(可能由多个线程更新)图形中的循环将非常昂贵。
请注意,上述代码故意简化了;在实际使用中,段会传递给多个 API,这可能会向其范围添加依赖项;在这种情况中,用户不一定知道已创建依赖项循环。
最后,由于范围依赖项是独立设置的,因此客户端可能会设置比严格要求更多的依赖项。考虑上面显示的关键部分示例:每个段的范围都添加了一个新依赖项。如果所有段共享相同的范围会怎样?如果两个范围之间存在预先存在的依赖项会怎样?由于客户端无法回答这些问题,因此安全的方法是仅添加三个单独的依赖项,这可能是次优的,尤其是在使用共享范围时,其依赖项状态必须以原子方式更新。
输入祖先!
上一部分中描述的所有问题都有一个共同的原因:范围依赖项是完全动态的。客户端无法静态设置范围依赖项图;此外,随着依赖项的出现和消失,客户端无法询问两个范围是否相关。可以通过允许客户端在创建新范围时指定一组范围依赖项来解决这些问题;也就是说,可以创建具有零个、一个或多个祖先的范围。如果范围创建正确,则在范围关闭之前(隐式或显式),其所有祖先都无法关闭。
可以使用 ResourceScope
[1] 中的新工厂创建具有多个祖先的范围,如下所示
ResourceScope scope1 = ...
ResourceScope scope2 = ...
ResourceScope scope12 = ResourceScope.ofConfined(Set.of(scope1, scope2));
创建具有一个或多个祖先的范围可能会成功或失败,具体取决于在创建时,运行时是否可以成功建立对所有指定祖先的依赖项。在创建上述依赖范围时,还会进行一些基本验证:例如,虽然受限范围可以具有共享范围祖先,但反之则不被允许。
以这种方式设置的范围依赖项形成有向无环图;不存在循环源于这样一个事实:对祖先的依赖项只能通过创建新资源范围来表达。因此,不可能创建以自身为祖先(直接或间接)的范围。
ResourceScope
API 还可以提供一个新的谓词方法,即 ResourceScope::isAncestorOf(ResourceScope)
,如果接收器范围是参数范围的祖先,则返回 true。我们说一个范围 S1
是另一个范围 S2
的祖先,如果可以通过递归向上遍历祖先链找到从 S2
到 S1
的路径。我们还说全局范围(ResourceScope::globalScope
)是任何其他范围 S
的祖先。很容易证明这种关系既是自反的又是传递的(事实上,祖先关系定义了范围上的偏序)。
现在让我们再次回到前面讨论的一组用例,看看在使用上面所示的 API,明确捕获作用域依赖关系的情况下,如何处理这些用例。
临界区
处理关键区域很容易:客户端必须创建一个作用域,其祖先是需要保持活动状态的作用域。如果创建这样的作用域成功,则可以开始对关键部分进行如下操作
void m(MemorySegment m1, MemorySegment m2, MemorySegment m3) {
try (ResourceScope criticalScope = ResourceScope.newConfinedScope(Set.of(m1.scope(), m2.scope(), m3.scope()))) {
// critical section
}
}
这与上面显示的内容并没有太大不同,只是现在在创建关键作用域时,依赖关系是批量指定的。
不可关闭的范围
处理不可关闭的作用域并没有太大变化。库所要做的就是创建一个私有作用域,其祖先是稍后将向客户端公开的外部作用域,如下所示
ResourceScope libScope = ReosurceScope.newConfinedScope();
MemorySegment libSegment = MemorySegment.allocateNative(..., libScope);
ResourceScope privateScope = ResourceScope.newConfinedScope(Set.of(libScope));
...
MemorySegment getSlice() {
return libSegment.asSlice(...)
}
池化分配器
在内存池示例中,有两个相关的资源作用域:池作用域和客户端作用域。池作用域是长期存在的,而客户端作用域是短期存在的。每当客户端作用域关闭时,一些内存都会返回到池中。如果作用域依赖关系是静态的,则现在内存池 API 可以探测池和客户端作用域之间的关系,如下所示
ResourceScope poolScope = ...
SegmentAllocator pooledAllocator(ResourceScope scope) {
if (!poolScope.isAncestorOf(scope)) {
throw new IllegalArgumentException("Bad scope!");
}
...
}
换句话说,分配器 API 现在可以确保客户端作用域不会比池作用域存在的时间更长。当这种情况发生时,将抛出一个异常,因为客户端使用池内存是不安全的。形成一个格式良好的作用域的责任落在客户端身上
MemoryPool pool = MemoryPool.create(ResourceScope.newSharedScope());
...
try (ResourceScope scope = ResourceScope.newConfinedScope(Set.of(pool.scope()))) {
SegmentAllocator allocator = pool.pooledAllocator(scope); //ok
...
}
在池作用域确实为全局作用域的特殊但常见情况下,上面的代码将简化为更简单的形式
MemoryPool pool = MemoryPool.create(ResourceScope.globalScope());
...
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
SegmentAllocator allocator = pool.pooledAllocator(scope); //ok
...
}
这里不需要作用域依赖关系——毕竟,全局作用域是所有其他可能作用域的祖先。
软依赖关系
由于 ResourceScope::addCloseAction
,仍然可以设置作用域之间更多临时关系。例如,可以在两个作用域 S1
和 S2
之间建立软依赖关系,以便关闭 S1
也关闭 S2
ResourceScope scope1 = ...
ResourceScope scope2 = ...
scope1.addCloseAction(scope2::close);
上面的代码可能会产生令人惊讶的行为:考虑 scope1
和 scope2
都是受限作用域的情况,但 scope1
与 Cleaner
实例相关联。在这种情况下,有可能与 scope1
关联的关闭操作将从清理程序线程执行。由于 scope2
也是一个受限作用域,因此从清理程序线程调用其 close
方法注定会失败。因此,除非客户端完全控制涉及的所有作用域,否则不应依赖上面代码段中所示的惯用法。
结论
在本文档中,我们探讨了将作用域之间的关系作为 ResourceScope
API 中的一级概念的扩展。此举允许以稳定方式捕获作用域依赖关系(作用域依赖关系形成一个无环图),客户端可以查询该图。后一点尤其重要,因为它赋予 API 更多权力来拒绝无效的作用域组合(请参见上面的池分配器示例)。
在 API 中明确标记作用域关系还为客户端提供了一个选项,用于推理与多个内存资源关联的生命周期。例如,将从一个段 S1
获得的内存地址存储到另一个段 S2
中是否安全?我们再次可以在上面定义的祖先关系的基础上进行构建:如果 S1
的作用域是 S2
的祖先,则存储有效,因为 S2
的客户端永远不可能观察到已释放内存位置的指针(因为 S1
只能在 S2
之后关闭)。
换句话说,本文档中描述的更改启用了更安全地使用外部内存访问 API,同时保留了以前 API 迭代的相同基本表现力。
-
虽然可能很想推断父作用域的集合,尤其是在嵌套的 try-with-resource 块中定义了两个受限作用域的情况下,
AutoCloseable
接口并不能保证例如ResourceScope
只能在 try-with-resources 块中使用——这意味着无法保证在与外部资源作用域关联的块内创建的任何资源作用域实际上都会在外部作用域之前关闭。出于这个原因,我们选择了更明确的 API 路径。 ↩