Taming Resource Scopes

原文はこちら。
The original article was written by Maurizio Cimadamore (compiler architect at Oracle).
https://inside.java/2021/05/28/taming-resource-scopes/

Memory Access APIには、ResourceScopeという抽象化機能があります。これは関連付けられたリソースの時間的な境界を管理するために使用されます。このエントリでは、Foreign Memory Access/Linker APIの安全性とアクセス性を向上させる方法を探ります。まず、リソーススコープを維持するための既存のメカニズム (API の acquire/release メソッド) の改善を説明し、次に、ダウンコール・メソッドのハンドルの引数としてスコープに関連するリソースが渡された場合の基本的な安全性の保証をご案内します。

Acquiring resource scopes

Memory Access APIには、ResourceScopeという抽象化機能があり、これはResourceScopeに関連付けられているリソース(メモリセグメント、メモリアドレス、アップコールスタブ、可変長引数)の時間的境界を管理するために使用されます。詳細は以下をご覧ください。

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/

リソーススコープには3つの種類があります。

スコープスコープのクローズ可否スコープ管理操作の主体
(利用、クローズ)
Implicit resource scopes
(暗黙的リソーススコープ)
明示的にスコープをクローズ不可ガベージコレクターが管理
(unreachableになればクローズ)
Explicit confined resource scopes
(明示的制限リソーススコープ)
明示的にスコープをクローズ可リソーススコープを作成したスレッドのみ利用、クローズ可
Explicit shared resource scopes
(明示的共有リソーススコープ)
明示的にスコープをクローズ可任意のスレッドが利用、クローズ可

明示的制限スコープはおそらく扱いが最も簡単でしょう。スコープを作成したスレッドだけがアクセスとクローズできるため、アクセスとクローズの競合は起こりえません(興味深いことですが、これはネイティブコードとのやり取りにおける問題を排除するものではありません)。

暗黙スコープはややトリッキーですが、おそらくJava開発者にとってはそれほど驚かされるものではないでしょう。unreachableになればGCが暗黙のうちにクローズするため、暗黙スコープに関連付けられたリソースにtry/finallyブロックを使ってアクセスするコードをラップし、暗黙スコープが生存していることを確認するreachability fenceの挿入が必要な場合があります。そうは言っても、暗黙スコープを明示的にクローズできないため、複数のスレッドが安全にスコープにアクセスできます。

共有スコープは最も扱いが複雑なケースです。これらのスコープには複数スレッドが同時にアクセス、クローズできます。そのため、あるスレッドがとある共有スコープを閉じようとする際に、別のスレッドが同じ共有スコープに関連するリソースにアクセスすることが可能だったりしますこともできてしまいます。Memory Access APIの中核では、スレッドローカル・ハンドシェイクに基づいた低レベルのメカニズムを採用し、共有スコープに関連付けられたメモリセグメントへのアクセスが効率的(ロックフリーなど)かつ安全に行われるようにしています。しかし、それ以外の場合は、共有スコープを一定期間閉じられないようにするなど、ユーザーが特別な注意を払う必要があります。

JEP 312: Thread-Local Handshakes
https://openjdk.java.net/jeps/312

このため、ResourceScope APIでは2個のメソッド(acquirerelease) をそれぞれ提供しています。これらを使ってクライアントは時間的にリソーススコープをクローズしようとする試みをブロックできます。これらのメソッドは共有セグメントの存続を保証するだけでなく、アクセス対象のメモリに関連付けられているスコープがリリースする可能性を心配する必要なく、複数のメモリアクセスが発生し得るクリティカルセクションをこれらのメソッドを使って定義できます。以下はその例です。

MemorySegment segment = ...
var handle = segment.scope().acquire();
try {
   <critical section>
} finally {
   segment().scope().release(handle);
}

この例では、入力セグメントのスコープを取得しています。tryブロック内では、セグメントをクローズできません。スコープを取得すると、一意のハンドルインスタンスが生成されます。このハンドルインスタンスを使用して(finallyブロックを参照)、スコープを解放し、再びクローズ可能な状態にできます。このコードスニペットは、入力セグメントに関連するスコープに関係なく動作します(ただし、共有スコープの取得にはコストがかかります)。acquire/releaseのメカニズムは、非対称のアトミックな参照カウントのように機能し、カウントをインクリメントしたクライアントだけが(取得ハンドルを使って)カウントをデクリメントして戻すことができます。

From locks to temporal dependencies

