title: 【転載】@Async アノテーションは実際にはこんなものです。
date: 2021-09-09 09:46:00
comment: false
toc: true
category:
- Java
tags: - 転載
- Java
- Async
- アノテーション
- スレッドプール
- スレッド
- 非同期
- ロジック
この記事は、@Async アノテーションは実際にはこんなものです。 - 掘金から転載されています。
こんにちは、私は why です。
以前、スレッドプールに関するいくつかの記事を書きましたが、あるクラスメートが私の過去の記事を調べてみたところ、@Async
アノテーションに関する記事を書いていないことに気づき、私に尋ねてきました:
そうです、私は白状します。
私がこのアノテーションを好まない理由は、全く使ったことがないからです。
私はカスタムスレッドプールを使って非同期のロジックを実行することに慣れており、長年ずっとそうしてきました。
したがって、私が主導するプロジェクトでは、プロジェクト内で@Async
アノテーションを見ることはありません。
では、以前に@Async
アノテーションを見たことがあるのでしょうか?
もちろん見たことがあります。友人の中にはこのアノテーションを好んで使う人もいます。
一つのアノテーションで非同期開発が完了するのは、なんて素晴らしいことでしょう。
このアノテーションを使う人がその原理を知っているかどうかは分かりませんが、私は知りません。
最近の開発でコンポーネントを導入した際、呼び出したメソッドの中でこのアノテーションが使われている部分があることに気づきました。
せっかく使われているのだから、研究してみましょう。
まず最初に言っておくべきことは、この記事ではスレッドプールに関する知識を説明するつもりはありません。
私がどのようにしてこのアノテーションを理解したのかを説明するだけです。
デモを作成する#
皆さんがこのような状況に遭遇した場合、どのように取り組むか分かりませんが、
私はどの角度から取り組んでも、最終的にはソースコードに行き着くと思います。
だから、私は一般的に最初にデモを作成します。
デモは非常にシンプルで、3 つのクラスだけです。
まずは起動クラス、これは特に説明することはありません:
次にサービスを作成します:
このサービスの中の syncSay メソッドには@Async
アノテーションが付けられています。
最後に、これを呼び出すコントローラーを作成し、完了です:
デモが構築されました。あなたも手を動かして作ってみてください。5 分以上かかったら、私の負けです。
次に、プロジェクトを起動し、インターフェースを呼び出してログを確認します:
ええと、スレッド名から見ると、これも非同期ではないですね?
どうしてまだ tomcat のスレッドなのですか?
そこで、私は研究の道で最初の問題に直面しました:@Async
アノテーションが機能していない。
なぜ機能しないのか?#
なぜ機能しないのでしょうか?
私も混乱しています。私はこのアノテーションについて全く知らなかったので、どうやって知ることができるのでしょうか?
この問題に直面したとき、どうしますか?
もちろん、ブラウザを使ってプログラミングします!
この場合、もし私がソースコードからなぜ機能しないのかを分析すれば、理由を見つけることができるでしょう。
しかし、ブラウザを使ってプログラミングすれば、30 秒でこの 2 つの情報を見つけることができます:
無効の理由:
- 1.
@SpringBootApplication
起動クラスに@EnableAsync
アノテーションが追加されていない。 - 2.Spring のプロキシクラスを通っていない。
@Transactional
と@Async
アノテーションの実装は、Spring の AOP に基づいており、AOP の実装は動的プロキシパターンに基づいています。したがって、アノテーションが無効になる理由は明らかです。呼び出すメソッドがオブジェクト自体であり、プロキシオブジェクトではないため、Spring コンテナによって管理されていない可能性があります。
明らかに、私の状況は最初のケースに該当し、@EnableAsync
アノテーションが追加されていません。
もう一つの理由にも興味がありますが、今はデモを構築することが最優先のタスクなので、他の情報に惑わされてはいけません。
多くのクラスメートが問題を持って調査に行くとき、最初に調べた問題は@Async
アノテーションがなぜ機能しないのかでしたが、徐々にそれがずれていき、15 分後には問題が SpringBoot の起動プロセスに変わってしまいました。
さらに 30 分後、ウェブページには面接で必ず覚えておくべき八股文のようなものが表示される...
私が言いたいのは、問題を調べるときは、しっかりと問題を調べることです。問題を調べる過程で、必ずこの問題から派生する自分がもっと興味を持つ問題が出てきます。しかし、記録しておき、問題が発散しないようにしましょう。
この理屈は、問題を持ってソースコードを見るのと同じです。見ているうちに、自分の問題が何であるかも分からなくなるかもしれません。
さて、話を戻しましょう。
私は起動クラスにこのアノテーションを追加しました:
再度呼び出しを行います:
スレッド名が変わったことが確認でき、実際に機能したことが分かります。
今、私のデモはすでに構築されており、どの角度から掘り下げるかを探し始めることができます。
上記のログからも分かるように、デフォルトではtask-
というスレッドプレフィックスを持つスレッドプールがタスクを実行するのを手伝っています。
スレッドプールについて言えば、その関連設定を知っておく必要があります。
では、どうやって知ることができるのでしょうか?
まずは圧力をかける#
実際、普通の人の考え方では、この時点でソースコードを調べて、対応するスレッドプールの注入場所を探すべきです。
しかし、私は少し普通ではなく、ソースコードの中を探すのが面倒なので、私の目の前に自分で暴露させたいと思います。
どうやって暴露させるか?
スレッドプールについての理解を頼りに、最初の考えはこのスレッドプールに圧力をかけることです。
それを爆発させて、タスクを処理できなくなるまで圧力をかけ、拒否ロジックに入らせると、通常は例外がスローされるでしょう?
そこで、プログラムを少し改造しました:
直接大きな力で奇跡を起こそうと考えました:
結果は...
なんと...
全て受け入れられ、例外は発生しませんでした?
ログは 1 秒間に何行も出力され、非常に楽しいです:
私が予想した拒否例外は発生しませんでしたが、ログからは少し手がかりを得ました。
例えば、このタスクは最大で 8 まで達することが分かりました:
友人たち、これは何を意味しますか?
これは、私が探しているスレッドプールのコアスレッド数の設定が 8 であることを意味しているのでしょうか?
何ですって、なぜ最大スレッド数ではないのかと?
あり得ますか?
もちろんあり得ます。しかし、10000 のタスクを送信して、スレッドプールの拒否戦略が発動せず、ちょうど最大スレッドプールを使い果たしたのですか?
つまり、このスレッドプールの設定はキューの長さが 9992、最大スレッド数が 8 ということですか?
それはあまりにも偶然で不合理ではありませんか?
だから、私はコアスレッド数の設定が 8 で、キューの長さはInteger.MAX_VALUE
であると考えています。
私の推測を証明するために、リクエストを次のように変更しました:
num = 一千万。
jconsole を使ってヒープメモリの使用状況を観察します:
それは急上昇します。GC を実行するボタンをクリックしても、何の緩和もありません。
また、側面から証明されました:タスクはキューに入って並んでいる可能性があり、メモリが急上昇しています。
今はまだその設定が何であるか分かりませんが、先ほどのブラックボックステストを経て、私は正当な理由を持って疑っています:
デフォルトのスレッドプールにはメモリオーバーフローのリスクがあります。
しかし、同時に、私が例外を発生させて自分の前に暴露させようとした騒がしい考えは失敗しました。
ソースコードを突き詰める#
前の考え方が通用しないので、素直にソースコードを突き詰めましょう。
私はこのアノテーションから突き詰め始めました:
このアノテーションに入ると、いくつかの英語があり、長くはありませんが、そこから重要な情報を得ました:
主に私が線を引いた部分に注目してください。
対象メソッドのシグネチャに関しては、任意のパラメータータイプがサポートされています。
対象メソッドのシグネチャにおいて、引数は任意のタイプがサポートされています。
もう一言付け加えると、ここで対象メソッド、target について言及すると、皆さんの頭の中にはすぐにプロキシオブジェクトの概念が浮かぶはずです。
上記の文は理解しやすく、むしろ無駄なことのように感じます。
しかし、その後に続く However:
しかし、戻り値の型は void または Future に制約されます。
constrained、制約されているという意味です。
この文は、戻り値が void または Future に制約されていることを示しています。
どういう意味でしょうか?
それなら、私は String を返したいのですが?
WTF、出力されたのは null でした!?
ここでオブジェクトを返したら、簡単に NullPointerException が発生するのではないでしょうか?
アノテーションの注釈を見た後、私は第二の隠れた罠を発見しました:
@Async
アノテーションが付けられたメソッドの戻り値は void または Future のみです。
void については言うまでもなく、Future について話しましょう。
私が線を引いたもう一つの文:
それは一時的な {@code Future} ハンドルを返す必要があります。それは、Spring の {@link AsyncResult} のように値を通過させるだけです。
temporary は四級の単語で、短期的な、または一時的なという意味です。
つまり、戻り値が必要な場合は、AsyncResult オブジェクトで包む必要があります。この AsyncResult が一時的な作業者です。
このように:
次に、アノテーションの value 属性に目を向けます:
このアノテーションは、注釈の上にある意味から、スレッドプールの bean 名を記入する必要があることを示しています。つまり、スレッドプールを指定することに相当します。
これが正しく理解されているかどうかは分かりませんが、後でメソッドを作成して確認してみます。
さて、ここまでの情報を整理してまとめます。
- 私は以前このアノテーションについて全く理解していませんでしたが、今はデモがあります。デモを構築する際に、
@Async
アノテーションの他に、@EnableAsync
アノテーションも追加する必要があることに気づきました。起動クラスに追加します。 - それから、このデフォルトのスレッドプールをブラックボックステストしました。私はそのコアスレッド数がデフォルトで 8 で、キューの長さが無限であると疑っています。メモリオーバーフローのリスクがあります。
@Async
の注釈を読んで、戻り値は void または Future 型のみであることが分かりました。そうでなければ、他の値を返してもエラーは発生しませんが、返された値は null であり、NullPointerException のリスクがあります。@Async
アノテーションには value 属性があり、注釈の説明からカスタムスレッドプールを指定できることが分かります。
次に、探求すべき問題を整理し、@Async
に関連する問題に集中します:
- 1. デフォルトのスレッドプールの具体的な設定は何ですか?
- 2. ソースコードはどのようにして void と Future のみをサポートするのですか?
- 3.value 属性は何のためにありますか?
具体的な設定は何ですか?#
具体的な設定を見つけるのは非常に迅速なプロセスです。
なぜなら、このクラスの value パラメータは非常に親切だからです:
5 つの呼び出し場所のうち、4 つはコメントです。
有効な呼び出しはこの 1 つだけです。まずはブレークポイントを設定してみましょう:
org.springframework.scheduling.annotation.AnnotationAsyncExecutionInterceptor#getExecutorQualifier
呼び出しを発起すると、ブレークポイントに到達しました:
ブレークポイントを下に進めると、次の場所に到達します:
org.springframework.aop.interceptor.AsyncExecutionAspectSupport#determineAsyncExecutor
このコード構造は非常に明確です。
番号①の場所は、対応するメソッドの@Async
アノテーションの value 値を取得します。この値は実際には bean 名であり、空でなければ Spring コンテナから対応する bean を取得します。
もし value が空であれば、私たちのデモのような場合、番号②の場所に進みます。
この場所が私が探しているデフォルトのスレッドプールです。
最終的に、デフォルトのスレッドプールでも、Spring コンテナ内のカスタムスレッドプールでも、
メソッドの次元で、メソッドとスレッドプールのマッピング関係を維持します。
つまり、番号③のこのステップで、コード内の executors はマップです:
だから、私が探しているものは、番号②のこの場所のロジックです。
ここには主に defaultExecutor オブジェクトがあります:
このものは関数型プログラミングであるため、もしこのものが何をするのか分からなければ、デバッグするのが少し混乱するかもしれません:
私はあなたに 10 分間の復習をお勧めします。
最終的に、あなたはこの場所にデバッグすることになります:
org.springframework.aop.interceptor.AsyncExecutionAspectSupport#getDefaultExecutor
このコードは少し面白いです。BeanFactory からデフォルトのスレッドプールに関連する Bean を取得します。プロセスは非常にシンプルで、ログも非常に明確に印刷されているので、詳しくは述べません。
しかし、私が言いたい面白い点は、あなたがこのコードを見たとき、少し親の委任の香りを感じるかどうかです。
すべて例外を利用して、例外の中でロジックを処理します。
上記の「ゴミ」コードは、直接的にアリの開発規範の 2 つの条項に違反しています:
ソースコードの中では、これは良いコードです。
ビジネスプロセスの中では、これは規範に違反しています。
したがって、余談を一言。
アリの開発規範は、私が個人的に感じるには、実際にはビジネスコードを書く同僚にとってのベストプラクティスです。
しかし、この尺度をミドルウェア、基盤コンポーネント、フレームワークのソースコードの範囲に引き上げると、少し不適合の症状が現れることがあります。このことは見解が分かれるかもしれませんが、私はアリの開発規範の IDEA プラグインが、私のように CRUD を書くプログラマーにとっては本当に素晴らしいと思います。
遠くのことは言わず、私たちは戻ってこのスレッドプールを取得したものを見てみましょう:
これで私が探していたものを見つけました。このスレッドプールの関連パラメータがすべて見えました。
また、私の以前の推測を確認しました:
私はコアスレッド数の設定が 8 で、キューの長さが
Integer.MAX_VALUE
であると考えています。
しかし、今、私は BeanFactory からこのスレッドプールの Bean を直接取得しました。この Bean はいつ注入されたのでしょうか?
友人たち、これは簡単ではありませんか?
私はすでにこの Bean の beanName を取得しました。それは applicationTaskExecutor です。もしあなたが Spring の Bean 取得プロセスの八股文を熟知していれば、この場所にブレークポイントを設定し、デバッグ条件を加えて、ゆっくりデバッグすれば分かります:
org.springframework.beans.factory.support.AbstractBeanFactory#getBean(java.lang.String)
仮にあなたが上記の場所にブレークポイントを設定してデバッグすることを知らなかった場合、もう一つの簡単で直接的な方法は、あなたが beanName を取得したので、コードの中で検索すれば出てくるということです。
簡単で効果的です:
org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration
このクラスを見つけたので、適当にブレークポイントを設定してデバッグを開始できます。
さらに、少し面白い操作をしましょう。
仮に私が beanName を知らなくても、Spring によって管理されているスレッドプールがあることは確かです。
それなら、プロジェクト内のすべての Spring によって管理されているスレッドプールを取得すれば、必ず一つは私が探しているものになるでしょう?
下のスクリーンショットを見てください。この現在の bean はまさに私が探している applicationTaskExecutor ではありませんか?
これらはすべて野路子で、面白い操作です。知っておくと良いでしょう。時には複数の調査のアプローチがあります。
戻り値の型のサポート#
前回、私たちは設定に関する最初の問題を解決しました。
次に、前回提起した別の問題を見てみましょう:
ソースコードはどのようにして void と Future のみをサポートするのですか?
その答えはこのメソッドの中に隠されています:
org.springframework.aop.interceptor.AsyncExecutionInterceptor#invoke
番号①の場所は、実際には前回分析した map からメソッドに対応するスレッドプールを取得する場所です。
スレッドプールを取得した後、番号②の場所に進み、Callable オブジェクトをラップします。
では、何を Callable オブジェクトにラップするのでしょうか?
この問題は一旦置いておき、私たちは私たちの問題に沿って進みましょう。そうでなければ、問題が増えていきます。
番号③の場所、doSubmit、見れば分かるように、ここがタスクを実行する場所です。
org.springframework.aop.interceptor.AsyncExecutionAspectSupport#doSubmit
実際、ここが私が探していた答えです。
このメソッドの引数 returnType は String で、実際には@Async
アノテーションが付けられた asyncSay メソッドです。
信じられない場合は、前の呼び出しスタックを見てみましょう。ここで具体的なメソッドが見えます:
どうですか、騙していませんよね。
だから、今、doSubmit メソッドがこのメソッドの戻り値の型をどうするのか見てみましょう。
合計で 4 つの分岐があります。最初の 3 つは、戻り値が Future 型かどうかを判断しています。
ListenableFuture と CompletableFuture はどちらも Future を継承しています。
この 2 つのクラスは@Async
アノテーションのメソッド注釈にも言及されています:
私たちのプログラムは最後の else に進み、戻り値が Future 型でないことを意味します。
では、彼は何をしたのでしょうか?
タスクをスレッドプールに直接提出し、その後 null を返しました。
これでは NullPointerException が発生するのではありませんか?
この地点で、私たちはこの問題を解決しました:
ソースコードはどのようにして void と Future のみをサポートするのですか?
実際、理由は非常にシンプルです。私たちが通常スレッドプールを使用する際、戻り値はこの 2 つの型だけではありませんか?
submit の方法で提出し、Future を返し、結果を Future にラップします:
execute の方法で提出し、戻り値はありません:
フレームワークはシンプルなアノテーションを通じて非同期化を実現し、どんなに華やかに遊んでも、スレッドプールの提出の基本原理には従わなければなりません。
したがって、ソースコードがなぜ void と Future の戻り値型のみをサポートするのか?
それは、基本的なスレッドプールがこの 2 つのタイプの戻り値のみをサポートしているからです。
ただし、彼のやり方は少し厄介で、他の戻り値の戻り値をすべて null として処理しています。
あなたが不満を持っても、あなたが注釈の説明を読まなかったからです。
さらに、この場所には小さな最適化ポイントがあります:
このメソッドに到達したとき、戻り値はすでに null であることが明確です。
なぜまだexecutor.submit(task)
でタスクを提出するのですか?
execute で十分です。
違いを尋ねますか?
さっき言ったばかりではありませんか、submit メソッドには戻り値があります。
あなたは使わないかもしれませんが、彼は依然として戻り値の Future オブジェクトを構築します。
しかし、構築されたとしても、使われません。
だから、execute を使って提出すればいいのです。
Future オブジェクトを一つ生成しないことで、最適化と見なせますか?
一言で言えば、価値のある最適化とは言えませんが、言ってしまえば Spring のソースコードを最適化したことになります。自慢には十分です。
次に、前回の話に戻り、番号②の場所でラップされたものは何かを見てみましょう。
実際、この問題は足の指で推測できるはずです:
ただ、私はそれを単独で取り出して言った理由は、ここで返される result が私たちのメソッドの戻り値の実際の値であることを証明したいからです。
ただし、戻り値が Future でない場合は処理しないだけです。例えば、ここではhi:1
という文字列を返しているのですが、条件に合わないため、捨てられています:
さらに、IDEA は非常に賢く、この場所の戻り値に問題があることを示唆します:
メソッドを変更することも示されています。あなたが少しだけ必要とすれば、彼はあなたを再び修正します。
なぜこれを変更する必要があるのか、今では非常に明確です。
その理由を知り、またその理由を知ることができました。
@Async アノテーションの value#
次に、@Async アノテーションの value 属性が何のためにあるのか見てみましょう。
実際、前回私はすでにこっそりと触れました。ただ一言で済ませました。それはこの場所です:
前回、番号①の場所は、対応するメソッドの@Async
アノテーションの value 値を取得する場所です。この値は実際には bean 名であり、空でなければ Spring コンテナから対応する bean を取得します。
そして、私は直接番号②の場所に分析しました。
今、再び番号①の場所を見てみましょう。
私は再度テストケースを設定して、私の考えを検証します。
いずれにせよ、value 値は Spring の bean 名であり、この bean は必ずスレッドプールオブジェクトであることは間違いありません。
したがって、私はデモプログラムを次のように変更しました:
再度実行すると、ブレークポイントの場所に到達し、デフォルトの状況とは異なり、この時点で qualifier に値があります:
次に、beanFactory から名前が whyThreadPool の bean を取得します。
最終的に、取得したスレッドプールは私がカスタマイズしたこのスレッドプールです:
これは実際には非常にシンプルな探求プロセスですが、その背後には一つの理論があります。
以前、私にこの質問をした同僚がいました:
実際、この質問は非常に代表的で、多くの同僚がスレッドプールを乱用してはいけないと考えています。一つのプロジェクトで一つのスレッドプールを共有すれば十分です。
スレッドプールは確かに乱用してはいけませんが、一つのプロジェクト内には複数のカスタムスレッドプールが存在することができます。
ビジネスシーンに応じて区分けすることができます。
例えば、簡単な例を挙げると、ビジネスの主なプロセスでは一つのスレッドプールを使用できますが、主なプロセスの中のある段階で問題が発生した場合、警告 SMS を送信する必要があると仮定します。
この警告 SMS を送信する操作は、別のスレッドプールを使用して行うことができます。
それらは同じスレッドプールを共有できますか?
できます、使えます。
しかし、何が問題になるのでしょうか?
プロジェクト内のあるビジネスに問題が発生し、警告 SMS を狂ったように送信し続け、スレッドプールを占有してしまった場合、
この時、主なプロセスのビジネスと SMS 送信が同じスレッドプールを使用していると、どんな美しいシーンが現れるでしょうか?
タスクを提出するたびに、拒否戦略に入ってしまうのではありませんか?
警告 SMS 送信という付随的な機能がビジネスを妨げ、本末転倒になってしまいますよね?
したがって、異なるスレッドプールを使用し、それぞれの役割を果たすことをお勧めします。
これは実際には、聞こえは良いスレッドプールの隔離技術です。
では、@Async
アノテーションに落とし込むとどうなるのでしょうか?
実際には、こうなります:
そして、前回私たちが言及したメソッドとスレッドプールのマッピング関係を維持するマップを覚えていますか?
それがこれです:
今、私はプログラムを実行して上記の 3 つのメソッドを呼び出します。その目的は、値をこのマップに入れることです:
分かりましたか?
再度この文を繰り返します:
メソッドの次元で、メソッドとスレッドプールの関係を維持します。
今、私は@Async
アノテーションについて少し理解しました。私はそれが非常に可愛らしいと思います。今後、プロジェクトで使用することを検討するかもしれません。結局、SpringBoot のアノテーションベースの開発理念により合致していますから。
最後に一言#
さて、ここまで来たら、いいねやフォローを自由にお願いします。もしあなたがそれをしてくれたら、私は気にしません。記事を書くのはとても疲れるので、少しの正のフィードバックが必要です。
読者の皆さんに感謝の意を表します: