atexitで終了させるスレッドはdaemonにしよう

なにかの処理をバックグラウンドスレッドで実行して、アプリケーション終了時にその処理を止めたいことがあります。 たとえばOpenTelemetryのトレースやログを送信するためにスレッドが使われていますが、それらは終了時にバッファリングしているデータを送信してから終了します。

コマンドラインアプリケーションでは、main関数の終了時にバックグラウンドスレッドの終了を呼び出して待つことができます。

# th_main.py
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モジュールです。次のようにすればバックグラウンドスレッドを止められそうに見えます。

# th_atexit.py
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)

    // Block some operations.
    tstate->interp->finalizing = 1;


    // Wrap up existing "threading"-module-created, non-daemon threads.
    wait_for_thread_shutdown(tstate);


    // Make any remaining pending calls.
    _Py_FinishPendingCalls(tstate);


    /* The interpreter is still entirely intact at this point, and the
     * exit funcs may be relying on that.  In particular, if some thread
     * or exit func is still waiting to do an import, the import machinery
     * expects Py_IsInitialized() to return true.  So don't say the
     * runtime is uninitialized until after the exit funcs have run.
     * Note that Threading.py uses an exit func to do a join on all the
     * threads created thru it, so this also protects pending imports in
     * the threads created via Threading.
     */


    _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から終了処理を実行する場合はデーモンスレッドにする必要があるので注意が必要です。

先ほどのプログラムをデーモンスレッドに修正してみましょう。

# th_atexit2.py
import threading
import time
import atexit

alive = True


def bgmain():
    while alive:
        print("hello")
        time.sleep(1)


th = threading.Thread(target=bgmain, daemon=True)  # daemonスレッドに
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を止めることができます。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。