上記のコードスニペットには何の問題もありません。ある期間、スコープをクローズできないようにする機能は非常に重要です。とはいえ、私たちはこのAPIをより使いやすく、理解しやすくすることができると考えています。これは、リソーススコープをクローズできないようにするためのプリミティブ(acquirerelease)から目をそらし、代わりに異なるリソーススコープ間の時間的な依存関係を表現するという観点から問題を定式化することで実現できます。

上記コードでは、1つ以上のスコープをクローズできないコードの領域を定義しています。しかし、私たちはすでに、コードのレキシカルスコープ・リージョンを表現できる構造を持っています。まさにそれがResourceScopeです。リソーススコープを使ってクリティカルな操作が行われるスコープを捉えるとどうでしょうか?そうすると、上記のコードを以下のように書き換えることができます。

MemorySegment segment = ...
try (ResourceScope criticalScope = ResourceScope.ofConfined()) {
    segment.scope().addCloseDependency(criticalScope);
    <critical region>
}

このコードは、先ほど示した(acquire/releaseをベースにした)コードと機能的には同じです。クリティカルな操作のためのスコープを作成し、セグメントスコープとクリティカルスコープの間に密接な依存関係を定義しています。これは、セグメントスコープがクリティカルスコープより先に閉じることができないことを意味し、事実上、try-with-resourcesブロック内でセグメントスコープを閉じることはできません。

この新しい定式化は、aquireとreleaseで表現されたものよりも上位のものです。クリティカルスコープは、(配下にはまだ存在する)acquire操作とrelease操作がいつ行われるかについての自然な境界を提供します。これにより、クライアントはカウンターの増分/減分ではなく、スコープ間の時間的な依存関係を考えることができます。つまり、クライアントはそれぞれのニーズに合わせて、任意の複雑な依存関係グラフを設定できます。

このハイレベルのソリューションは、これまでacquire/releaseで対処してきた問題に対処するための、より自然なアプローチを提供することがわかりました。このハイレベルのソリューションでは、NIO非同期操作とpooled allocatorsをサポートします。

Improve NIO channel support for buffer views over segments #512
https://github.com/openjdk/panama-foreign/pull/512
MemorySegmentPool + Allocator #509
https://github.com/openjdk/panama-foreign/pull/509

前者(acquire/releaseを使う場合)では、リソーススコープの機能(スコープの依存関係を追跡し、非同期操作が終了すると、依存するすべてのセグメントでリリースメソッドが呼び出されるようにする)をエミュレートするために、大量のコードを書く必要がありました。しかしながら、上記の提案したAPIを使うと、全てのこのコードは必要なくなります。非同期オペレーション全体のスコープを作成し、非同期オペレーションのスコープと、非同期オペレーションでタッチされたセグメントのスコープの間に一時的な依存関係を設定するだけです。pooled allocatorの場合、スコープSに関連付けられたセグメントプールがあり、スコープRで(プールに関連付けられた)allocatorを要求しているユーザーがいます。これにより、RとSの間の時間的依存関係を構成する必要があります。再度申し上げますが、ここで説明しているAPIでは、このユースケースをナチュラルに扱います。

Scopes and native calls

ダウンコール・メソッドハンドルとのやりとりは、特に(ポインタなどの)参照で渡される引数の場合に、いくつかの問題を引き起こす可能性があります。CLinkerは、MemorySegmentのような入力引数のほとんどをプリミティブ・ワードの束に分解します(その後、あるものはレジスタに、あるものはスタックスロットに渡されます)が、MemoryAddressのような引数は直接渡されます。これにはリスクがあります。ネイティブコールが完了する前に、メモリアドレス引数に関連するスコープがクローズされた場合、ネイティブコードが既に解放されたメモリ位置を参照しようとするからです。さらに悪いことに、これはあらゆる種類のスコープで起こり得ます。

  • 別スレッドが同時にexplicit shared scopeをクローズできる
  • Javaへのアップコールバックを必要とするようなダウンコールの場合、同じスレッドがexplicit confined scopeをクローズできる
  • implicit scopeはネイティブ関数実行中にunreachableになる可能性がある(CLinkerMemoryAddressを素のlong値にしてしまうため、アドレスと関連付けられたスコープの追跡ができなくなります)。

現在のCLinkerの実装では、ダウンコール・メソッドハンドルに渡されるすべてのAddressable引数に対して、到達可能性のフェンスを挿入しています。これにより、少なくとも暗黙的スコープが早々に閉じられることは防げるはずですが、明示的スコープを扱う場合には保護されません。

