もう結構前なのですが、メモ化というテクニックを紹介しました。
参考: pythonの関数をメモ化する
これは@functools.lru_cacheというデコレーターを使って、関数の戻り値を記録しておいて何度も同じ関数を実行するコストを削減するのでしたね。計算コストが削減される代わりに、結果を保存しておく分メモリを消費します。
僕はこれを結構使ってたのですが、最近、これをクラスのメソッドに対して利用しているとメモリリークを引き起こすことがあるという気になる情報を得ました。ブログで紹介しちゃった責任もあるので、今回はその問題について調べました。
この問題は何箇所かで指摘されていて、一例を挙げるとこのissueなどがあります。
参考: functools.lru_cache keeps objects alive forever · Issue #64058 · python/cpython
こっちのYoutubeでも話されていますね。
参考: don’t lru_cache methods! (intermediate) anthony explains #382 – YouTube
具体的に説明していくために超単純なクラスを作って実験していきましょう。
まず、そのまま返すメソッドを持ってるだけのシンプルクラスを作ります。そして、このクラスがメモリから解放されたことを確認できる様に、デストラクターが呼び出されたらメッセージを表示する様にしておきます。これをインスタンス化して関数を1回使って、delで破棄します。
class sample1:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"インスタンス: {self.name} を破棄しました。")
def identity(self, x):
return x
a = sample1("a")
print(a.identity(5))
# 5
del a
# インスタンス: a を破棄しました。
デストラクターがちゃんと呼び出されていますね。
これが、メソッドがメモ化されていたらどうなるのかやってみます。
from functools import lru_cache
class sample2:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"インスタンス: {self.name} を破棄しました。")
@lru_cache(maxsize=None)
def identity(self, x):
return x
b = sample2("b")
print(b.identity(5))
# 5
del b
# 何も表示されない。
今度はガベージコレクターが動きませんでしたね。これは、変数bを削除したことによって変数bからの参照は消えたのですが、メソッドのidentity の一つ目の引数がそのインスタンス自体をとっていて、これを含めてキャッシュしているので、キャッシュがインスタンス自身への参照を保存しているためガベージコレクションの対象にならなかったのです。そのため、インスタンスbが確保していたメモリは解放されず、占拠されたままになります。
ちなみに、循環参照の状態なので、明示的にガベージコレクターを動かすと消えます。
import gc
gc.collect()
# インスタンス: b を破棄しました。
ちなみに、最初のキャッシュが発生した時に循環参照が生まれているのでメモ化したメソッドを一回も使わなかったら普通に消えます。
c = sample2("c")
del c
# インスタンス: c を破棄しました。
以上の様な問題があるので、クラスメソッドで lru_casheを使う時は気をつけて使うことをお勧めします。
とはいえ、最近のMacBookくらいのメモリ量であれば、インスタンスが何個か過剰に残ったとしてそれでメモリが枯渇する様なことはないんじゃないかなとも思います。仮にメモリがピンチになる様な使い方をしていたとしても、maxsizeを適切に設定してメモリサイズを押さえておくとか、明示的にgc/collect()動かすとかの対応が取れるかと。
僕としては、メモリが解放されないことよりも、デストラクターが動かなくてそこに仕込んだ後始末形の処理が動かないのが気になりましたね。