Pythonのメモリ管理
メモリリークとは
メモリリークとは GC - ガベージコレクション(garbage collection)
が行われたのにまだ解放されていないメモリがあることを指す。
PythonのGCは、基本的には生成したオブジェクトがどこにも参照されなくなった時にメモリを解放する。
そのため、メモリリークはプログラム内でオブジェクトがもう使われていないのにそのオブジェクトへの参照が残ってしまっている時に発生する。
参照が残ってしまう例
Pythonのdelは一般的には要素を削除しつつメモリを解放してくれる。
しかし、以下の例のように他で参照していると
メモリの解放を行わない。
# main.py
class Profile:
def __init__(self, _id: str, name: str):
self._id = _id
self.name = name
profile_id: str = "M001"
profile_name: str = "Taro"
profiles: dict[str, Profile] = {}
profiles[profile_id] = Profile(profile_id, profile_name)
names: dict[str, Profile] = {}
names[profile_name] = profiles[profile_id]
print("---- before ----")
print("members:", dict(profiles))
print("names:", dict(names))
del profiles["M001"]
print("---- after ----")
print("members:", dict(profiles))
print("names:", dict(names))
実行してみると、profiles
とnames
の辞書型の変数にある全てのオブジェクトは0x106277c10
になっている。
del profiles["M001"]
で 0x106277c10
のオブジェクトを削除しているのに、---- after ----
の後にnames
変数のTaro
というkeyのオブジェクトである <Profile object at 0x106277c10>
が削除されていない。
$ python main.py
---- before ----
members: {'M001': <Profile object at 0x106277c10>}
names: {'Taro': <Profile object at 0x106277c10>}
---- after ----
members: {}
names: {'Taro': <Profile object at 0x106277c10>}
メモリリークを試す
# main.py
class ClassList:
def __init__(self):
self.values: dict[object, str] = {}
def add(self, instance: object, name: str) -> None:
self.values[instance] = name
class ClassExample:
class_list: ClassList = ClassList()
def add(self, name: str):
self.class_list.add(self, name)
def run():
class_example: ClassExample = ClassExample()
class_example.add("class_1")
print("values:", dict(class_example.class_list.values))
print("---" * 10)
# run()
for _ in range(3):
run()
上記のmain.py
を実行してみる。
結果を見ると、run関数
が呼ばれるたびにClassListのvaluesの中のclass_exampleオブジェクトが増えていくのが分かる。
本来はrun関数の終了とともにインスタンス化したclass_exampleのメモリは解放してほしい
ものの、このようにして使われないのに残ってしまう。
$ python main.py
values: {<ClassExample object at 0x1056ebe50>: 'class_1'}
------------------------------
values: {<ClassExample object at 0x1056ebe50>: 'class_1', <ClassExample object at 0x1056ebcd0>: 'class_1'}
------------------------------
values: {<ClassExample object at 0x1056ebe50>: 'class_1', <ClassExample object at 0x1056ebcd0>: 'class_1', <ClassExample object at 0x1056ebee0>: 'class_1'}
解決策としてはweakrefモジュールのWeakKeyDictionaryを使う。
# main.py
from weakref import WeakKeyDictionary
class ClassList:
def __init__(self):
self.values: WeakKeyDictionary = WeakKeyDictionary()
def add(self, instance: object, name: str) -> None:
self.values[instance] = name
class ClassExample:
class_list: ClassList = ClassList()
def add(self, name: str):
self.class_list.add(self, name)
def run():
class_example: ClassExample = ClassExample()
class_example.add("class_1")
print("values:", dict(class_example.class_list.values))
print("---" * 10)
# run()
for _ in range(3):
run()
実行結果は以下のようになり、メモリリークは行わない。
他のメモリリークの例としてはファイルやネットワークのopen関数の後にcloseの呼び忘れなどがある。
$ python main.py
<WeakKeyDictionary at 0x103367550> <class 'weakref.WeakKeyDictionary'>
values: {<ClassExample object at 0x103367dc0>: 'class_1'}
------------------------------
<WeakKeyDictionary at 0x103367550> <class 'weakref.WeakKeyDictionary'>
values: {<ClassExample object at 0x103367dc0>: 'class_1'}
------------------------------
<WeakKeyDictionary at 0x103367550> <class 'weakref.WeakKeyDictionary'>
values: {<ClassExample object at 0x103367dc0>: 'class_1'}
メモリを計測する
- tracemalloc
- objgraph
- Pyrasite
tracemalloc
上記の処理をtracemallocで見てみる。
import tracemalloc
tracemalloc.start(10)
current_snap = tracemalloc.take_snapshot()
def run():
profile_id: str = "M001"
profile_name: str = "Taro"
profiles: dict[str, Profile] = {}
profiles[profile_id] = Profile(profile_id, profile_name)
names: dict[str, Profile] = {}
names[profile_name] = profiles[profile_id]
print("---- before ----")
print("members:", dict(profiles))
print("names:", dict(names))
del profiles["M001"]
print("---- after ----")
print("members:", dict(profiles))
print("names:", dict(names))
for _ in range(3):
print("---" * 30)
run()
snap = tracemalloc.take_snapshot()
stats = snap.compare_to(current_snap, "lineno")
for stat in stats:
print("stat", stat)
current_snap = snap
コードをいじらずにメモリリークを検証する
アプリケーションの実際のコードにメモリプロファイラを入れて検証することが難しいケースもある。実際にtracemalloc
などを随所に入れるのは難しい状況です。
コードをいじらずにメモリリークの検証方法として、上記でも紹介した Pyrasite
というライブラリーがあル。
Pyrasite
を使うと動いてるPythonのプロセスにコードを注入することができるため、Pyrasite + Pythonの好きなメモリプロファイラ
の組み合わせで簡単にメモリリークの検証ができる。
メモリ検証のツールとして、今回はObjgraph
という便利なライブラリーを使う。
$ pip install pyrasite objgraph
メモリ管理と循環参照メモリ管理
この廃物は、C言語などの変数を宣言をする言語は二種類の残骸が存在する。
-
静的に配置された変数
の残骸 -
malloc()等で動的に確保されたメモリ領域
の残骸
しかしpythonのように宣言が無い言語の場合、numpy配列のようにプログラム内で静的に配置するメモリ領域もありますが、殆どが動的に配置されたメモリの残骸。
つまりはmalloc等で動的に確保されたメモリ領域です。
しかし、普通にpythonを使っていれば、メモリーの残骸に対する配慮などはしない。
ガベージコレクション(garbage collection) という「pythonが不要になったメモリの残骸を勝手に消去する」機能があるためです。
メモリーにも仮想メモリーにも制限があるため、ガベージコレクションの例外やメモリーの効率的使用が損なわれたら、目に見えない形でエラーとなり、動的配置の為際限もなかなか難しいという場面で発生するという問題をはらんでいる。
実はメモリー管理には二種類存在する。
- 参照カウントを使った方式 (参照カウント)
- 世代別ガベージコレクション(GC - Garbage Collection)
参照カウント方式
参照カウント(さんしょうカウント、英: reference counting)
は、メモリオブジェクトのライフサイクル(寿命)
管理に使用される方式のひとつです。
ガベージコレクションの実装方法およびガベージコレクタの動作方法のひとつとしても利用される。
また、コピーオンライト の実装方法としても多用される。
処理の概要
-
すべてのオブジェクト(メモリ上に置かれているデータの単位)
に対して、 参照カウントと呼ばれる整数値を付加しておく。これは、このオブジェクトへの参照(あるいはポインタ)がシステム全体にいくつ存在しているかを数えるもの。 - オブジェクトへの参照が変化するたびにこの値は随時書き換わる。
- 参照カウントが0になったものについては破棄が許される。 共有された単一のオブジェクトへの参照ではなく、独立したデータを擬似的に表現する場合は、 下記の処理を追加する。
- オブジェクトのコピーが要求されても、実際にはコピーを行わず 元のオブジェクトへの参照を返し、参照カウントに1加える。
- オブジェクトの変更が行われる場合は、以下の手順で行う
- 参照カウントが1であればそのまま書き換える。
- 参照カウントが2以上であれば、元のオブジェクトをコピーして参照カウントが1の新オブジェクトを作成し、それを書き換える。元のオブジェクトの参照カウントは1減らす。
実装例
実際どのようなケースで参照されるかは具体的に以下がある。
- 代入演算子を使用した時
- 関数の引き渡しをした時
- コンテナ型オブジェクトを追加した時
+1がされ、参照が無くなると(例えば関数から抜ける場合など)-1され、最終的に0になったタイミングでpythonインタプリタが終了する。
参照カウントを表示するメソッドである sys.getrefcount()
が用意されていますので、実際にそうなっているのか検証してみる。
# main.py
import sys
class obj:
pass
def f(x):
print(">>test17(f) x = " , sys.getrefcount(x))
a = obj()
print(">>test17_test01 a = " , sys.getrefcount(a))
print(">>test17_test01 f = " , sys.getrefcount(f))
f(a)
print(">>test17_test01 a = " , sys.getrefcount(a))
a = None
print(">>test17_test01 a = " , sys.getrefcount(a))
# [result]
$ python main.py
# >> test17_test01 a = 2
# >> test17_test01 f = 2
# >> test17(f) x = 4
# >> test17_test01 a = 2
# >> test17_test01 a = 2205
$ python main.py
# >> test17_test01 a = 2
# >> test17_test01 f = 2
# >> test17(f) x = 4
# >> test17_test01 a = 2
# >> test17_test01 a = 2229
$ python main.py
# >> test17_test01 a = 2
# >> test17_test01 f = 2
# >> test17(f) x = 4
# >> test17_test01 a = 2
# >> test17_test01 a = 2291
sys.getrefcount()
リファレンスにもありますが、参照カウンタは定義されると実際より+1
された数字で出るため最初は2
として登場する。
次にpython関数内で参照すると、パラメータとして記載時スタックが参照するため+2
ずつ加算され結果的に+2
されて4
となる。
「オブジェクトの参照カウンタは0になります(誰にも参照されない状態)」
これは0になったのではなく、実際には領域がなくなった = 解放された
ことにより「誰にも参照されなくなる」と解釈される。
参照されない
ということを、プログラム上では不定な状態
となり、値が起動ごとに異なる状態(定まらない)となる。
さらに分かったことは、今は変数を対象にしていますがサンプルプログラム中f
も参照しているように、特定の対象に対してのみ参照カウンタが存在するのではなく、オブジェクトの基本構造体内にob_refcnt(参照カウンタ)
が存在し、参照される毎にインクリメント、参照が解かれるたびにデクリメントを行い最終的に解放され不定値となっている。
https://www.sejuku.net/blog/90518'
参照カウントが1以上のとき、メモリは確保されたままになる。
逆に0になったとき、GCはそれを不要だと判断する。
import sys
mylist = []
def myfunc(x):
print("3 - refcount of `mylist`: ", sys.getrefcount(mylist))
print("1 - refcount of `mylist`: ", sys.getrefcount(mylist))
print("2 - refcount of `myfunc`: ", sys.getrefcount(myfunc))
myfunc(mylist)
print("4 - refcount of `mylist`: ", sys.getrefcount(mylist))
実行結果は以下のようになります。
1 - refcount of `mylist`: 2
2 - refcount of `myfunc`: 2
3 - refcount of `mylist`: 4
4 - refcount of `mylist`: 2
1 - mylistという変数が参照しているリスト型オブジェクトの参照カウントを表示しています。ここで、mylist変数とsys.getrefcount関数がそれぞれこのオブジェクトを参照しているので、カウントは2になります。
2 - myfuncという名前で定義された関数オブジェクトの参照カウントを表示しています。これも1
と同様の理由でカウントは2です。関数であっても同様に扱われます。
3 - myfunc内での引数xが参照するオブジェクトの参照カウントです。ここでは、mylist、関数の引数、sys.getrefcount関数、そしてPythonの関数スタックが参照しているので4になります。
4 - myfuncの関数スコープから出た後なので、2
と同様になります。
このカウントが0になるとGCがメモリを開放します。
import sys
test_val = {"test":"test"} # 変数の作成
print(sys.getrefcount(test_val)) # 2 -> 変数自身と、getrefcount関数からの参照
copy_val = test_val # 別の変数にtest_valをコピー
print(sys.getrefcount(test_val)) # 3 -> copy_valからも参照されたため数が増えた
del copy_val # コピーした変数を削除
print(sys.getrefcount(test_val)) # 2 -> copy_valからの参照が消えたため
世代別ガベージコレクション
参照カウント方式
はシンプルで分かりやすいアルゴリズムですが、よく知られた落とし穴がある。
循環参照という状態になったとき、参照カウントが常に1以上になる(のでGCが動かない)というもの。
mylist = []
mylist.append(mylist)
これはリスト型を始めとしたコンテナ型オブジェクトで起こる問題で、自分で自分自身を参照したり、2つのコンテナで相互に参照し合ったりすると発生する。
- 自分で自分自身を参照した
- 2つのコンテナで相互に参照し合う
この場合常に参照カウントは1以上なので、参照カウント方式のGCだけではメモリ解放ができない。
世代別ガベージコレクションではこのような場合にもGCを動かすための実装がされている。