CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: グローバルスコープにあるオブジェクトの __del__() でインポートしたときの挙動について

今回は Python の __del__() メソッドでちょっと不思議な挙動を目にしてから色々と調べてみた話。 具体的には、グローバルスコープにあるオブジェクトの __del__() で別のモジュールをインポートしてるとき、そのオブジェクトがプロセス終了時に破棄されると場合によっては例外になる。 ただし、これは Python の仕様かというとかなり微妙で CPython の 3.x 系でしか同じ問題は観測できていない。 少なくとも、同じ CPython でも 2.x 系や、同じ Python 3.x 系の実装でも PyPy3 では発生しない。 おそらく実装上の都合によるものだと思う。

使った環境は次の通り。

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.1 LTS"
$ uname -r
4.15.0-20-generic
$ python3 -V
Python 3.6.5
$ python -V
Python 2.7.15rc1

オブジェクトの __del__() メソッドについて

まずは前提となる知識から。 Python のオブジェクトは __del__() という特殊メソッドを定義することで、自身が破棄されるときの挙動をオーバーライドできる。 この __del__() メソッドは、デストラクタやファイナライザとも呼ばれる。

例えば、以下のサンプルコードでは Example クラスに __del__() メソッドを定義している。 それを main() 関数の中でインスタンス化した後に del 文を使って明示的に破棄している。

#!/usr/bin/env python
# -*- coding: utf-8 -*-


class Example(object):

    def __init__(self):
        """オブジェクトが作られるとき呼び出される"""
        print('born:', id(self))

    def __del__(self):
        """オブジェクトが破棄されるとき呼び出される"""
        print('died:', id(self))


def main():
    print('making')
    o = Example()  # オブジェクトを作る
    print('deleting')
    del o  # オブジェクトを明示的に破棄する
    print('done')


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python3 explicitdel.py
making
born: 139716100508416
deleting
died: 139716100508416
done

del 文が発行されたタイミングで __del__() メソッドが呼び出されていることが分かる。

また、明示的に del 文を発行しなくても、オブジェクトの寿命と共に呼び出される。 次のサンプルコードでは、先ほどとは異なり明示的に del 文を発行していない。 ただし、関数スコープの終了と共にオブジェクトは破棄されることが期待できる。

#!/usr/bin/env python
# -*- coding: utf-8 -*-


class Example(object):

    def __init__(self):
        """オブジェクトが作られるとき呼び出される"""
        print('born:', id(self))

    def __del__(self):
        """オブジェクトが破棄されるとき呼び出される"""
        print('died:', id(self))


def main():
    print('making')
    o = Example()
    # 明示的にオブジェクトを破棄しない
    print('done')


if __name__ == '__main__':
    print('start')
    main()
    print('end')

上記を実際に実行してみよう。

$ python3 implicitdel.py 
start
making
born: 140412486589464
done
died: 140412486589464
end

たしかに main() 関数が終了するタイミングで __del__() メソッドが呼び出されていることが分かる。

本題

ここからが本題なんだけど、例えば以下のようなコードがあったとする。 特徴としては二つある。 まずひとつ目は __del__() メソッドの中で別のモジュールをインポートしているところ。 そしてふたつ目が、そのオブジェクトがグローバルスコープにあるところ。

#!/usr/bin/env python
# -*- coding: utf-8 -*-


class Example(object):

    def __init__(self):
        """オブジェクトが作られるとき呼び出される"""
        print('born:', id(self))

    def __del__(self):
        """オブジェクトが破棄されるとき呼び出される"""
        import sys  # __del__() の中で他のモジュールをインポートする
        print('died:', id(self))


# オブジェクトをグローバルスコープに設置する
print('making')
o = Example()
print('done')
# プロセスが終了するタイミングでオブジェクトが破棄される

で、これを 3.x 系の CPython で実行すると、次のような例外になる。

$ python3 gimplicitdel.py 
making
born: 139889227380104
done
Exception ignored in: <bound method Example.__del__ of <__main__.Example object at 0x7f3a7fb4b588>>
Traceback (most recent call last):
  File "globaldel.py", line 13, in __del__
ImportError: sys.meta_path is None, Python is likely shutting down

なんかもう Python が終了しようとしてるからインポートするの無理っすみたいなエラーになってる。

ちなみに、同じ CPython でも 2.x 系であれば例外にならない。

$ python gimplicitdel.py
making
('born:', 140683376878608)
done
('died:', 140683376878608)

回避策 (ワークアラウンド)

この問題が起こる理由は一旦置いといて、とりあえず回避する方法としては以下の三つがある。 尚 Python 2.x を使うという選択肢は、もちろんない。

  1. __del__() メソッド内でインポートするのをやめる
  2. オブジェクトを del 文で明示的に破棄する
  3. CPython 以外の実装を使う

ひとつ目の回避策は一見するともっともで、そもそもファイルの先頭以外でのインポートは PEP8 に準拠していない。 とはいえ、現実にはそうもいかない場合があって。 例えば標準パッケージでもファイルの先頭以外でインポートしている例は見つかる。 このようなコードを間接的にでも呼び出すと、同じ問題が起こる。