Native calls as critical regions

明示的スコープに関連するアドレス引数を持つダウンコール・メソッドハンドルの呼び出しの問題は、前述のクリティカルリージョンの問題と多くの特徴を共有しています。実際、明示セグメントの早期クローズに対する安全性を確保するには、ダウンコール・メソッドハンドルの呼び出し自体をクリティカルリージョンと考えることができます。CLinkerの実装では、ロジックを挿入して、ネイティブコールのスコープとネイティブコードに渡される引数のスコープの間に時間的な依存関係を追加できます。

このメカニズムではどれほどのコストがかかるのでしょうか?以下で、このエントリで説明した拡張機能をサポートするプロトタイプを使って測定した数値をご紹介します。以下のマイクロベンチマークでは、異なる引数(プリミティブ、メモリアドレス、メモリセグメント)と異なる個数の引数(1または3)を持つ多くのネイティブ関数を呼び出しています。このベンチマークに含まれるすべてのネイティブ関数の実装は、渡されたパラメータの1つを返すだけの簡単なものです。そのため、ダウンコール・メソッドのハンドル機構に関連するオーバーヘッドを測定するのは公正な方法と言えます。

(現在の実装のように)暗黙スコープだけを生かした場合、次のような数字になります。

Benchmark                                                       Mode  Cnt   Score   Error  Units
CallOverheadConstant.panama_identity                            avgt   30  10.108 ? 0.055  ns/op
CallOverheadConstant.panama_identity_memory_address_confined    avgt   30  10.032 ? 0.113  ns/op
CallOverheadConstant.panama_identity_memory_address_confined_3  avgt   30   9.973 ? 0.129  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit    avgt   30   9.751 ? 0.108  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit_3  avgt   30   9.745 ? 0.123  ns/op
CallOverheadConstant.panama_identity_memory_address_shared      avgt   30   9.944 ? 0.123  ns/op
CallOverheadConstant.panama_identity_memory_address_shared_3    avgt   30  10.083 ? 0.114  ns/op
CallOverheadConstant.panama_identity_struct_confined            avgt   30  12.342 ? 0.160  ns/op
CallOverheadConstant.panama_identity_struct_confined_3          avgt   30  12.592 ? 0.155  ns/op
CallOverheadConstant.panama_identity_struct_implicit            avgt   30  12.263 ? 0.208  ns/op
CallOverheadConstant.panama_identity_struct_implicit_3          avgt   30  12.226 ? 0.198  ns/op
CallOverheadConstant.panama_identity_struct_shared              avgt   30  12.338 ? 0.106  ns/op
CallOverheadConstant.panama_identity_struct_shared_3            avgt   30  12.515 ? 0.186  ns/op

では、時間的依存関係に関連するコストを見てみましょう。

Benchmark                                                       Mode  Cnt   Score   Error  Units
CallOverheadConstant.panama_identity                            avgt   30   9.861 ? 0.131  ns/op
CallOverheadConstant.panama_identity_memory_address_confined    avgt   30  12.891 ? 0.092  ns/op
CallOverheadConstant.panama_identity_memory_address_confined_3  avgt   30  12.703 ? 0.101  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit    avgt   30  12.025 ? 0.071  ns/op
CallOverheadConstant.panama_identity_memory_address_implicit_3  avgt   30  12.551 ? 0.360  ns/op
CallOverheadConstant.panama_identity_memory_address_shared      avgt   30  19.167 ? 0.164  ns/op
CallOverheadConstant.panama_identity_memory_address_shared_3    avgt   30  19.323 ? 0.206  ns/op
CallOverheadConstant.panama_identity_struct_confined            avgt   30  12.361 ? 0.198  ns/op
CallOverheadConstant.panama_identity_struct_confined_3          avgt   30  12.428 ? 0.178  ns/op
CallOverheadConstant.panama_identity_struct_implicit            avgt   30  12.195 ? 0.338  ns/op
CallOverheadConstant.panama_identity_struct_implicit_3          avgt   30  12.185 ? 0.208  ns/op
CallOverheadConstant.panama_identity_struct_shared              avgt   30  12.137 ? 0.192  ns/op
CallOverheadConstant.panama_identity_struct_shared_3            avgt   30  12.356 ? 0.130  ns/op

