日々常々

ふつうのプログラマがあたりまえにしたいこと。

SpringBootでAsyncを使う時に知っておきたいExecutorのこと

  • SpringFramework 6.0.11
  • SpringBoot 3.1.2

@Async@EnableAsync の使い方

SpringFrameworkで @Async を使うとかんたんにメソッドを非同期で実行できます。

@Component
class AsyncComponent {

    @Async
    void method() {
        // 時間のかかる処理
    }
}

使う側は単にメソッドを呼び出すだけです。戻り値を処理したいなら Future で受ければいいけど、投げっばでいいなら void で良い。投げっばでいい場合の方が多いはず。

@Async を使うためにはアノテーションを処理する何かしらが必要で、この一式を用意するように指示するのが @EnableAsync です。 以下のようなクラスをSpringがわかるところに置いてあげます。

@EnableAsync
@Configuration(proxyBeanMethods = false)
class AsyncConfiguration {
}

かんたんですね。

⚠️以降はひたすらSpringの中身の話です。「知っているべき」とまでは言いませんが、知っておくとなんかの役には立つんじゃないかな。

SpringFrameworkの話

細かいことはコードやおまけでつけたクラス図と照らし合わせてもらうとして、大雑把に以下のようなことをやっている。

  • 準備
    • @EnableAsync により AsyncAnnotationBeanPostProcessor が登録される。
    • AsyncAnnotationBeanPostProcessor@Async なメソッド呼び出しを AnnotationAsyncExecutionInterceptor でラップするようにする。
  • 実行
    • @Async なメソッドが呼び出されると AnnotationAsyncExecutionInterceptor がメソッドに応じた Executor に実行させする。

コードを見ての通りAnnotationAsyncExecutionInterceptor では ExecutorCallablesubmit しているだけです。( ExecutorCallablejava.util.concurrent です。わからない場合は別途勉強してください。)

さてここで重要になるのが Executor の決まり方です。 ExecutorAnnotationAsyncExecutionInterceptor (の親である AsyncExecutionAspectSupport )により以下の順で決定されます。

  1. メソッドで指定されている Executor のBean
  2. AnnotationAsyncExecutionInterceptor に設定されているデフォルトの Executor
  3. TaskExecutor のBean
  4. "taskExecutor" という名前の Executor のBean
  5. SimpleAsyncTaskExecutor を生成して使用

色々ありますが、SpringFrameworkの歴史的経緯などもあるのでしょう。とりあえずこうなっています。 SpringFrameworkの文脈で「 @Async を動かす Executor に手を加えたい」と思ったならば、この1から4のいずれかを使えばいいわけです。(5は全部ダメだった時にSpringがなんとか動かすための実装なので手は出せません。)

1番目はたとえば @Async("myExecutor") と書いていれば myExecutor という名前の Executor Beanがあればそれが使われるということです。アノテーションを使わない場合も考慮されてるのでこんな表現になっています。

2番目の「 AnnotationAsyncExecutionInterceptor に設定されているデフォルトの Executor 」はわかりづらいですが、 @EnableAsync を使用する場合は AsyncConfigurergetAsyncExecutor メソッドが返すインスタンス になります。なぜかよく紹介されているので使ったことのある人も多いかもしれません。

SpringBootの話

SpringBootを使用する場合、3番目の「 TaskExecutor のBean 」が使用されます。 このBeanは TaskExecutionAutoConfiguration が作成 します。

自動構成される TaskExecutor は他の Executor のBeanを作成すれば作られなくできますが、嬉しいことはないのでやめておきましょう。

TaskExecutionAutoConfiguration を見ると、 TaskExecutorBuilder を通して TaskExecutionProperties に設定された値などを使って TaskExecutor を作っているのがわかると思います。 SpringBootを使用する場合、SpringBootのプロパティ(ここでは spring.task.execution.* )が効くことが期待されます。 そのため、 @Async を実行するデフォルトの Executor を差し替える場合、以下の対応は好ましくありません。動くけど。動くけど、好ましくない。

  • AsyncConfigurer を作成して Executorインスタンスを生成する(前述2番)
  • TaskExecutorBuilder (もしくは TaskExecutionProperties )を使用しない TaskExecutor のBeanを作成する(前述3番)
  • taskExecutor という名前の Executor Beanを作成する(前述4番)

