DUICUO

Alibaba のオープンソース TransmittableThreaLocal の原理と使用方法を理解するための包括的なガイド。

今日は、Alibaba の TTL、つまり TransmittableThreadLocal についてお話しましょう。

親スレッドと子スレッド間でパラメータを受け渡す場合、通常はInheritableThreadLocalが使用されます。親スレッドと子スレッド間でパラメータを受け渡すためのInheritableThreadLocalの実装方法の詳細については、以前公開したこちらの記事を参照してください。

InheritableThreadLocal によってすでに親スレッドと子スレッド間のパラメータの受け渡しが可能になっているのに、なぜ Alibaba が独自の TransmittableThreadLocal をオープンソース化する必要があったのかと疑問に思う学生もいるかもしれません。

TransmittableThreadLocal がどのような問題を解決するのかを説明しましょう。

バージョン: TransmittableTreadLocal v2.14.5

コード例には `remove` 操作は含まれていませんが、実際に使用する際には必ず実行してください。この記事のコード例に `remove` メソッドを追加しても、テスト結果には影響しません。

1. TransmittableThreadLocal はどのような問題を解決しますか?

まず、次の質問を考えてみましょう。ビジネス開発では、このタスクを非同期的に実行するためにどのような方法を使用できますか?

  • @Asyncアノテーションを使用する
  • 新しいスレッド()
  • スレッドプール
  • MQ
  • 他の

上記の方法のうち、ここではスレッドベースの方法についてのみ説明します。メッセージ キュー (MQ) などの他の方法はこの記事の範囲外です。

@Async アノテーションを使用する場合でも、スレッドまたはスレッド プールを使用する場合でも、基本的な原則は、別の子スレッドを通じて実行されることです。

@Async アノテーションの原理について詳しくない場合は、リンクをクリックして詳細を確認してください。

1つの記事で@Asyncアノテーションの原理を理解する

子スレッドなので、親スレッドと子スレッド間での変数パラメータの受け渡しをどのように実装するのでしょうか?

親スレッドと子スレッド間の変数の受け渡しは、InheritableThreadLocal を使用することで実現できます。

InheritableThreadLocal を使用して親スレッドと子スレッド間でパラメータを渡す原理については、この記事を参照してください。

InheritableThreadLocal は親スレッドと子スレッド間のローカル変数の受け渡しをどのように実装しますか?

この記事は、InheritableThreadLocal の補足として考えることができます。

new Thread() を使用する場合、ThreadLocal を設定することで変数を直接渡すことができます。

ここで重要なのは、ThreadLocal では子スレッドで親スレッドから値を取得できないため、値を渡すには InheritableThreadLocal を使用する必要があることです。

ほとんどの作業シナリオではスレッド プールが使用されているため、上記の方法は依然として機能しますか?

スレッドプール内のスレッド数は指定可能で、これらのスレッドはプールされた後、繰り返し作成・再利用されます。したがって、この時点では親子スレッド関係における変数の受け渡しは意味がありません。必要なのは、タスクがスレッドプールにサブミットされた時点でのThreadLocal変数の値を、タスクを実行するスレッドに渡すことです。

InheritableThreadLocal の原則に関する記事の最後で、スレッド プールのパラメータ渡し方法について説明しました。これは本質的に、InheritableThreadLocal を介した変数の受け渡しです。

Alibaba の TransmittableThreadLocal クラスは、InheritableThreadLocal の拡張バージョンです。

TransmittableThreadLocal は、スレッド プール内のスレッドを再利用する際に、実際にビジネス ロジックを実行するスレッドに値を渡す問題を解決できるため、非同期実行時のコンテキストの受け渡しの問題を解決できます。

さらに、他の典型的なシナリオ例がいくつかあります。

  • 分散トレース システムまたはエンドツーエンドのストレス テスト (リンク タグ付け)。
  • ログ収集システムのコンテキスト。
  • セッション レベルのキャッシュ。
  • アプリケーション コンテナーまたは上位レベルのフレームワークは、アプリケーション コードと下位レベルの SDK 間で情報を渡すことができます。