数字が示すように、そのコストは非常に小さいものです。スコープ依存性を必要としない呼び出し(プリミティブを含む呼び出しや、値で渡される構造体など)では、追加のオーバーヘッドはありません。引数の参照渡しによる呼び出しでは、制限スコープに関連付けられた引数では約2ns/op、共有スコープに関連付けられた引数では約9ns/opのコストがかかります(共有スコープの取得は、より複雑なアトミック操作によって行われるため、これは当然のことです)。とはいえ、同じ共有スコープを持つ引数を持つダウンコール・メソッドハンドルを呼び出しても、追加のオーバーヘッドは発生しません。

言い換えれば、安全性には代償が伴いますが、この代償は暗黙的スコープと制限スコープでは比較的抑えられています。明示的スコープではこの代償は高くなりますが、複数の引数が同じスコープを共有する(一般的な)ケースでは、償却することができます。

もちろん、(よりわかりやすく、予測可能なプログラミング・モデルになるため)今後はこのモードをデフォルトの呼び出しモードにしたいと考えていますが、すべてのユースケースでこのコストを許容できるとは考えていません。このため、CLinkerのAPIを調整して、ビット・マスクを受け付けるようにしました。このビット・マスクを使い、そのリンカー・インスタンスが生成するダウンコールとアップコールに対して、どの安全特性を有効にする必要があるかを指定できます。もちろん、安全ベルトが外されれば外されるほど、クライアントがVMのクラッシュなどの低レベルの障害に直面する可能性が高くなります。

Upcalls woes

前述のメカニズムは全てのダウンコール(Javaへの1個以上のアップコールを引き起こすダウンコールでも)に有効ですが、アップコールは追加のユニークな課題を提示します。それは、アップコールが呼び出したネイティブ関数にメモリアドレスを返すことができるため、実行でアップコールのJavaコードを離れてネイティブ・コードに戻ったときに、返されたアドレスに関連するスコープがどのように維持されるかという問題が再び生じます。そのスコープが早々に閉じられてしまうと(暗黙的スコープの場合)、ネイティブ関数はすでに閉じられたメモリ位置を逆参照しようとする可能性があります。

残念ながら、このケースは簡単には扱えません。理想的には、アップコールで返されたスコープと、それを囲むスコープ(ダウンコールのメソッドハンドルの呼び出しが行われるスコープ)の間に依存関係を追加したいところです。さて、実装上の課題はさておき(そもそも包含スコープは、アップコール・コードを呼び出したネイティブ・コードの下のJavaフレームの中に埋もれています)、スケーラビリティの問題に直面していることに変わりありません。アップコールは何度も呼び出される可能性があります。例えば、与えられた配列をソートするためにコンパレータ関数を何度も呼び出すqsortを考えてみてください。アップコールの各呼び出しによって返されるスコープをすべて追跡しなければならないとしたら、ダウンコールのスコープに大量の依存関係を追加してしまうことになるかもしれません(依存関係が追加されるたびにメモリコストが発生しますが、それは小さいものです)。これは望ましくありません。

アップコールの内部で作成されたメモリ(領域)に関連するメモリアドレスを返すアップコールは比較的稀です。このような場合、アップコールはメモリ領域を呼び出し元のネイティブ関数に渡しており、おそらくそのネイティブ関数は終了時にメモリを解放する役割を担っていると思われますが、これは奇妙なユースケースと思われます。このようなケースでは、(例えば、アップコールがネイティブ関数が期待するものとは異なるメモリアロケータを使用した場合など)ネイティブ関数が領域を安全に解放する方法を知っているという保証はないため、ネイティブ関数の中で割り当てを行う方が安全です。

この観察結果に基づいて、単純化した仮定を立てる余地があると考えています。アップコールのJavaコードがあるスコープに関連するメモリ・アドレスを返すとき、Foreign Linkerランタイムが追加のチェックを挿入し、返されたアドレスに関連付けられたスコープが実際にグローバル・スコープであること(つまり、そのアドレスに関連付けられたメモリがForeign Memory Accessランタイムによって管理されていないこと)を確認しようとします。この制限は、以下のような一般的なユースケースをサポートします。

  • ふつうのmallocで管理されているメモリ領域へのポインタを返すアップコール。これは、CLinker.allocateMemoryがグローバルスコープで管理されているメモリアドレスを返すためです。
  • (おそらくオフセットが加わっている) 引数として受け取ったメモリ・アドレスの 1 つを返すアップコール 。この場合も、アップコールに渡されるすべてのアドレスはグローバル・スコープで管理されています。