……「全滅じゃん?」と言ってはいけない。まだだいじょうぶ。

下2つを採用する場合は spring.task.execution.* が効かないことを明確にドキュメントに記述し、保守では引き継ぎ続けなければなりません。また「自分が作った通りの Executor 」となるため、SpringBootが「だいたいのアプリはこの設定でいけるっしょ」とやってくれているものに乗っかれません。ちゃんと自分たちで決めてぜんぶ実装しなければならないのです。もちろんSpringBootの設定値も妥当性を確認するのが望ましいですが、全部の値は見てられないし、そんな関心のないものはSpringBootに任せとくというのも一つの選択です。「SpringBootではないデフォルトでもいいじゃないか」というのもそれはそうかもしれませんが、おそらくSpringBootよりも広いコンテキストのデフォルトであり、SpringBootのデフォルトよりも微妙なものが多いなぁという感覚。

一番上の「 AsyncConfigurerExecutor を作成」はプロパティの問題に加えてさらに別の問題があります。 Executor をBean登録しないので、SpringBootの管理外となってしまいます。SpringBootが管理してくれないことによる問題としては、シャットダウン時に後片付けがされないことや、メトリクスが自動では見えないことが挙げられます。

SpringBootActuatorで metrics エンドポイントを公開すると /actuator/metrics で簡易的なメトリクスが参照できます。 SpringBootActuatorは Executor なBeanを TaskExecutorMetricsAutoConfiguration が拾って自動的に見れるようにしてくれます。以下ではアクティブなタスク数とキューに入っている数を取得しています。

% curl http://localhost:8080/actuator/metrics/executor.active -s | jq
{
  "name": "executor.active",
  "description": "The approximate number of threads that are actively executing tasks",
  "baseUnit": "threads",
  "measurements": [
    {
      "statistic": "VALUE",
      "value": 8
    }
  ],
  "availableTags": [
    {
      "tag": "name",
      "values": [
        "applicationTaskExecutor"
      ]
    }
  ]
}
% curl http://localhost:8080/actuator/metrics/executor.queued -s | jq
{
  "name": "executor.queued",
  "description": "The approximate number of tasks that are queued for execution",
  "baseUnit": "tasks",
  "measurements": [
    {
      "statistic": "VALUE",
      "value": 52
    }
  ],
  "availableTags": [
    {
      "tag": "name",
      "values": [
        "applicationTaskExecutor"
      ]
    }
  ]
}

実運用では /actuator/metrics ではなく /actuator/prometheus などを使うと思います。

% curl http://localhost:8080/actuator/prometheus -s | grep "^executor_"
executor_pool_max_threads{name="applicationTaskExecutor",} 2.147483647E9
executor_active_threads{name="applicationTaskExecutor",} 8.0
executor_pool_size_threads{name="applicationTaskExecutor",} 8.0
executor_completed_tasks_total{name="applicationTaskExecutor",} 168.0
executor_queued_tasks{name="applicationTaskExecutor",} 14.0
executor_queue_remaining_tasks{name="applicationTaskExecutor",} 2.147483633E9
executor_pool_core_threads{name="applicationTaskExecutor",} 8.0

これが見えないのは運用を考えると厳しいため、AsyncConfigurer を使う場合も別途Bean登録した Executor を使うようにしてあげる必要があります。で、そうするなら TaskExecutor なBean登録すりゃいいんじゃね?などになり、であれば AsyncConfigurer をSpringBootで使うのは微妙に思えます。

そもそもやりたいこと

Executor を変えたいというのはそうそうありません。 もちろん ThreadPoolTaskExecutor だと要件を満たさなくて、別のライブラリや自分たちで制御する Executor にしたいなどもあるでしょう。その場合はやるといいと思います。

ですがここに手を伸ばしたい多くの理由は「スレッドを跨いでMDCなど ThreadLocal に頼っているコンテキストを引き渡したい」などでしょう。 であるならば Executor の置き換えではなく、 TaskDecorator を使うのが妥当でしょう。 TaskExecutionAutoConfigurationtaskExecutorBuilder メソッド を見てください。