II. TransmittableThreadLocal の使い方

上記では、TransmittableThreadLocal が、プールされたスレッドがスレッド プール内のスレッドを再利用するときに値の受け渡しの問題を解決するために使用できることを学習しました。

使い方を見てみましょう。

1. スレッドローカル

すべてのコード例は Spring Boot で示されています。

ThreadLocal を使用すると、次のように親スレッドと子スレッド間でパラメータを渡すことが可能です。

 @RestController @RequestMapping("/test2") public class Test2Controller { ThreadLocal<String> stringThreadLocal = new ThreadLocal<>(); @RequestMapping("/set") public Object set(){ stringThreadLocal.set("主线程给的值:stringThreadLocal"); Thread thread = new Thread(() -> { System.out.println("读取父线程stringThreadLocal的值:" + stringThreadLocal.get()); }); thread.start(); return ""; } }

起動後、/test2/set にアクセスすると次のように表示されます。

上記の出力からわかるように、親スレッドからの値は読み取られませんでした。

したがって、親子間でのパラメータの受け渡しを実現するには、ThreadLocal を InheritableThreadLocal に変更する必要があります。

2. 継承可能なスレッドローカル

変更されたコードは次のようになります。

 @RestController @RequestMapping("/test2") public class Test2Controller { ThreadLocal<String> stringThreadLocal = new ThreadLocal<>(); ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>(); @RequestMapping("/set") public Object set(){ stringThreadLocal.set("主线程给的值:stringThreadLocal"); inheritableThreadLocal.set("主线程给的值:inheritableThreadLocal"); Thread thread = new Thread(() -> { System.out.println("读取父线程stringThreadLocal的值:" + stringThreadLocal.get()); System.out.println("读取父线程inheritableThreadLocal的值:" + inheritableThreadLocal.get()); }); thread.start(); return ""; } }

同じコマンドを実行して出力を確認します。

上記のデモ例では、 new Thread() を直接使用しました。代わりにスレッドプールを使用してみましょう。

変更されたコードを以下に示します。

 @RestController @RequestMapping("/test2") public class Test2Controller { ThreadLocal<String> stringThreadLocal = new ThreadLocal<>(); ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>(); ThreadLocal<String> transmittableThreadLocal = new TransmittableThreadLocal<>(); ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); @RequestMapping("/set") public Object set(){ for (int i = 0; i < 10; i++) { String val = "主线程给的值:inheritableThreadLocal:"+i; System.out.println("主线程set;"+val); inheritableThreadLocal.set(val); executor.execute(()->{ System.out.println("线程池:读取父线程inheritableThreadLocal 的值:" + inheritableThreadLocal.get()); }); } return ""; } }

出力も見てみましょう:

出力から、スレッド プールを使用する場合、スレッドが再利用されるため、子スレッドで親スレッドから値を取得すると、前のスレッドから値が取得されることになり、スレッドの安全性の問題が発生する可能性があるという結論に至ります。

スレッド プール内のスレッドは必ずしも毎回新しく作成されるわけではないため、InheritableThreadLocal では親子間のパラメータの受け渡しはできません。

出力が十分に明確でないと思われる場合は、子スレッドのスレッド名を出力できます。

TransmittableThreadLocal を使用して、スレッド プールで親変数と子変数を渡す問題を解決する方法を見てみましょう。

3. 送信可能なスレッドローカル

上記のコードを修正し続けます。結果は次のようになります。

変更点: TransmittableThreadLocal を使用する最初の方法は、TtlRunnable.get() を使用してカプセル化されます。

 @RestController @RequestMapping("/test2") public class Test2Controller { ThreadLocal<String> transmittableThreadLocal = new TransmittableThreadLocal<>(); ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); @RequestMapping("/set") public Object set(){ for (int i = 0; i < 10; i++) { String val = "主线程给的值:TransmittableThreadLocal:"+i; System.out.println("主线程set3;"+val); transmittableThreadLocal.set(val); executor.execute(TtlRunnable.get(()->{ System.out.println("线程池线程:"+Thread.currentThread().getName()+ "读取父线程TransmittableThreadLocal 的值:" + transmittableThreadLocal.get()); })); } return ""; } }

実行結果は以下の通りです。

ログ出力を見ると、少数の値のみを使い続ける InheritableThreadLocal とは異なり、子スレッドは親スレッドで設定されたすべての値を出力していることがわかります。

TransmittableThreadLocal は、スレッド プール内のスレッドを再利用する際に、実際にビジネス ロジックを実行するスレッドに値を渡す問題を解決し、非同期実行時のコンテキスト受け渡しの問題を解決できると結論付けることができます。

それで、これですべてですか?使い方はとても簡単で、Runnableをラップするだけです。ThreadLocalに格納されているString型の値をMapに変換してみましょう。

III. TransmittableThreadLocalにおけるディープコピー

ThreadLocalに格納されている値をMapに変更し、変更後のコードは以下のようになります。

 @RestController @RequestMapping("/test2") public class Test2Controller { ThreadLocal<Map<String,Object>> transmittableThreadLocal = new TransmittableThreadLocal<>(); ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); @RequestMapping("/set") public Object set(){ Map<String, Object> map = new HashMap<>(); map.put("mainThread","主线程给的值:main"); System.out.println("主线程赋值:"+ map); transmittableThreadLocal.set(map); executor.execute(TtlRunnable.get(()->{ System.out.println("线程池线程:"+Thread.currentThread().getName()+ "读取父线程TransmittableThreadLocal 的值:" + transmittableThreadLocal.get()); })); return ""; } }

API 呼び出しの結果は次のようになります。

ご覧の通り、問題はありません。では、コードに少し修正を加えてみましょう。

  • メイン スレッドが子スレッドにタスクを送信した後、ThreadLocal 値を再度変更します。
  • 子スレッドの ThreadLocal の値を変更します。

変更されたコードを以下に示します。

 @RestController @RequestMapping("/test2") public class Test2Controller { ThreadLocal<Map<String, Object>> transmittableThreadLocal = new TransmittableThreadLocal<>(); ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); @RequestMapping("/set") public Object set() { Map<String, Object> map = transmittableThreadLocal.get(); if (null == map) {map = new HashMap<>();} map.put("mainThread", "主线程给的值:main"); System.out.println("主线程赋值:" + map); transmittableThreadLocal.set(map); executor.execute(TtlRunnable.get(() -> { System.out.println("子线程输出:" + Thread.currentThread().getName() + "读取父线程TransmittableThreadLocal 的值:" + transmittableThreadLocal.get()); Map<String, Object> childMap = transmittableThreadLocal.get(); if (null == childMap){childMap = new HashMap<>();} childMap.put("childThread","子线程添加值"); })); Map<String, Object> stringObjectMap = transmittableThreadLocal.get(); if (null == stringObjectMap) { stringObjectMap = new HashMap<>(); } stringObjectMap.put("mainThread-2", "主线程第二次赋值"); transmittableThreadLocal.set(stringObjectMap); try{ Thread.sleep(1000); }catch (InterruptedException e){e.printStackTrace();} System.out.println("主线程第二次输出ThreadLocal:"+transmittableThreadLocal.get()); return ""; } }

API 呼び出しの出力は次のとおりです。

ログ出力は、ThreadLocal がオブジェクトを保存すると、親スレッドと子スレッドが同じオブジェクトを共有することを示します。

つまり、親スレッドと子スレッドの両方が同じマップを保持しているため、親スレッドと子スレッドによる変更は可視となります。親スレッドが値を再度設定すると、同じマップが変更されているため、子スレッドもその値を読み取ることができます。

これは特別な注意が必要な点です。厳密なビジネスロジックがあり、同じThreadLocalを共有する場合は、このスレッドセーフティの問題に注意する必要があります。

