🧵

ThreadPoolTaskExecutorの停止時の挙動

2024年07月30日


Quartz Scheduler のジョブからバックグラウンドタスクを実行する際、 Spring の ThreadPoolTaskExecutor に処理を移乗するようにしました。 1

その際、以下のような挙動を実現したかったが、どう設定すれば思った通りの設定になるのかよくわからず、いろいろ調べた内容をまとめておきます。

実現したい挙動

アプリを停止する際に、以下のようにしたい。

  • 新規のタスクは受付停止する
  • すでに起動中のタスクは、そのまま完了するまで待つ
    • 無限に待つのも良くないので、タイムアウトを設定する

ThreadPoolTaskExecutorの停止関連の設定

調べたところ、以下の設定が停止時の挙動に関連している事が判明。

  • strictEarlyShutdown
  • acceptTasksAfterContextClose
  • waitForTasksToCompleteOnShutdown
  • awaitTerminationMillis

JavaDocを読んでもよく挙動の違いがわかんないため、ソースからいろいろ調べてみた。

三種類の停止モード

実装を見たところ、どうやら三種類の「停止モード」(JavaDocではそのような呼び方はしてないため、便宜上の呼び方)がある模様。 この停止モードの違いにより、新規タスクを受け付けるタイミング、新規タスクの実行を抑制するタイミングなどが異なるようです。

  • default
  • earlyShutdown
  • lateShutdown
タイミングdefaultearlyShutdownlateShutdown
ContextClose制御なし新規タスク受付停止制御なし
LifecycleStop新規タスク受付停止, 新規タスク実行抑制, 実行中タスクの完了待ち新規タスク実行抑制, 実行中タスクの完了待ち制御なし
BeanDestroy実行中タスクの強制停止, 完了待ち実行中タスクの強制停止, 完了待ち新規タスク受付停止, 新規タスク実行抑制, (実行中タスクの強制停止), 完了待ち

基本的に lateShutdown 以外は、ライフサイクルの停止処理時には新規タスクの実行を抑制して、実行中タスクの完了待ちをする。 defaultearlyShutdown の違いは、新規タスクの受付(submit) をいつまで許容するかで、 earlyShutdown のほうがタイミングが早い。

lateShutdown の場合は、ライフサイクルとか関係なく、停止する直前までなるべくタスク受付して実行しようと頑張る感じ。「実行中タスクの強制停止」を括弧書きにしたのは、ここが設定によって挙動が変わるから。

また、default earlyShutdown の場合は LifecycleStop で完了待ちするから、BeanDestroy で強制停止なんてしないのでは?という感じもあるが、多分 LifecycleStop の完了待ちにはタイムアウトがありそう。このタイムアウト時間を超えて停止できないと処理が先に進んで、最終的に BeanDestroy で強制停止という形になるように見えた。

各設定値の効果

strictEarlyShutdown

earlyShutdown モードにする。 ただし、acceptTasksAfterContextClose または waitForTasksToCompleteOnShutdown が設定されていると無視されて、強制的に lateShutdown モードに切り替わる。

acceptTasksAfterContextClose

lateShutdown モードにする。 前述の通り、strictEarlyShutdown よりも優先される。

また、BeanDestroy 時に実行中タスクを強制停止する

waitForTasksToCompleteOnShutdown

lateShutdown モードにする。 前述の通り、strictEarlyShutdown よりも優先される。

acceptTasksAfterContextClose とは違い、BeanDestroy 時に実行中タスクを強制停止しない。

awaitTerminationMillis

BeanDestroy 時の、実行中タスク完了までの待機時間。 デフォルトでは待機しない

強制停止したら待機もなにもないのでは?と思うかもしれませんが、処理が動いているスレッドを止めるのはそんなに簡単じゃないのです。 基本的に外部からはそのスレッドに割り込むしかできませんが、 スレッド側の処理が (Thread.sleep() などの) 割り込みを受け付ける処理で待機しているか、 明示的に割り込みがないかを確認して自発的に止めない限り、処理は止まらない仕組みです。 なので、強制停止という名の割り込みをかけても止まる保証はないので、ちゃんと止まるまで待つみたいなことを考える必要があるわけです。

デフォルトは待機しないなので、例えば実行中のタスクの最後でDBにステータス更新するような処理が合った場合も、アプリが終了しちゃって更新されないままになります。無慈悲です。

実現したい挙動を実現するには

基本的にはデフォルトのままで良い(というか、良かれと思って waitForTasksToCompleteOnShutdown とか設定すると思わぬ挙動になる) ということがわかりました。

また、追加で以下のようなカスタムが必要ということもわかりました。

  • ThreadPoolTaskExecutorphase を、 Quartzの SchedulerFactoryBean よりも小さい値にする
    • 停止時に Quartz Scheduler が停止してから ThreadPoolTaskExecutor を停止しないと、新規タスクを受付できなくなるため (ここで良かれと思って acceptTasksAfterContextClose を設定したりすると思わぬ挙動になる)
    • 実行されることよりも、変なログが出ないようにしたいという気持ち
  • LifecycleStop時のタイムアウトを調整する

参考資料

Footnotes

  1. Quartz SchedulerのMisfireあたりの挙動がいろいろ難しく、ジョブが滞留した場合とか、アプリを上げ下げしたときとか、なかなか思ったとおりに制御しづらい感じになりました。 できないことはないけど、Quartzのナイーブな仕様に乗っかった実装をするのはリスクがあるため、自分でスレッドプールを使って制御したほうが楽そうという判断になりました。