原文はこちら。
The original article was written by Chris Hegarty (Networking Lead of the Java Platform Group, Oracle).
https://mail.openjdk.java.net/pipermail/panama-dev/2021-April/013317.html
https://inside.java/2021/04/21/fma-and-nio-channels/
現在、Java PlatformのNIOチャンネルは、限られたメモリセグメント上のバイトバッファビューを使う同期チャンネルのI/O操作のみをサポートしています。多少の制限はありますが、これはAPIの制約に対する実用的な解決策を反映したものであり、同時に外部メモリアクセスAPI自体の設計を後押しするものでもあります。
JEP 393: Foreign-Memory Access API (Third Incubator)
https://openjdk.java.net/jeps/393
Foreign Memory Access APIの最新版(JDK 17をターゲット)では、メモリセグメントのライフサイクルは、より高いレベルの抽象化であるリソース・スコープに委ねられています。
Foreign Memory Access – Pulling all the threads
https://inside.java/2021/01/25/memory-access-pulling-all-the-threads/
https://logico-jp.io/2021/04/04/foreign-memory-access-pulling-all-the-threads/
リソーススコープは、1つまたは複数のメモリセグメントのライフサイクルを管理するもので、いくつかの異なる特徴を持っています。これらの特徴について詳しく見ていきますが、特に重要なのは、共有メモリセグメントを一定期間クローズできない(non-closable)ようにする方法があるということです。このことから、NIO チャンネルで使用できるメモリセグメントの種類や、セグメントビューを使用できるチャンネルの種類について、現在の制限を見直すことができます。
この記事では、リソーススコープの抽象化を紹介し、その特徴を説明した上で、最終的にどのように活用すれば、さまざまな種類のNIOチャンネルとの相互運用性を高めることができるかを説明します。詳細の多くはNIOチャンネルに特有のものですが、ここで説明されている懸念事項やアプローチの多くは一般的なものなので、バイトバッファで動作する他の低レベルのフレームワークやライブラリにも適用できるかもしれません。
NIOチャンネルがメモリセグメント上でリソーススコープやメモリセグメントのビューを活用する方法を説明する前に、まずリソーススコープの抽象化について理解する必要があります。
Resource Scope
リソーススコープは、メモリセグメントなど、関連する1つまたは複数のリソースのライフサイクルをモデル化します。新規に作成されたリソーススコープが動作(alive)しているとは、その関連リソースすべてに安全にアクセスできることを意味します。リソーススコープをクローズできますが、これは関連するリソースへのアクセスができなくなることを意味します。クローズすると、ネイティブメモリセグメントに関連付けられたメモリの解放など、関連するすべてのリソースが解放されます。リソーススコープにはいくつかの特徴があり、その概要は以下の通りです。
1. Confinement
リソーススコープはconfinedスコープもしくはsharedスコープのいずれかです。
Confined (スレッド制限あり) | セグメントを所有するスレッドのみがこのリソーススコープに関連するリソースを操作できます |
Shared (スレッド制限なし) | どのスレッドでもこのリソーススコープに関連するリソースを操作できます |
2. Cleanup
Implicit (暗黙スコープ) | implicitスコープは、到達できなくなった後、ある時点で自動的にクローズされます。スコープがクローズされた後、追加のクリーンアップ処理が行われます。implicitスコープにcloseを実行すると失敗します。 Reachability (Package java.lang.ref) https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/lang/ref/package-summary.html#reachability |
Explicit (明示スコープ) | explicitスコープは、closeメソッドを呼び出してクローズできます。追加のクリーンアップアクションがある場合は、スコープクローズ時にcloseメソッドを呼び出したスレッドによってクリーンアップ処理されます。explicitスコープを、ユーザーが提供するCleanerと関連付けできます。 Class Cleaner https://docs.oracle.com/en/java/javase/16/docs/api/java.base/java/lang/ref/Cleaner.html これにより、スコープのインスタンスに到達できなくなり、close メソッドが呼び出されなかった場合に、リソースのクリーンアップが可能になります。いずれにしても、スコープは確実に一度だけ(exactly once)クローズされます。 |
以下の6個の静的ファクトリAPIを使うと、上記の特性の組み合わせに対応するリソーススコープオブジェクトを取得または作成できます。
API point | Confinement | Cleanup | |
---|---|---|---|
1 | newConfinedScope() | confined | explicit |
2 | newConfinedScope(Cleaner) | confined | explicit |
3 | newSharedScope() | shared | explicit |
4 | newSharedScope(Cleaner) | shared | explicit |
5 | newImplicitScope() | shared | implicit |
6 | globalScope() | shared | implicit |
implicitクリーンアップ機能を備えるconfinedリソーススコープのためのAPIポイントがないことがわかります。このスコープは、スレッドで制限され、容易に決定論的に閉じることができるため、それほど有用ではありません。
グローバルスコープはimplicitクリーンアップを備えていますが、常に確実に到達可能である(strongly reachable)ことが保証されているため、実質的には閉じることができません(したがって、閉じることはありません)。
NIO channels
NIOチャンネルは、バイトバッファを使ってI/O操作を行います。これらのバイトバッファは、Javaヒープ内のメモリ、オフヒープ(ダイレクト)、またはメモリセグメント上のビューに支持されています。
(read/write)I/O操作を行うNIOチャンネルには、大きく分けて2種類あります。
同期チャネル | DatagramChannel |
非同期チャネル | AsynchronousFileChannel |
同期チャンネルでは、読み取りと書き込みの操作が同期形式でAPIに現れます。スレッドTで開始されたI/O操作は、
- 適切な戻り値を返して正常に完了する
- エラーが発生した場合は例外をスローする
のいずれかの結果がスレッドT
で発生します。チャネルでreadまたはwrite操作が呼び出されると、メソッド呼び出しに読み取りまたは書き込みを行うバイトバッファまたはバイトバッファの集合体を渡します。(read
またはwrite
)メソッドの呼び出しの時点で、制御の論理的な移行が行われ、渡されたバイトバッファは、メソッドの呼び出しが完了するまで、実質的にチャネルの制御下に置かれ、メソッド呼出しが完了すると制御は呼び出し側に戻されます。これらはすべてスレッドT
上で同期的に行われます。
非同期チャンネルでは、readとwriteの操作は非同期の形でAPIに現れます。スレッドT
で開始されたI/O操作は、そのI/O操作を後の時点で、しかもT
以外のスレッドで完了するようにスケジュールできます。同期チャンネルと同様に、非同期チャンネルでreadまたはwrite操作が呼び出されると、メソッド呼び出しに対し、readまたはwriteのためのバイトバッファ、またはバイトバッファの集合体を渡します。(read
またはwrite
)メソッド呼び出しの時点で、論理的な制御の移行が行われます。渡されたバイトバッファは、操作が完了するまで実質的にチャネルの制御下に置かれ、その時点でバイトバッファの制御はユーザーコードに戻されます。同期チャネルとは異なり、非同期チャネルでのI/O操作は、通常、すぐには完了せず、後になって、I/O操作を開始したスレッドとは別のスレッドで完了します。
前章で説明した、NIOチャンネルの2つのカテゴリーとリソーススコープの様々な特徴を理解した上で、以後でこれらをどのように組み合わせて使うかを説明します。
Synchronous channels
同期チャンネルは、非同期チャンネルよりも簡単です。なぜなら、「すべてのアクション」が単一のスレッド上で同期的に行われるからです。つまり、I/O操作が開始され、バイトバッファの制御がチャンネルに引き渡されると、バイトバッファは他のユーザコードの影響を受けなくなるはずです。もしそうであれば、ユーザコードのバグでしょう。しかしながら、Javaプラットフォームは、絶対にクラッシュしないという強力な安全性を保証しています。この保証を守るために,同期チャネルの実装では,バイトバッファに格納されたメモリにアクセスする際に,自分自身を保護する必要があります.
ByteBufferクラスのファクトリーメソッドで作成された通常のバイトバッファには、決定論的な解放機能はありません。つまり、バッファが到達不能になったときにのみ、バッファの裏のメモリが解放されます。チャネルの実装は、I/O操作の期間中、バッファへの強力な参照を保持すると、バッファの裏付けとなるメモリが解放されないことを確認できます。
メモリセグメント上のバイトバッファビューの場合は少し複雑になります。というのも、セグメントを支えるメモリがリソーススコープに関連付けられているためです。implicitスコープに関連付けられたセグメントは明示的にクローズできないため、通常のバイトバッファと同様に、バッファへの強い参照(およびリソーススコープへの推移的な参照)を保持するだけで十分です。confinedスコープを持つメモリセグメントは、セグメントを所有するスレッドによってのみアクセスできます。そのため、バッファの制御がチャネルに移されると、I/O操作の間、他のスレッドによって閉じられることはありません。ここまではいいですね。残るは、sharedスコープに関連付けられたメモリセグメント上のバイトバッファビューです。この手のバッファの場合、チャネルの実装はリソーススコープハンドルを取得して、スコープを一時的に閉じられないようにできます。これにより、I/O操作が行われている間、スコープに関連付けられたリソースのセグメントを支えるメモリが解放されなくなります。その後、リソーススコープハンドルは解放されます。
これにより、同期チャネルでは、さまざまな種類のリソーススコープに関連付けられたセグメントのバイトバッファビューでI/O操作を実行できます。現在(JDK 16)、同期チャネルは制限されたメモリセグメント上のバイトバッファビューでのI/O操作しかサポートしていませんが、この制限を取り除くことができます。
Asynchronous channels
非同期チャンネルは、あるスレッドで開始されたI/O操作が他のスレッドで完了することが多いため、定義上スレッド制限とは相反するものです。そのため、NIOの非同期チャネルは、スレッド制限スコープ(confinedスコープ)に関連付けられたセグメント上のバイトバッファビューとはうまく機能しません。実際、そのようなバイトバッファで開始されたI/O操作は失敗します(適切な詳細メッセージを持つ例外がスローされる)。残るはsharedスコープだけです。
implicitスコープに関連付けられたセグメント上のバイトバッファビューでは、同期チャネルと同様に、バッファへの(そしてリソーススコープへの)強力な参照を保持するチャネルの実装が必要です。これにより、I/O操作を開始したスレッドとは別のスレッドでI/O操作が完了したかどうかに関わらず、I/O操作が未処理の間にメモリが解放されるのを防ぎます。explicitスコープに関連付けられたセグメントのバイトバッファビューは、これまた同期チャネルと同様に、リソーススコープハンドルを取得して、スコープを一時的にクローズされないようにできます。これにより、I/O操作の間、スコープに関連付けられたリソースのメモリ解放を防ぎ、その後、ハンドルを解放してスコープを再びクローズできます。ハンドルの取得と解放は、異なるスレッドで行うことができます。
これにより、非同期チャネルは、(confiedスコープではなく)sharedリソーススコープに関連付けられたセグメント上のバイトバッファビューでI/O操作を行うことができます。これは非同期チャネルがセグメント上のバイトバッファビューでのI/O操作をサポートしていないJDK 16に対する改善です。
Implementation details
ここまでは、リソーススコープに関連付けられたセグメント上のバッファビューと、異なる種類のチャンネルがどのように相互運用できるかを説明してきましたが、(いつものように)コードの実用性や、「まだ実証されていない」といった木を見て森を見ない微視的最適化が判断に影響を与えます。数多くの小さな制限と簡略化を適用することで、使い勝手を損なわずにより簡単にストレートな実装を書くことができます。これらの簡略化は以下の通りです。
- explicitスコープに関連付けられたセグメントのバイトバッファビューに対して、常にリソーススコープハンドルを取得する。前述の通り、これは同期チャンネルでは厳密には不要だが、コードパスを単純化できる。この制限が問題になるという十分な証拠がある場合、後で削除できる。
- 複数のexplicitスコープからのセグメント上のバイトバッファビューを使用したscattering I/Oと gathering I/O操作(分散および収集のI/O操作)では、リソーススコープハンドルをrunnables/closeablesの普通のlinked listのような構造として保持する。すべてのバッファが1つのスコープからのものであることが多く、その場合は1つのrunnable/closeableで十分である。
- 特定のスコープのハンドルをすでに保持している場合でも、explicitスコープのリソーススコープハンドルを無条件に取得する。繰り返しになるが、これはコードの統一性を保つための単純化であるが、問題があることが判明した場合には、後で見直すことができる。
I/O操作の実行(scattering/gathering)の様々な側面を比較するマイクロベンチマークを使い、実装のパフォーマンスを調査します。
以下のPull Requestでは、コードの変更を追跡しています。
Improve NIO channel support for buffer views over segments
https://github.com/openjdk/panama-foreign/pull/512
Conclusion
PanamaのForeign Memory Access API(JDK 17)の機能強化、特にリソーススコープの抽象化により、メモリセグメントとNIOチャンネル上のバイトバッファビューの相互運用性が大幅に向上します。
JDK 17
https://openjdk.java.net/projects/jdk/17/
NIOチャネルの実装では、チャネルが提供するプログラミングモデルに論理的に適用可能な、すべてのバイトバッファビューをサポートできるようになりました。