外部内存访问 - 汇集所有线程

简而言之

本文档中描述的重新堆叠以多种不同方式增强了外部内存访问 API,并允许客户端以越来越复杂的程度(取决于需求)使用 API

  • 为了实现更平滑的过渡,从 ByteBuffer API 过来的用户只能用 MemorySegment::allocateNative 交换 ByteBuffer::allocateDirect - 其他变化不大,无需考虑生命周期(和 ResourceScope);GC 仍然负责释放
  • 想要对资源进行更严格控制的用户可以深入了解如何将段(和其他资源)附加到资源范围(如果需要,可以安全地关闭该范围)
  • 对于本机互操作情况,NativeScope 抽象被重新定义为 ResourceScope NativeAllocator - 因此,只要 API 需要知道如何分配或为新创建的资源使用哪个生命周期,就可以使用它
  • 范围可以锁定,这允许客户端编写关键部分,在这些部分中必须对段进行操作,而无需担心它被关闭
  • 此处描述的习惯用法可用于例如增强 ByteBuffer API 并在其中添加关闭功能

以上所有内容对内存访问 API 的客户端几乎不需要进行任何更改。最大的更改是 MemorySegment 不再支持 AutoCloseable 接口,该接口已移至 ResourceScope。虽然在需要单个段时这可能会变得有点冗长,但如果你需要多个段/资源,则代码的扩展性会好很多。另一方面,使用 jextract 生成的 API 的现有客户端不会受到太大影响,因为它们主要依赖 NativeScope API,而此提议不会更改该 API(尽管 NativeScope 的角色现在被重新定义为分配器 + 范围)。

详细说明…

如你所知,我一直在查看有关内存访问 API 使用情况的内部和外部反馈,以尝试了解 API 的问题以及如何继续前进。正如此处 [1]所讨论的,有些事情运行良好,例如结构化访问或最近添加的共享段支持(后者似乎启用了各种实验,使我们能够收集更多反馈 - 谢谢!)。但仍有一些问题需要解决 - 可以概括为“MemorySegment 抽象试图一次完成太多事情”(同样,请参阅 [1]以获取所涉及问题的更详细描述)。

[1] 中,我描述了一种可能的方法,其中每个分配方法(MemorySegment::allocateNativeMemorySegment::mapFile)返回“分配句柄”,而不是直接返回段。句柄是可关闭实体,而段只是视图。虽然这种方法是可行的(并且这里 [2] 确实已经探索了非常类似的方法),但在实现其中某些部分后,我对这种方法与外部链接器支持的集成方式并不满意。例如,定义诸如 CLinker::toCString 之类的方法的行为变得相当复杂:与返回的字符串关联的分配句柄来自哪里?如果段没有指向句柄的指针,如何关闭与字符串关联的内存?分配句柄和 NativeScope 之间的关系是什么?所有这些问题让我得出结论,所提出的方法还不够,我们需要更加努力。

上述方法做了一件正确的事情:它将内存段从管理内存资源的分配/关闭实体中分离出来,从而将内存段变成哑视图。但它在这方面做得还不够远;事实证明,我们真正想要的是一种方法来捕获与一个或多个(逻辑相关的)资源关联的生命周期概念 - 毫不奇怪,这也是 NativeScope 所做的一部分。因此,让我们尝试对这个抽象进行建模

interface ResourceScope extends AutoCloseable {
   void addOnClose(Runnable) // adds a new cleanup action to this scope
   void close() // closes the scope

   static ResourceScope ofConfined() // creates a confined resource scope
   static ResourceScope ofShared() // creates a shared resource scope
   static ResourceScope ofConfined(Cleaner) // creates a confined resource scope - managed by cleaner
   static ResourceScope ofShared(Cleaner) // creates a shared resource scope - managed by cleaner
}

这是一个非常简单的界面 - 你基本上可以向其中添加新的清理操作,这些操作将在关闭范围时调用;请注意,ResourceScope 支持隐式关闭(通过 Cleaner)或显式关闭(通过 close 方法) - 甚至可以同时支持两者(此处未显示)。

有了这个新的抽象,让我们尝试看看是否可以对一些现有的 API 方法和抽象进行新的阐释。

让我们从堆段开始 - 这些段使用 MemorySegment::ofArray() 工厂之一进行分配;堆段的一个问题是关闭它们没有多大意义。在所提出的方法中,可以很好地处理这个问题:堆段与无法关闭的全局范围关联 - 一个始终处于活动状态的范围。这很好地阐明了堆段(以及缓冲区段)的作用。

让我们继续 MemorySegment::allocateNative/mapFile - 这些工厂应该做什么?根据新的提议,这些方法应该接受 ResourceScope 参数,该参数定义新创建的段应附加到的生命周期。如果我们仍想提供不带 ResourceScope 的重载(如 API 现在所做的那样),我们可以选择一个有用的默认值:一个共享的、不可关闭的、由清理程序支持的范围。此选择为我们提供了与字节缓冲区基本相同的语义,因此对于尝试熟悉新内存访问 API 的 ByteBuffer API 开发人员来说,这将是一个理想的起点。请注意,在使用这些更紧凑的工厂时,范围几乎完全对客户端隐藏 - 因此不会增加额外的复杂性(例如,与 ByteBuffer API 相比)。

事实证明,ResourceScope 不仅对段有用,而且对需要附加到某些生命周期的一系列实体也有用,例如

  • 上调存根
  • va 列表
  • 已加载库

上调存根案例尤其说明:在该案例中,我们决定将上调存根建模为 MemorySegment,原因并非对上调存根进行取消引用有意义,而是因为我们需要一种方法来在使用完上调存根后释放上调存根。根据新提案,我们有一个新的强大选项:上调存根 API 点可以接受用户提供的 ResourceScope,该 ResourceScope 负责管理上调存根实体的生命周期。也就是说,我们现在可以自由地将 upcallStub 调用结果转换为 MemorySegment 以外的其他内容(例如 FunctionPointer?),而不会损失功能。

Resource Scope 非常适用于管理资源,事实上,存在一个或多个段共享相同生命周期的情况,也就是说,它们需要同时全部处于活动状态;为了处理其中一些用例,现状添加了 NativeScope 抽象,该抽象可以接受外部内存段的注册(通过 MemorySegment::handoff API)。此用例自然由 ResourceScope API 处理

try (ResourceScope scope : ResourceScope.ofConfined()) {
    MemorySegment.allocateNative(layout, scope):
    MemorySegment.mapFile(… , scope);
    CLinker.upcallStub(… , scope);
} // release all resources

这是否消除了对 NativeScope 的需求?不要这么快:NativeScope 用于对逻辑相关的资源进行分组,是的,但也用作更快的基于竞技场的分配器,它尝试通过分配更大的内存块然后将切片移交给客户端来最大程度减少系统调用的次数(例如,对 malloc)。让我们尝试使用单独的接口对 NativeScope 的分配特性进行建模,如下所示

@FunctionalInterface
interface NativeAllocator {
    MemorySegment allocate(long size, long align);
    default allocateInt(MemoryLayout intLayout, int value) { … }
    default allocateLong(MemoryLayout intLayout, long value) { … }
    … // all allocation helpers in NativeScope
}

起初,似乎此接口并没有增加太多内容。但它非常强大,例如,客户端可以创建简单的类似 malloc 的分配器,如下所示

NativeAllocator malloc = (size, align) -> 
     MemorySegment.allocateNative(size, align, ResourceScope.ofConfined());

这是一个分配器,它在每次分配请求时分配一个新的内存区域,由新的受限范围(可以独立关闭)作为后盾。事实上,此惯用语非常常见,以至于 API 允许客户端以更紧凑的方式创建这些分配器

NativeAllocator confinedMalloc = NativeAllocator.ofMalloc(ResourceScope::ofConfined);
NativeAllocator sharedMalloc = NativeAllocator.ofMalloc(ResourceScope::ofConfined);

但其他策略也是可能的

  • 竞技场分配(例如 NativeScope 当前使用的分配策略)
  • 回收分配(分配一个具有给定布局的单个段,并通过重复切片该段来满足分配请求),这是循环等中的关键优化
  • 与自定义分配器的互操作

那么,我们将在 API 中哪里接受 NativeAllocator?事实证明,每当 API 点需要分配一些本机内存时,接受分配器都很方便,因此,而不是

MemorySegment toCString(String)

这样更好

MemorySegment toCString(String, NativeAllocator)

当然,我们需要调整外部链接器,以便在所有通过值返回结构(需要一些分配)的外部调用中,向方法句柄添加 NativeAllocator 前缀参数,以便用户可以指定调用应使用哪个分配器;这是一个简单的更改,极大地增强了链接器 API 的表达能力。

因此,我们现在处于一个位置,其中一些方法(例如创建一些资源的工厂)采用一个附加的 ResourceScope 参数 - 而一些其他方法(例如需要分配本机段的方法)采用一个附加的 NativeAllocator 参数。现在,对于用户来说,在至少在简单的用例中创建这两个参数会不方便 - 但是,由于这些是接口,因此没有什么可以阻止我们创建一个同时实现ResourceScopeNativeAllocator 的新抽象 - 事实上,这正是已经存在的 NativeScope 的作用!

interface NativeScope extends NativeAllocator, ResourceScope { … }

换句话说,我们通过根据更原始的抽象(范围和分配器)来解释其行为,重新连接了现有的 NativeScope 抽象。这意味着客户端在大多数情况下只需创建一个 NativeScope,然后在需要 ResourceScope 或 NativeAllocator 时传递它(这正是我们所有 jextract 示例中已经发生的情况)。

这种方法还有一些额外的优点。

首先,ResourceScope 具有某些锁定功能 - 例如,您可以执行类似以下操作:

try (ResourceScope.Lock lock = segment.scope().lock()) {
    <critical operation on segment>
}

这允许客户端执行段关键操作,而无需担心在操作过程中回收段内存。这解决了从共享段派生的字节缓冲区的异步操作问题(请参见 [3])。

另一个优点是 ResourceScope 接口完全与段无关 - 事实上,我们现在有一种方法来描述返回必须由用户(或隐式地由 GC)清理的资源的 API。例如,完全可以想象有一天 ByteBuffer API 提供一个附加工厂 - 例如 allocateDirect(int size, ResourceScope scope) - 它为您提供附加到给定(可关闭)范围的直接缓冲区。同样的技巧可能也适用于其他 API,其中出于性能和/或安全原因而首选隐式清理。

资源

您可以在 此处 找到一个实现了上述一些更改的分支(不包括对外部链接器 API 的更改),而可以在 此处 找到此电子邮件中描述的 API 的初始 javadoc。