では、これをどのように解決するのでしょうか? ディープコピーを使用することで、親スレッドと子スレッドが独立し、変更中に同じオブジェクトを共有することを防ぎます。

`TransmittableThreadLocal`クラスには、親スレッドから値をコピーする`copy`メソッドがあります。このメソッドを使うことで、親スレッドのオブジェクトではなく、新しいオブジェクトを返すことができます。コードの変更は以下のとおりです。

コピー方式を使用する理由については後ほど説明します。

 @RestController @RequestMapping("/test2") public class Test2Controller { ThreadLocal<Map<String, Object>> transmittableThreadLocal = new TransmittableThreadLocal(){ @Override public Object copy(Object parentValue) { return new HashMap<>((Map)parentValue); } }; ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 5, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); @RequestMapping("/set") public Object set() { Map<String, Object> map = transmittableThreadLocal.get(); if (null == map) {map = new HashMap<>();} map.put("mainThread", "主线程给的值:main"); System.out.println("主线程赋值:" + map); transmittableThreadLocal.set(map); executor.execute(TtlRunnable.get(() -> { System.out.println("子线程输出:" + Thread.currentThread().getName() + "读取父线程TransmittableThreadLocal 的值:" + transmittableThreadLocal.get()); Map<String, Object> childMap = transmittableThreadLocal.get(); if (null == childMap){childMap = new HashMap<>();} childMap.put("childThread","子线程添加值"); })); Map<String, Object> stringObjectMap = transmittableThreadLocal.get(); if (null == stringObjectMap) { stringObjectMap = new HashMap<>(); } stringObjectMap.put("mainThread-2", "主线程第二次赋值"); transmittableThreadLocal.set(stringObjectMap); try{ Thread.sleep(1000); }catch (InterruptedException e){e.printStackTrace();} System.out.println("主线程第二次输出ThreadLocal:"+transmittableThreadLocal.get()); return ""; } }

変更点は次のとおりです。

API を呼び出して実行結果を確認すると、親スレッドと子スレッドによって行われた変更が独立したオブジェクトによって行われ、共有されなくなったことがわかります。

ここまでで、TransmittableThreadLocalの使い方がわかったと思います。では、TransmittableThreadLocalが実際に親スレッドと子スレッド間でどのように変数を渡すのか見ていきましょう。

IV. 送信可能スレッドローカル原則

TransmittableThreadLocal は TTL と呼ばれます。

始める前に、公式のタイミング図をご覧ください。この図を見ると、ソースコードが理解しやすくなります。

1. TransmittableThreadLocalの使い方

(1)RunnableとCallableの変更

このアプローチは上記のサンプル コードと同じで、TtlRunnable と TtlCallable を使用してスレッド プールに渡される Runnable と Callable を変更します。

(2)スレッドプールを変更する

スレッド プールは、次のメソッドが使用できる TtlExecutors ユーティリティ クラスを使用して変更できます。

(3)Javaエージェント

エージェントアプローチはコードに悪影響を与えることはありません。具体的な使用方法については、ここでは詳しく説明しませんので、公式サイトをご覧ください。公式サイトへのリンクは記事の最後に掲載します。

他のエージェント (Skywalking や Promethues など) と一緒に使用する必要がある場合は、最初に TransmittableThreadLocal Java Agent を配置する必要があることに注意することが重要です。

2. ソースコード分析

まず簡単に概要を説明します。

  • Runnable を変更すると、メイン スレッドの TTL 値が TtlRunnable コンストラクターに渡されます。
  • 子スレッドの TTL をバックアップし、メインスレッドの値を子スレッドに設定します。
  • 子スレッドはビジネス ロジックを実行します。
  • 子スレッドに追加された TTL を削除し、子スレッドへのバックアップをリセットします。

(1) TtlRunnable#run メソッドは何をしますか?

TtlRunnable#run メソッドから始めましょう。

