memory access - pulling all the threads
Maurizio Cimadamore
maurizio.cimadamore at oracle.com
Mon Jan 25 17:52:10 UTC 2021
Hi,
as you know, I've been looking at both internal and external feedback on
usage of the memory access API, in an attempt to understand what the
problem with the API are, and how to move forward. As discussed here
[1], there are some things which work well, such as structured access,
or recent addition to shared segment support (the latter seem to have
enabled a wide variety of experiments which allowed us to gather more
feedback - thanks!). But there are still some issues to be resolved -
which could be summarized as "the MemorySegment abstraction is trying to
do too many things at once" (again, please refer to [1] for a more
detailed description of the problem involved).
In [1] I described a possible approach where every allocation method
(MemorySegment::allocateNative and MemorySegment::mapFile) return a
"allocation handle", not a segment directly. The handle is the closeable
entity, while the segment is just a view. While this approach is
workable (and something very similar has indeed been explored here [2]),
after implementing some parts of it, I was left not satisfied with how
this approach integrates with respect to the foreign linker support. For
instance, defining the behavior of methods such as CLinker::toCString
becomes quite convoluted: where does the allocation handle associated
with the returned string comes from? If the segment has no pointer to
the handle, how can the memory associated to the string be closed? What
is the relationship between an allocation handle and a NativeScope? All
these questions led me to conclude that the proposed approach was not
enough, and that we needed to try harder.
The above approach does one thing right: it splits memory segments from
the entity managing allocation/closure of memory resources, thus turning
memory segments into dumb views. But it doesn't go far enough in this
direction; as it turns out, what we really want here is a way to capture
the concept of the lifecycle that is associated to one or more
(logically related) resources - which, unsurprisingly, is part of what
NativeScope does too. So, let's try to model this abstraction:
```
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
}
```
It's a very simple interface - you can basically add new cleanup actions
to it, which will be called when the scope is closed; note that
ResourceScope supports implicit close (via a Cleaner), or explicit close
(via the close method) - it can even support both (not shown here).
Armed with this new abstraction, let's try to see if we can shine new
light onto some of the existing API methods and abstractions.
Let's start with heap segments - these are allocated using one of the
MemorySegment::ofArray() factories; one of the issues with heap segments
is that it doesn't make much sense to close them. In the proposed
approach, this can be handled nicely: heap segments are associated with
a _global_ scope that cannot be closed - a scope that is _always alive_.
This clarifies the role of heap segments (and also of buffer segments)
nicely.
Let's proceed to MemorySegment::allocateNative/mapFile - what should
these factories do? Under the new proposal, these method should accept a
ResourceScope parameter, which defines the lifecycle to which the newly
created segment should be attached to. If we want to still provide
ResourceScope-less overloads (as the API does now) we can pick a useful
default: a shared, non-closeable, cleaner-backed scope. This choice
gives us essentially the same semantics as a byte buffer, so it would be
an ideal starting point for developers coming from the ByteBuffer API
trying to familiarize with the new memory access API. Note that, when
using these more compact factories, scopes are almost entirely hidden
from the client - so no extra complexity is added (compared e.g. to the
ByteBuffer API).
As it turns out, ResourceScope is not only useful for segments, but it
is also useful for a number of entities which need to be attached to
some lifecycle, such as:
* upcall stubs
* va lists
* loaded libraries
The upcall stub case is particularly telling: in that case, we have
decided to model an upcall stub as a MemorySegment not because it makes
sense to dereference an upcall stub - but simply because we need to have
a way to _release_ the upcall stub once we're done using it. Under the
new proposal, we have a new, powerful option: the upcall stub API point
can accept an user-provided ResourceScope which will be responsible for
managing the lifecycle of the upcall stub entity. That is, we are now
free to turn the result of a call to upcallStub to something other than
a MemorySegment (e.g. a FunctionPointer?) w/o loss of functionality.
Resource scopes are very useful to manage _group_ of resources - there
are in fact cases where one or more segments share the same lifecycle -
that is, they need to be all alive at the same time; to handle some of
these use cases, the status quo adds the NativeScope abstraction, which
can accept registration of external memory segment (via the
MemorySegment::handoff) API. This use case is naturally handled by the
ResourceScope API:
```
try (ResourceScope scope : ResourceScope.ofConfined()) {
MemorySegment.allocateNative(layout, scope):
MemorySegment.mapFile(... , scope);
CLinker.upcallStub(..., scope);
} // release all resources
```
Does this remove the need for NativeScope ? Not so fast: NativeScope is
used to group logically related resources, yes, but is also used as a
faster, arena-based allocator - which attempts to minimize the number of
system calls (e.g. to malloc) by allocating bigger memory blocks and
then handing over slices to clients. Let's try to model the
allocation-nature of a NativeScope with a separate interface, as follows:
```
@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
}
```
At first, it seems this interface doesn't add much. But it is quite
powerful - for instance, a client can create a simple, malloc-like
allocator, as follows:
```
NativeAllocator malloc = (size, align) ->
MemorySegment.allocateNative(size, align, ResourceScope.ofConfined());
```
This is an allocator which allocates a new region of memory on each
allocation request, backed by a fresh confined scope (which can be
closed independently). This idiom is in fact so common that the API
allows client to create these allocators in a more compact fashion:
```
NativeAllocator confinedMalloc =
NativeAllocator.ofMalloc(ResourceScope::ofConfined);
NativeAllocator sharedMalloc =
NativeAllocator.ofMalloc(ResourceScope::ofConfined);
```
But other strategies are also possible:
* arena allocation (e.g. the allocation strategy currently used by
NativeScope)
* recycling allocation (a single segment, with given layout, is
allocated, and allocation requests are served by repeatedly slicing that
very segment) - this is a critical optimization in e.g. loops
* interop with custom allocators
So, where would we accept a NativeAllocator in our API? Turns out that
accepting an allocator is handy whenever an API point needs to allocate
some native memory - so, instead of
```
MemorySegment toCString(String)
```
This is better:
```
MemorySegment toCString(String, NativeAllocator)
```
Of course, we need to tweak the foreign linker, so that in all foreign
calls returning a struct by value (which require some allocation), a
NativeAllocator prefix argument is added to the method handle, so that
the user can specify which allocator should be used by the call; this is
a straightforward change which greatly enhances the expressive power of
the linker API.
So, we are in a place where some methods (e.g. factories which create
some resource) takes an additional ResourceScope argument - and some
other methods (e.g. methods that need to allocate native segments) which
take an additional NativeAllocator argument. Now, it would be
inconvenient for the user to have to create both, at least in simple use
cases - but, since these are interfaces, nothing prevents us from
creating a new abstraction which implements _both_ ResourceScope _and_
NativeAllocator - in fact this is exactly what the role of the already
existing NativeScope is!
```
interface NativeScope extends NativeAllocator, ResourceScope { ... }
```
In other words, we have retconned the existing NativeScope abstraction,
by explaining its behavior in terms of more primitive abstractions
(scopes and allocators). This means that clients can, for the most part,
just create a NativeScope and then pass it whenever a ResourceScope or a
NativeAllocator is required (which is what is already happening in all
of our jextract examples).
There are some additional bonus points of this approach.
First, ResourceScope features some locking capabilities - e.g. you can
do things like:
```
try (ResourceScope.Lock lock = segment.scope().lock()) {
<critical operation on segment>
}
```
Which allows clients to perform segment critical operations w/o worrying
that a segment memory will be reclaimed while in the middle of the
operation. This solves the problem with async operation on byte buffers
derived from shared segments (see [3]).
Another bonus point is that the ResourceScope interface is completely
segment-agnostic - in fact, we have now a way to describe APIs which
return resources which must be cleaned by the user (or, implicitly, by
the GC). For instance, it would be entirely reasonable to imagine, one
day, the ByteBuffer API to provide an additional factory - e.g.
allocateDirect(int size, ResourceScope scope) - which gives you a direct
buffer attached to a given (closeable) scope. The same trick can
probably be used in other APIs as well where implicit cleanup has been
preferred for performance and/or safety reasons.
tl;dr;
This restacking described in this email enhances the Foreign Memory
Access API in many different ways, and allows clients to approach the
API in increasing degrees of complexity (depending on needs):
* for smoother transition, coming from the ByteBuffer API, users can
only have swap ByteBuffer::allocateDirect with
MemorySegment::allocateNative - not much else changes, no need to think
about lifecycles (and ResourceScope); GC is still in charge of deallocation
* users that want tighter control over resources, can dive deeper and
learn how segments (and other resources) are attached to a resource
scope (which can be closed safely, if needed)
* for the native interop case, the NativeScope abstraction is retconned
to be both a ResourceScope *and* a NativeAllocator - so it can be used
whenever an API needs to know how to _allocate_ or which _lifecycle_
should be used for a newly created resource
* scopes can be locked, which allows clients to write critical sections
in which a segment has to be operated upon w/o fear of it being closed
* the idiom described here can be used to e.g. enhance the ByteBuffer
API and to add close capabilities there
All the above require very little changes to the clients of the memory
access API. The biggest change is that a MemorySegment no longer
supports the AutoCloseable interface, which is instead moved to
ResourceScope. While this can get a little more verbose in case you need
a single segment, the code scales _a lot_ better in case you need
multiple segments/resources. Existing clients using jextract-generated
APIs, on the other hand, are not affected much, since they are mostly
dependent on the NativeScope API, which this proposal does not alter
(although the role of a NativeScope is now retconned to be allocator +
scope).
You can find a branch which implements some of the changes described
above (except the changes to the foreign linker API) here:
https://github.com/mcimadamore/panama-foreign/tree/resourceScope
While an initial javadoc of the API described in this email can be found
here:
http://cr.openjdk.java.net/~mcimadamore/panama/resourceScope-javadoc_v2/javadoc/jdk/incubator/foreign/package-summary.html
Cheers
Maurizio
[1] -
https://mail.openjdk.java.net/pipermail/panama-dev/2021-January/011700.html
[2] - https://datasketches.apache.org/docs/Memory/MemoryPackage.html
[3] -
https://mail.openjdk.java.net/pipermail/panama-dev/2021-January/011810.html
More information about the panama-dev
mailing list