Open8

Pythonのメモリ管理

shimakaze_softshimakaze_soft

メモリリークとは

メモリリークとは GC - ガベージコレクション(garbage collection) が行われたのにまだ解放されていないメモリがあることを指す。

PythonのGCは、基本的には生成したオブジェクトがどこにも参照されなくなった時にメモリを解放する。

そのため、メモリリークはプログラム内でオブジェクトがもう使われていないのにそのオブジェクトへの参照が残ってしまっている時に発生する。

shimakaze_softshimakaze_soft

参照が残ってしまう例

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))

実行してみると、profilesnamesの辞書型の変数にある全てのオブジェクトは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>}
shimakaze_softshimakaze_soft

メモリリークを試す

# 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'}
shimakaze_softshimakaze_soft

メモリを計測する

  • 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

https://qiita.com/hnw/items/3e01f60eb190f748539a

https://yusekita.com/detail/78d1f7f8-dd9e-4b04-b9d6-f26e2e0d2a0e/

shimakaze_softshimakaze_soft

コードをいじらずにメモリリークを検証する

アプリケーションの実際のコードにメモリプロファイラを入れて検証することが難しいケースもある。実際にtracemallocなどを随所に入れるのは難しい状況です。

コードをいじらずにメモリリークの検証方法として、上記でも紹介した Pyrasite というライブラリーがあル。

Pyrasiteを使うと動いてるPythonのプロセスにコードを注入することができるため、Pyrasite + Pythonの好きなメモリプロファイラの組み合わせで簡単にメモリリークの検証ができる。

メモリ検証のツールとして、今回はObjgraphという便利なライブラリーを使う。

$ pip install pyrasite objgraph

https://tech.curama.jp/entry/2020/12/02/174938

shimakaze_softshimakaze_soft

メモリ管理と循環参照メモリ管理

この廃物は、C言語などの変数を宣言をする言語は二種類の残骸が存在する。

  • 静的に配置された変数の残骸
  • malloc()等で動的に確保されたメモリ領域の残骸

しかしpythonのように宣言が無い言語の場合、numpy配列のようにプログラム内で静的に配置するメモリ領域もありますが、殆どが動的に配置されたメモリの残骸。

つまりはmalloc等で動的に確保されたメモリ領域です。

しかし、普通にpythonを使っていれば、メモリーの残骸に対する配慮などはしない。
ガベージコレクション(garbage collection) という「pythonが不要になったメモリの残骸を勝手に消去する」機能があるためです。

メモリーにも仮想メモリーにも制限があるため、ガベージコレクションの例外やメモリーの効率的使用が損なわれたら、目に見えない形でエラーとなり、動的配置の為際限もなかなか難しいという場面で発生するという問題をはらんでいる。

実はメモリー管理には二種類存在する。

  • 参照カウントを使った方式 (参照カウント)
  • 世代別ガベージコレクション(GC - Garbage Collection)
shimakaze_softshimakaze_soft

参照カウント方式

参照カウント(さんしょうカウント、英: 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された数字で出るため最初はとして登場する。

次にpython関数内で参照すると、パラメータとして記載時スタックが参照するため+2ずつ加算され結果的に+2されてとなる。

「オブジェクトの参照カウンタは0になります(誰にも参照されない状態)」

これは0になったのではなく、実際には領域がなくなった = 解放されたことにより「誰にも参照されなくなる」と解釈される。

参照されないということを、プログラム上では不定な状態となり、値が起動ごとに異なる状態(定まらない)となる。

さらに分かったことは、今は変数を対象にしていますがサンプルプログラム中も参照しているように、特定の対象に対してのみ参照カウンタが存在するのではなく、オブジェクトの基本構造体内に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からの参照が消えたため

https://qiita.com/KMim/items/1c9e162f28309842669e

https://daeudaeu.com/c-python-refcount/#Py_INCREFPy_DECREF

shimakaze_softshimakaze_soft

世代別ガベージコレクション

参照カウント方式はシンプルで分かりやすいアルゴリズムですが、よく知られた落とし穴がある。

循環参照という状態になったとき、参照カウントが常に1以上になる(のでGCが動かない)というもの。

mylist = []
mylist.append(mylist)

これはリスト型を始めとしたコンテナ型オブジェクトで起こる問題で、自分で自分自身を参照したり、2つのコンテナで相互に参照し合ったりすると発生する。

  • 自分で自分自身を参照した
  • 2つのコンテナで相互に参照し合う

この場合常に参照カウントは1以上なので、参照カウント方式のGCだけではメモリ解放ができない。

世代別ガベージコレクションではこのような場合にもGCを動かすための実装がされている。

https://kiyosucyberclub.web.fc2.com/DeZero/DeZero_02-17.html