プロセス全体の観点から見ると、コンテキスト転送プロセス全体は、スナップショット、再生、回復 (CRR) という 3 つの操作に標準化できます。

  • `captured` はメイン スレッド (スレッド A) によって渡された TTL 値です。
  • バックアップは、子スレッド (スレッド B) の現在の TTL 値です。
  • 再生操作は、メイン スレッド (スレッド A) の TTL 値を現在の子スレッド (スレッド B) に再生し、再生前の TTL 値のバックアップ (前述のバックアップ) を返します。
  • runnable.run() は実行されるメソッドです。
  • `restore` は、子スレッド(スレッドB)が入った時点でバックアップされたTTL値を復元します。子スレッドのTTLは変更されている可能性があるため、このメソッドは子スレッドが `replay` メソッドを実行する前のTTL値にロールバックします。

(2)撮影したスナップショットはいつ撮影されましたか?

学生の皆さん、考えてみてください。スナップショットはいつ撮影されたのでしょうか?

上記の run メソッドからわかるように、メソッドの最初の行ではすでにスナップショット値が取得されているため、スナップショットの生成は間違いなく run メソッド内で行われません。

念のためおさらいですが、冒頭のタイミング図を覚えていますか?セクション4.1をご覧ください。

スレッドをどのようにカプセル化したかを覚えていますか? スレッドをカプセル化するために TtlRunnable.get() を使用し、TtlRunnable を返しました。