github.com

上記でインポートしているモジュール dbm は、環境によっては存在しない。 そこで、あるときだけ使うためにこのようなコードになっているんだと思う。

ふたつ目の選択肢は、ひとつ目がダメなときは現実的になってくるかもしれない。 Python は atexit というモジュールを使ってインタプリタの終了ハンドラが登録できる。 その中で del 文を発行すれば良いと思う。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import atexit


class Example(object):

    def __init__(self):
        """オブジェクトが作られるとき呼び出される"""
        print('born:', id(self))

    def __del__(self):
        """オブジェクトが破棄されるとき呼び出される"""
        import sys  # __del__() の中で他のモジュールをインポートする
        print('died:', id(self))


# オブジェクトをグローバルスコープに設置する
print('making')
o = Example()
print('making done')

def _atexit():
    """オブジェクトを後始末する"""
    print('atexit')
    global o  # グローバルスコープの変数を変更する
    print('deleting')
    del o  # 終了ハンドラの中で明示的にオブジェクトを破棄する
    print('deleting done')


def main():
    # 終了ハンドラに登録する
    atexit.register(_atexit)


if __name__ == '__main__':
    main()

上記を実行してみよう。

$ python3 delatexit.py 
making
born: 140689479625640
making done
atexit
deleting
died: 140689479625640
deleting done

たしかに、今度はエラーにならない。

ただ、ここまで書いておいてなんだけど、そもそも本当に回避する必要はあるのか?という点も議論の余地があるかもしれない。 問題は Python のプロセスが終了するタイミングの話なので、放っておいても結局のところオブジェクトとか関係なくメモリは開放される。 とはいえ、例外のせいで終了時のステータスコードは非ゼロにセットされるし、対処しないと気持ち悪いのはたしか。

ソースコードから問題について調べる

ここからは、この問題について CPython のソースコードを軽く追ってみた話。

まず、前述した例外を出しているのは以下のようだ。

https://github.com/python/cpython/blob/v3.6.6/Lib/importlib/_bootstrap.py#L873,L876

コメントには PyImport_Cleanup() 関数が呼ばれている最中か、あるいは既に呼ばれたことで起こると書いてある。

ドキュメントを確認すると、この関数は用途が内部利用なもののインポート関連のリソースを開放する目的があるらしい。

モジュールのインポート — Python 3.6.6 ドキュメント

ソースコードでいうと、以下に該当する。

https://github.com/python/cpython/blob/v3.6.6/Python/import.c#L335

上記の関数が呼ばれるのは、以下の二箇所かな。

https://github.com/python/cpython/blob/v3.6.6/Python/pylifecycle.c#L608

https://github.com/python/cpython/blob/v3.6.6/Python/pylifecycle.c#L881

上記の PyImport_Cleanup() という関数がオブジェクトが破棄されるよりも前に呼び出されているとアウトっぽいことが分かった。

続いては、実際にタイミングを動的解析で調べてみよう。 まずは GDB と Python3 のデバッグ用パッケージをインストールする。

$ sudo apt-get install gdb python3-dbg

次に gdb コマンド経由で Python を起動する。

$ gdb --args python3 gimplicitdel.py

お目当ての関数にブレークポイントを打つ。

(gdb) b PyImport_Cleanup
Breakpoint 1 at 0x573b80: file ../Python/import.c, line 336.

プログラムを走らせるとブレークポイントに引っかかった。

(gdb) run
Starting program: /usr/bin/python3 gimplicitdel.py
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
making
born: 140737352545616
done

Breakpoint 1, PyImport_Cleanup () at ../Python/import.c:336
336 ../Python/import.c: No such file or directory.

バックトレースはこんな感じ。 さっき確認した場所から呼ばれてる。

(gdb) bt
#0  PyImport_Cleanup () at ../Python/import.c:336
#1  0x0000000000426906 in Py_FinalizeEx () at ../Python/pylifecycle.c:608
#2  0x0000000000426b15 in Py_FinalizeEx () at ../Python/pylifecycle.c:740
#3  0x0000000000441c22 in Py_Main (argc=argc@entry=2, argv=argv@entry=0xa8f260)
    at ../Modules/main.c:830
#4  0x0000000000421ff4 in main (argc=2, argv=<optimized out>)
    at ../Programs/python.c:69

プログラムを進めると、今度は前述した例外が上がった。

(gdb) c
Continuing.
Exception ignored in: <bound method Example.__del__ of <__main__.Example object at 0x7ffff7e7b550>>
Traceback (most recent call last):
  File "gimplicitdel.py", line 13, in __del__
ImportError: sys.meta_path is None, Python is likely shutting down
[Inferior 1 (process 11210) exited normally]

たしかにオブジェクトの __del__() メソッドが呼ばれるより前に PyImport_Cleanup() 関数が呼ばれているようだ。

いじょう。