Access and NIO channels – Going Further
The Java Platform’s NIO channels currently only support I/O operations on synchronous channels with byte buffer views over confined memory segments. While somewhat of a limitation, this reflects a pragmatic solution to API constraints, while simultaneously pushing on the design of the Foreign Memory Access API itself.
With the latest evolution of the Foreign Memory Access API (targeting JDK 17), the lifecycle of memory segments is deferred to a higher-level abstraction, a resource scope. A resource scope manages the lifecycle one or more memory segments, and has several different characteristics.
This writeup introduces the resource scope abstraction, describes its characteristics, and finally how it can be leveraged to provide better interoperability with the different kind of NIO channels. While much of the details are specific to NIO channels, many of the concerns and approaches described here are general enough so might also be applied to other low-level frameworks or libraries operating with byte buffers.
A resource scope models the lifecycle of one or more associated resources, such as memory segments. A newly-created resource scope is alive, which means that all of its associated resources can be accessed safely. A resource scope can be closed, which means that access to its associated resources is no longer allowed. Once closed, all of its associated resources are freed, such as deallocation of memory associate with native memory segments. A resource scope has a number of characteristics, outlined here:
- Confined – thread confinement, only the owner thread can manipulate the resources associated with this kind of resource scope
- Shared – no thread confinement, any thread can manipulate the resources associated with this kind of resource scope
A resource scope is either a confined scope or a shared scope.
- Implicit – an implicit scope is automatically closed at some point after it becomes unreachable. Additional cleanup actions are handled after the scope has been closed. Invoking close on an implicit scope will fail.
- Explicit – an explicit scope may be closed by invoking the close method. Additional cleanup actions, if any, are handled when the scope is closed, by the thread invoking the close method. Explicit scopes can be associated with a user-provided Cleaner, to allow for resource cleanup in case the scope instance becomes unreachable and the close method has not been invoked. Either way, the scope is closed exactly once.
There are six static factory API points that allow to retrieve or create resource scope objects that correspond to a combination of the above characteristics.
NIO channels perform I/O operations with byte buffers. These byte buffers can be backed by memory in the Java heap, off-heap (direct), or views over memory segments.
There are two broad categories of NIO channels that perform (read/write) I/O operations:
- Synchronous channels – DatagramChannel, FileChannel, SocketChannel
- Asynchronous channels – AsynchronousFileChannel, AsynchronousSocketChannel
The first category, synchronous channels; read and write operations are surfaced in the API in synchronous form. An I/O operation initiated on thread T will either i) complete successfully returning an appropriate return value, or ii) throw an exception if an error occurs, either outcome occurs on thread T. When a read or write operation is invoked on a channel, the method invocation is passed a byte buffer, or aggregate of byte buffers, to read into or write from, respectively. At the point of method invocation (either read or write) there is a logical transfer of control, the passed byte buffer(s) are effectively under control of the channel until the method invocation completes, at which point control is passed back to the caller. All this occurs synchronously on thread T.
The second category, asynchronous channels; read and write operations are surfaced in the API in asynchronous form. An I/O operation initiated on thread T may schedule that I/O operation to complete at some later time, and on some thread other than T. Similar to synchronous channels, when a read or write operation is invoked on an asynchronous channel, the method invocation is passed a byte buffer, or aggregate of byte buffers, to read into or write from, respectively. At the point of method invocation (either read or write) there is a logical transfer of control, the passed byte buffer(s) are effectively under control of the channel until the operation completes, at which point control of the byte buffers is passed back to the user code. Dissimilar to synchronous channels, I/O operations on asynchronous channels commonly do not complete immediately, but at some later time and on a thread other than the thread that initiated the I/O operation.
There are practicalities of the code and a nod to “yet to be proven” micro optimizations, that influence decision making. By applying a number of small restrictions and simplifications, we can more easily write a straightforward implementation without hindering usability. These simplifications are:
Always acquire a resource scope handle for byte buffer views over segments associated with an explicit scope. As outlined above, this is not strictly necessary for synchronous channels, but will simplify the code paths. This restriction can be removed later if there is sufficient evidence that is it problematic.
For scattering and gathering I/O operations with byte buffer views over segments from multiple explicit scopes, retain the resource scope handles as a trivial linked-list-like structure of runnables/closeables. Quite often all the buffers will be from a single scope, in which case a single runnable/closeable will be sufficient.
Unconditionally acquire the resource scope handle for explicit scopes, even if the handle for a particular scope is already held. Again, this is a simplification that helps keep the code uniform, but can be revisited later if proven to be an issue.