なにかの処理をバックグラウンドスレッドで実行して、アプリケーション終了時にその処理を止めたいことがあります。
たとえばOpenTelemetryのトレースやログを送信するためにスレッドが使われていますが、それらは終了時にバッファリングしているデータを送信してから終了します。
コマンドラインアプリケーションでは、main関数の終了時にバックグラウンドスレッドの終了を呼び出して待つことができます。
import threading
import time
alive = True
def bgmain():
while alive:
print("hello")
time.sleep(1)
th = threading.Thread(target=bgmain)
th.start()
def main():
global alive
try:
while True:
time.sleep(1)
finally:
alive = False
th.join()
main()
実行例:
$ python3 th_main.py
hello
hello
^CTraceback (most recent call last):
File "/Users/inada-n/work/th_main.py", line 27, in <module>
main()
~~~~^^
File "/Users/inada-n/work/th_main.py", line 21, in main
time.sleep(1)
~~~~~~~~~~^^^
KeyboardInterrupt
しかし、wsgiアプリケーションのようにmain関数が無い場合があります。そのような場合に終了処理を実装するための標準ライブラリとして提供されているのがatexitモジュールです。次のようにすればバックグラウンドスレッドを止められそうに見えます。
import threading
import time
import atexit
alive = True
def bgmain():
while alive:
print("hello")
time.sleep(1)
th = threading.Thread(target=bgmain)
th.start()
@atexit.register
def on_exit():
global alive
print("atexit")
alive = False
th.join()
def main():
while True:
time.sleep(1)
main()
しかし、このプログラムを止めるためにはCtrl-Cを2回押す必要がありました。コマンドラインプログラムだから停止できましたが、wsgiアプリケーションだとしたら強制的に終了されるまで待ち続けることになります。
$ python3 th_atexit.py
hello
hello
hello
^CTraceback (most recent call last):
File "/Users/inada-n/work/th_atexit.py", line 32, in <module>
main()
~~~~^^
File "/Users/inada-n/work/th_atexit.py", line 29, in main
time.sleep(1)
~~~~~~~~~~^^^
KeyboardInterrupt
hello
hello
^CTraceback (most recent call last):
File "/opt/homebrew/Cellar/python@3.13/3.13.5/Frameworks/Python.framework/Versions/3.13/lib/python3.13/threading.py", line 1542, in _shutdown
_thread_shutdown()
KeyboardInterrupt:
atexit
ateixit が表示されているのが二回目のCtrl-Cの後、最後であることがわかります。
これはatexitが呼ばれるタイミングの問題です。
Py_Finalize(PyRuntimeState *runtime)
tstate->interp->finalizing = 1;
wait_for_thread_shutdown(tstate);
_Py_FinishPendingCalls(tstate);
_PyAtExit_Call(tstate->interp);
atexitが呼ばれるのは非daemonスレッドが全て終了してからだということがわかります。
Pythonのドキュメントにはdaemonスレッドについてこう書かれています。
スレッドには "デーモンスレッド (daemon thread)" であるというフラグを立てられます。 このフラグには、残っているスレッドがデーモンスレッドだけになった時に Python プログラム全体を終了させるという意味があります。フラグの初期値はスレッドを生成したスレッドから継承します。フラグの値は daemon プロパティまたは daemon コンストラクタ引数を通して設定できます。
注釈 デーモンスレッドは終了時にいきなり停止されます。デーモンスレッドで使われたリソース (開いているファイル、データベースのトランザクションなど) は適切に解放されないかもしれません。きちんと (gracefully) スレッドを停止したい場合は、スレッドを非デーモンスレッドにして、Event のような適切なシグナル送信機構を使用してください。
https://docs.python.org/ja/3.12/library/threading.html#thread-objects
このドキュメントを読むと、アプリケーション終了時にきちんと終了させるスレッドはデーモンスレッドにするべきでないということになります。しかし、atexitから終了処理を実行する場合はデーモンスレッドにする必要があるので注意が必要です。
先ほどのプログラムをデーモンスレッドに修正してみましょう。
import threading
import time
import atexit
alive = True
def bgmain():
while alive:
print("hello")
time.sleep(1)
th = threading.Thread(target=bgmain, daemon=True)
th.start()
@atexit.register
def on_exit():
global alive
print("atexit")
alive = False
th.join()
def main():
while True:
time.sleep(1)
main()
$ python3 th_atexit2.py
hello
hello
hello
^CTraceback (most recent call last):
File "/Users/inada-n/work/th_atexit2.py", line 32, in <module>
main()
~~~~^^
File "/Users/inada-n/work/th_atexit2.py", line 29, in main
time.sleep(1)
~~~~~~~~~~^^^
KeyboardInterrupt
atexit
きちんと1回のCtrl-Cでatexitが呼ばれて終了しました。
しかし、たとえば標準ライブラリの concurrent.futures.ThreadPoolExecutor
はdamonスレッドを使っていません。
そのため、 executor.shutdown(wait=True)
を atexit から呼び出すことができません。
この問題は現在コア開発者で話し合いが進められていますが、まだ解決のためのAPIは実装されていません。
今は非デーモンスレッドをバックグラウンドで使うライブラリにモンキーパッチを当てていくか、WSGIサーバーが提供する独自APIを使う必要があります。
例えば uwsgi
には uwsgi.atexit
というAPIがあり、これに登録された処理は Py_Finalize()
を呼び出す前に実行されるので ThreadPoolExecutorを止めることができます。
9/2追記: ThreadPoolExecutorはatexit実行前に終了待ちされるので、 executor.shutdown()
などを atexit から実行する必要はありません。問題になるのはバックグラウンドスレッドで動き続けている処理を止めるためにatexitを使う場合だけで、ThreadPoolExecutorの一般的な使い方であるそれなりの時間で終わる処理をバックグラウンドで実行するだけであれば問題ありません。次の補足記事を参照してください。
methane.hatenablog.jp