今回は 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 を使うという選択肢は、もちろんない。
__del__()
メソッド内でインポートするのをやめる- オブジェクトを
del
文で明示的に破棄する - CPython 以外の実装を使う
ひとつ目の回避策は一見するともっともで、そもそもファイルの先頭以外でのインポートは PEP8 に準拠していない。 とはいえ、現実にはそうもいかない場合があって。 例えば標準パッケージでもファイルの先頭以外でインポートしている例は見つかる。 このようなコードを間接的にでも呼び出すと、同じ問題が起こる。
上記でインポートしているモジュール 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()
関数が呼ばれているようだ。
いじょう。
スマートPythonプログラミング: Pythonのより良い書き方を学ぶ
- 作者: もみじあめ
- 発売日: 2016/03/12
- メディア: Kindle版
- この商品を含むブログ (1件) を見る