答えはこのメソッドの中にあります。それが何をするのか見てみましょう。

 @Nullable @Contract(value = "null -> null; !null -> !null", pure = true) public static TtlRunnable get(@Nullable Runnable runnable) { return get(runnable, false, false); } @Nullable @Contract(value = "null, _, _ -> null; !null, _, _ -> !null", pure = true) public static TtlRunnable get(@Nullable Runnable runnable, boolean releaseTtlValueReferenceAfterRun, boolean idempotent) { if (runnable == null) return null; if (runnable instanceof TtlEnhanced) { // avoid redundant decoration, and ensure idempotency if (idempotent) return (TtlRunnable) runnable; else throw new IllegalStateException("Already TtlRunnable!"); } return new TtlRunnable(runnable, releaseTtlValueReferenceAfterRun); } private TtlRunnable(@NonNull Runnable runnable, boolean releaseTtlValueReferenceAfterRun) { this.capturedRef = new AtomicReference<>(capture()); this.runnable = runnable; this.releaseTtlValueReferenceAfterRun = releaseTtlValueReferenceAfterRun; }

ご覧のとおり、TtlRunnable.get() メソッドの呼び出しの最後に、TtlRunnable のコンストラクターが呼び出され、そのコンストラクター内でキャプチャ メソッドが呼び出されます。

`capture` メソッドは、実際のスナップショットが取得される場所です。

`transmittee.capture()` 関数は `ttlTransmittee` を呼び出します。

threadLocal.copyValue() は参照をコピーするので、オブジェクトの場合はコピー メソッドをオーバーライドする必要があることに注意することが重要です。

 public T copy(T parentValue) { return parentValue; }

コード内のホルダーは InheritableThreadLocal であり、その値の型は WeakHashMap です。

キーは TransmittableThreadLocal であり、値は常に null であり、使用されることはありません。

使用されているすべての TransmittableThreadLocal インスタンスを維持し、それらをホルダーに均一に追加します。

これにより、別の疑問が生じます。保有者に価値が追加されたのは何時でしょうか?

ソースコードを1ステップずつ見ていくという罠にはまらないようにしましょう。1つのメソッドから展開していくのもやめましょう。メインスレッドが存在するべきです。ここでは、スナップショットがいつどのように取得されるかは既に分かっています。ホルダーの値がどこに追加されるかは別の問題です。

(3)ホルダー内のどこに価値が割り当てられているか?

ホルダー内の値の割り当ては、addThisToHolder メソッドで実装されます。

詳細は `transmittableThreadLocal.get()` および `transmittableThreadLocal.set()` で確認できます。

 @Override public final T get() { T value = super.get(); if (disableIgnoreNullValueSemantics || value != null) addThisToHolder(); return value; } @Override public final void set(T value) { if (!disableIgnoreNullValueSemantics && value == null) { // may set null to remove value remove(); } else { super.set(value); addThisToHolder(); } } private void addThisToHolder() { if (!holder.get().containsKey(this)) { holder.get().put((TransmittableThreadLocal<Object>) this, null); // WeakHashMap supports null value. } }

`addThisToHolder` 関数は、この `TransmittableThreadLocal` インスタンスをホルダーのキーに追加します。

このメソッドを使用すると、使用されたすべての TransmittableThreadLocal インスタンスを記録できます。

(4)バックアップと再生データの再生

再生メソッドは 2 つのことだけを実行します。

  • スナップショットのデータ (メイン スレッドから渡されたデータ) を現在の子スレッドに設定します。
  • 現在のスレッドの TTL 値を返します (現在の子スレッドの前の TTL のスナップショット再生)。

バックアップと再生の操作は、実際には、transmitter.replay メソッドで実行されます。

(5)復元する

CRR 操作の最後のステップである復元を見てみましょう。

復元関数は、現在のスレッドの TTL を、メソッド実行前にバックアップされた値に復元します。

restore メソッドは内部的に transmitter.restore メソッドを呼び出します。

考えてみてください。タスクの実行が完了した後に復元操作を実行する必要があるのはなぜでしょうか?

最初の目的は、スレッド プール内のスレッドがすべて再利用されるため、スレッドをクリーンな状態に保つことです。

スレッドが複数のタスクを繰り返し実行する場合、最初のタスクが TTL 値を変更し、それが復元されないと、2 番目のタスクは、予想される初期値ではなく、最初のタスクから変更された値を取得します。

V. TransmittableThreadLocalの初期化方法

図に示すように、TransmittableThreadLocal に関連する初期化メソッドは 3 つあります。

1. ThreadLocal#initialValue()

ThreadLocal が存在しない場合にその値を取得するメソッドは、ThreadLocal#get によってトリガーされます。

ThreadLocal#initialValue() は遅延ロードされることに注意してください。つまり、ThreadLocal インスタンスが作成されるときに ThreadLocal#initialValue() は呼び出されません。

最初にThreadLocal.set(T) 操作を実行し、その後に値取得操作を実行した場合、既に値が設定されているため、ThreadLocal#initialValue() は実行されません。たとえNULLが設定されていても、初期化操作は実行されません。

削除メソッドが呼び出された場合、値の取得によって ThreadLocal#initialValue() 初期化操作がトリガーされます。

2.継承可能なスレッドローカル#childValue(T)

`childValue` メソッドは、新しいスレッドが作成されるときに子スレッドの `InheritableThreadLocal` 値を初期化するために使用されます。

3. 転送可能なスレッドローカル#コピー(T)

TtlRunnable または TtlCallable が作成された場合にトリガーされます。

たとえば、TtlRunnable.get() を使用してスナップショットを取得するときにトリガーされます。

TtlRunnable などで実行中に TransmittableThreadLocal 値を初期化するために使用されます。

VI. 結論

この記事では、コード例を通じて、ThreadLocal、InheritableThreadLocal、および TransmittableThreadLocal を使用して親スレッドと子スレッド間でパラメータを渡す方法の進化について説明します。

結論は次のとおりです。

  • ThreadLocal は、親スレッドと子スレッド間でパラメータを渡すために使用できません。
  • InheritableThreadLocal は親子間のパラメータ渡しを実現できますが、スレッド プールのシナリオにおけるスレッド再利用の問題を解決することはできません。
  • TransmittableThreadLocal は、スレッド プールでのスレッド再利用の問題を解決できます。

TransmittableThreadLocal を使用してオブジェクトを保存するときにディープコピーが必要な場合は、TransmittableThreadLocal#copy(T) メソッドをオーバーライドする必要があることに注意してください。