引数で ObjectProvider<TaskDecorator> を受けて

   public TaskExecutorBuilder taskExecutorBuilder(TaskExecutionProperties properties,
            ObjectProvider<TaskExecutorCustomizer> taskExecutorCustomizers,
            ObjectProvider<TaskDecorator> taskDecorator) {

getIfUnique で取得したのをTaskExecutionBuilder に渡しています。

       builder = builder.taskDecorator(taskDecorator.getIfUnique());

このため TaskDecorator をBean登録すれば自動的に取り込まれますが、 ObjectProvider#getIfUnique の動きも知っておきたいところです。

  • 対象Beanがなければ null
  • 対象Beanが1つだけならそれ
  • 対象Beanが複数ある場合
    • 何かしらで1つに特定できればそれ
    • 特定できなければ null

注意したいのは「複数登録されていると null になり得る」です。特定にはBeanの名前や @Primaryが使用されますが、特定できなくてもエラーも出ず単に設定されていないって動きをします。Bean定義が散在すると気づかないうちに未設定になっているなどがあるかもしれません。残念ながら特にエラーログも出てくれません。組織体制によっては固く定義した方がいいかもしれません。

ということで「 Executor とかいじってないで TaskDecorator だけBean登録する」で多くの場合は事足りるはずです。

@Bean
TaskDecorator taskDecorator() {
    return runnable -> {
        // ここは呼び出し元のスレッドで実行される。必要なものを取り出す。
        return () -> {
            // ここからExecutorのスレッドで実行される。外から受け取ったものを設定する。
            try {
                runnable.run();
            } finally {
                // 後片付けを忘れない
            }
        };
    };
}

もし「TaskDecoratorじゃ足りないんだよぅ」とかでも、先のシグネチャの通りまだ TaskExecutorCustomizer があります。 TaskExecutor をごそっと差し替えるのは、以下を検討してからって感じですね。

  • プロパティでなんとかならないか
  • TaskDecorator でどうにかならないか
  • TaskExecutorCustomizer でもできないだろうか
  • TaskExecutorBuilder は使えるんじゃないか

どれでも満たせないなら Executor のBean登録で対応。その時は Executor をちゃんと正しく取り扱いましょう。でもそこまで行くと @Async とか使わず実装する方が制御しやすいとかもある気がしないでもないです。

あとがき

SpringBootはSpringFrameworkの拡張ポイントも踏襲しているため、一つのことでもたくさんのやり方があります。 検索するとやり方はたくさん出てくるし、確かにそれでも動きはします。確かに動く、動くけど、けどさー……と思いつつ。

「知っているべき」って言わないのは、私も知らずにやってるものたくさんあるからです。知ろうと思った時に知ることができる筋力はつけておきたいお気持ち。

特にSpringFrameworkの拡張ポイントはSpringBootよりもドラスティックに変更できるものも多く、SpringBootはそちらに譲る形で実装されています。たとえば @ConditionalOnMissingBean とか。 でもSpringBootにのっかるならSpringBootに乗っかったまま拡張したい感がある。SpringBootのバージョンアップとかでその辺の対応がいらなくなった時にすんなり乗れますし、互換性も崩れにくいし、マイグレーションガイドでフォローされる可能性も高い。先にあげたメトリクスのようにいい感じに面倒見てくれるところも多いですし。たまに予期しないところに影響でたりもするんだけど、それ系は自分が微妙なことしたりするせいで、メリットの方が多いと思ってます。

あ、今回は @Async のを書きましたが、 @ScheduledExecutor も大体同じです。 ThreadPoolTaskExecutorThreadPoolTaskScheduler の違いは型階層だと TaskScheduler を実装してるかどうかだけで、TaskSchedulingAutoConfiguration@AutoConfiguration(after = TaskExecutionAutoConfiguration.class) なところとか趣深い。

おまけ

この辺のことを整理するときは物理紙やMiroとかに図を起こしながらやってます。こういう崩したり強弱つけたり配置に意味を持たせたりは機械的なクラス図だと厳しいのよね。

参考/参照