この記事では、C++ の RAII クラスを Python 上で再現することで発生する問題と、コンテキストマネージャによる安全なリソース管理の実装例を紹介します。
背景
私がこの問題に遭遇したのは、C++ で書いたクラスを Pybind11 というライブラリを用いて Python 上でも使えるようにしていたときでした。(Pybind11 は C++ の関数やクラスを Python から呼び出せるようにできるライブラリです。)
C++ ではリソースの確実な解放のため、デストラクタでリソースを解放する RAII (Resource Acquisition Is Initialization) のクラスをよく書きますが、その RAII のクラスを Python から呼び出せるようにした結果、デストラクタが呼ばれないという問題が発生しました。
環境
- OS: Ubuntu 20.04
- Python: CPython 3.8.6(Pipenv を使用)
実験 1(デストラクタが呼ばれない例)
ここでは、簡単な例として、循環参照したオブジェクトを利用します。(実際に問題に直面したプログラムは複雑すぎてどこに原因があるのかわかっていません。)
まず、C++ でクラスを書いて Python から呼べるようにします。
#include <functional>
#include <iostream>
#include <pybind11/functional.h>
#include <pybind11/pybind11.h>
class TestClass {
public:
TestClass() { std::cout << "TestClass::TestClass" << std::endl; }
~TestClass() { std::cout << "TestClass::~TestClass" << std::endl; }
void test() { std::cout << "TestClass::test" << std::endl; }
std::function<void()> func;
};
PYBIND11_MODULE(test_lib, module) {
pybind11::class_<TestClass>(module, "TestClass")
.def(pybind11::init<>())
.def("test", &TestClass::test)
.def_readwrite("func", &TestClass::func);
}
このクラスを Python から利用してみます。
from test_lib import TestClass
def main():
obj = TestClass()
obj.func = obj.test # 循環参照を使用すると
del obj # たとえ del を使用してもデストラクタは呼ばれない。
if __name__ == '__main__':
print('before main')
main()
print('after main')
このスクリプトを実行すると以下のようになります。
$ python ./test_destruct_pybind11_class.py
before main
TestClass::TestClass
after main
デストラクタが呼ばれません。
メンバ関数でも循環参照になるため、コールバック関数を用いるようなプログラムでは気づかないうちに循環参照することになります。C++ なら std::weak_ptr とかで回避できるのですが…。(おそらく、「背景」の中で私が直面した問題もこれだと思います。)
Python におけるリソース管理
Python のドキュメント によると、Python におけるデストラクタ __del__
(Python ではファイナライザと呼ぶ)はインタプリタ終了時に呼ばれる保証がありません。また、__del__
はどのスレッドがいつ呼んでも良いという緩い制約から、デッドロックの原因にもなると書いてあります。実際、Pybind11 で デストラクタ内におけるスレッドの join がデッドロックした例 もあります。
代わりに、Python にはリソース管理に適した コンテキストマネージャ があります。Python 標準のファイル読み書きの API や threading.Lock といったリソースのオブジェクトはコンテキストマネージャで管理できるようになっています。__del__
は使われません。
実験 2(コンテキストマネージャの利用)
コンテキストマネージャを Pybind11 でも使えるようにしたいと思い、次のようなクラスを作ってみました。
#include <iostream>
#include <pybind11/pybind11.h>
class ContextTestClass {
public:
ContextTestClass() {}
void start() { std::cout << "ContextTestClass::start" << std::endl; }
void stop() { std::cout << "ContextTestClass::stop" << std::endl; }
void test() { std::cout << "ContextTestClass::test" << std::endl; }
};
PYBIND11_MODULE(test_lib, module) {
pybind11::class_<ContextTestClass>(module, "ContextTestClass")
.def(pybind11::init<>())
.def("test", &ContextTestClass::test)
.def("start", &ContextTestClass::start)
.def("stop", &ContextTestClass::stop)
.def(
"__enter__",
[](ContextTestClass& self) -> ContextTestClass& {
self.start();
return self;
},
pybind11::return_value_policy::reference)
.def("__exit__",
[](ContextTestClass& self, pybind11::args /*unused_args*/) {
self.stop();
return false;
});
}
ここで、__enter__
と __exit__
がコンテキストマネージャに必要な関数です。上記のクラスが実際にコンテキストマネージャになっているか試してみました。
from test_lib import ContextTestClass
def main():
obj = ContextTestClass()
with obj:
obj.test()
if __name__ == '__main__':
print('before main')
main()
print('after main')
このスクリプトを実行すると、以下のように stop 関数が呼ばれることを確認できます。
$ python ./test_pybind11_context_manager.py
before main
ContextTestClass::start
ContextTestClass::test
ContextTestClass::stop
after main
これなら確実に終了処理を呼ばせることができます。
実験 1 のように循環参照が起きてしまっても、終了処理で内部の変数をクリアしてしまえば、循環参照をなくしてオブジェクトが解放されるようにできそうです。
まとめ
Python において、必ずしも呼ばれる保証のないデストラクタと違って、コンテキストマネージャでは確実に終了処理を呼ばせることができます。リソースを確実に解放させたい場合はコンテキストマネージャを利用しましょう。