前述のように、この制限はデフォルトでは有効ですが、特定のケースで制限が強すぎることが判明した場合には、選択的に無効にすることができますが、実際にはそのようなケースは稀であると考えています。

Conclusions

明示的にクローズできるスコープを持つリソースを扱うことは、Foreign Memory Access/Linker APIを使用するクライアントにとって新たな課題となります。現在のAPIでは、リソースのスコープを一時的に閉じられないようにする低レベルのacquire/releaseメソッドを使用することで、このような課題に対処できます。この記事では、リソーススコープ間の時間的な依存関係をモデル化することで、より自然なプログラミングモデルが生まれることを示しました。そして、この概念をダウンコール・メソッド・ハンドルにどのように適用できるかを示しました。具体的には、ダウンコール・メソッド・ハンドルに渡されたポインタに関連付けられたメモリが早急に解放されないようにすることを確実にするためです。ダウンコールのメソッドハンドル呼び出しの安全性を高めることで、JVMの偽のクラッシュの可能性を減らすだけでなく、さらなる機能強化を安全にサポートする道を開きます。その例がdlopen/LoadLibraryのシンプルなラッパーで、これを使うと、JNIライブラリの読み込みに一般的に伴う制限なしに、指定されたスコープでネイティブ・ライブラリをロード(およびアンロード)できます。

/*
* Copyright (c) 2021, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit http://www.oracle.com if you need additional information or have any
* questions.
*/
import jdk.incubator.foreign.CLinker;
import jdk.incubator.foreign.FunctionDescriptor;
import jdk.incubator.foreign.MemoryAddress;
import jdk.incubator.foreign.ResourceScope;
import jdk.incubator.foreign.SymbolLookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
import java.util.Optional;
class DlOpen {
static final MethodHandle DL_OPEN = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("dlopen").get(),
MethodType.methodType(MemoryAddress.class, MemoryAddress.class, int.class),
FunctionDescriptor.of(CLinker.C_POINTER, CLinker.C_POINTER, CLinker.C_INT));
static final MethodHandle DL_SYM = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("dlsym").get(),
MethodType.methodType(MemoryAddress.class, MemoryAddress.class, MemoryAddress.class),
FunctionDescriptor.of(CLinker.C_POINTER, CLinker.C_POINTER, CLinker.C_POINTER));
static final MethodHandle DL_CLOSE = CLinker.getInstance().downcallHandle(
CLinker.systemLookup().lookup("dlclose").get(),
MethodType.methodType(int.class, MemoryAddress.class),
FunctionDescriptor.of(CLinker.C_INT, CLinker.C_POINTER));
private static MemoryAddress dlopen(MemoryAddress libName, int dlopenOptions) {
try {
return (MemoryAddress)DL_OPEN.invokeExact(libName, dlopenOptions);
} catch (Throwable ex) {
throw new IllegalStateException();
}
}
private static MemoryAddress dlsym(MemoryAddress handle, MemoryAddress symbolName) {
try {
return (MemoryAddress)DL_SYM.invokeExact(handle, symbolName);
} catch (Throwable ex) {
throw new IllegalStateException();
}
}
private static int dlclose(MemoryAddress handle) {
try {
return (int)DL_CLOSE.invokeExact(handle);
} catch (Throwable ex) {
throw new IllegalStateException();
}
}
public static SymbolLookup lookup(String libraryName, ResourceScope scope) {
try (ResourceScope openScope = ResourceScope.newConfinedScope()) {
final MemoryAddress handle = dlopen(CLinker.toCString(libraryName, openScope).address(), 1);
if (handle == MemoryAddress.NULL) {
throw new IllegalArgumentException("Cannot find library: " + libraryName);
}
scope.addCloseAction(() -> dlclose(handle));
return name -> {
try (ResourceScope lookupScope = ResourceScope.newConfinedScope()) {
MemoryAddress sym = dlsym(handle, CLinker.toCString(name, lookupScope).address());
return sym == MemoryAddress.NULL ?
Optional.empty() : Optional.of(sym);
}
};
}
}
public static void main(String[] args) {
// quick test
try (ResourceScope scope = ResourceScope.newConfinedScope()) {
SymbolLookup clib = DlOpen.lookup("libc.so.6", scope);
System.out.println(clib.lookup("qsort"));
} // library unloaded here
}
}
view raw DlOpen.java hosted with ❤ by GitHub

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中