プログラミング
Pythonにおけるメモリ管理への第一歩
First Steps into Memory Management in Python (tech.daniellbastos.com.br)
要約
この記事では、Pythonのメモリ管理の仕組みについて、特にメモリの割り当てと解放に焦点を当てて解説しています。Pythonはオブジェクトのサイズに応じてpymallocまたはmallocを使用し、メモリの割り当てと解放には参照カウントとガベージコレクタが用いられます。メモリが解放されてもOSにすぐに返還されない場合があることや、参照の寿命を管理することの重要性が示唆されています。
全文翻訳
長らく、Pythonでメモリ管理について実装中に心配することはありませんでした。仕事でもまだ問題は起きていませんが、それを理解することに非常に興味を持つようになりました。
出発点となったのは、「Strong ref, weakref and garbage collector walk into a bar」というトークでした。参照カウント、gen0、gen1、gen2:それらすべては理解すると理にかなっていましたが、下着を履く前にズボンを履いているような感覚でした。そこで一歩戻り、Pythonがどのようにメモリを解放するかを学ぶ前に、どのように割り当てるかを理解する必要がありました。
終わりのないように思える穴に落ちていきました。まだその穴の中にいます。しかし、もう少し深く潜る前に、自分自身のために物事を明確にするために、息を吸うために頭を出すことができました。
免責事項:
ここでは、CPython 3.12を使用して、このトピックに関する私の学習について話します。これは紙に書き出した思考の断片なので、100%正確ではないかもしれません。
Pythonでは、オブジェクトのサイズに基づいて、メモリを割り当てる方法が2つあります。小さいオブジェクト(≤512バイト)にはpymallocを、大きいオブジェクトにはmalloc(PyMem_RawMalloc)を使用します。
小さいオブジェクト(pymalloc)から始めましょう:プロセスが新しいオブジェクトのためにスペースを必要とし、利用可能なスペースがない場合、pymallocはOSにアリーナと呼ばれる大きなメモリチャンクを要求します。各アリーナは64個のプールに分割され、各プールは同じサイズのブロックに分割されます。
アリーナ、プール、ブロックについてさらに詳しく知りたい場合は、このブログ記事をご覧ください。
オブジェクトは、プール内の空きブロックに格納されます。メモリは、そのページが初めて書き込まれたときにのみ、OSの観点から現実のものとなります。pymallocは、既存のアリーナのいずれにもそのサイズのオブジェクト用のスペースがない場合にのみ、OSに新しいアリーナを要求します。
アリーナは、そのすべてのプールが空になった場合にのみOSに返還されます。したがって、単一のブロックが解放されても、OSには何も変わりません。pymallocはそのスペースを保持し、次の同じサイズのオブジェクトのために再利用します。
これは高速です。なぜなら、ほとんどの割り当ては、OSに何も要求することなく、既存のプールから空きブロックを取得するだけだからです。トレードオフとして、Pythonプロセスはめったに縮小しません。単一のライブオブジェクトがあれば、そのアリーナ全体がOSに返還されるのを防ぐのに十分です。
より大きいオブジェクト(>512バイト)は、システムのCライブラリに付属するメモリ割り当て器であるmallocを使用します。ここではアリーナやプールはありません。Pythonはバイト数を要求し、mallocが残りを処理します。オブジェクトが解放されると、mallocは通常、再利用のためにメモリを保持します。非常に大きなブロックのみが直接OSに返還されます。
メモリ割り当てに関するこの簡単な概要は、ある点を示すのに十分です。メモリが割り当てられると、解放してもOSが見るメモリが必ずしも減るとは限りません。
割り当てと同様に、解放にもメモリを解放する方法が2つあります。参照カウントとガベージコレクタです。
参照カウントは理解するのが最も簡単な方法です。Pythonではすべてがオブジェクトです。int、文字列、リスト、関数など。
変数はオブジェクトを指す名前です。したがって、「x = [1, 2, 3]」と書くと、実際には「名前xは今このリストオブジェクトを参照しています」と言っていることになり、リストはxによって保持される参照カウント1で始まります。名前がそれを指さなくなると(xに別のものを代入する、del xを使用する、またはxが存在する関数が返される)、カウントは1ずつ減少します。カウントがゼロに達すると、オブジェクトは即座に破棄され、そのブロックはプール内の利用可能なブロックに戻されます。
def foo():
x = [1, 2, 3] # list refcount = 1 (x)
y = x # list refcount = 2 (x and y)
y = "foo" # list refcount = 1 (x)
return y
foo()
ガベージコレクタは、参照カウントでは解決できない問題、つまり循環参照を解決します。2つのオブジェクトがお互いを指している場合、それらのカウントは決してゼロになりません。時々、ガベージコレクタはコンテナオブジェクト(リスト、辞書、クラスインスタンス)を調べ、プログラムの残りの部分から到達できなくなったオブジェクトを見つけます。それらはその実行中に解放されます。新しいオブジェクトはgen0で開始され、ほとんどのオブジェクトはすぐに不要になるため、頻繁にチェックされます。コレクションを生き残ったオブジェクトは、より古い世代に移動し、それほど頻繁にチェックされません。
def foo():
x = [1, 2, 3] # list A refcount = 1 (x)
y = [x] # list B refcount = 1 (y), list A = 2 (x, B)
x.append(y) # list B = 2 (y, A): now A -> B -> A, a cycle
foo() # x and y are gone: A = 1, B = 1 # the next gen0 collection finds A and B unreachable and frees both
これは私が深く掘り下げたほどではありませんが、洞察を得るには十分です。割り当て器がOSに何を返すかを決定するため、解放されたメモリを完全に制御することはできませんが、参照が存続する期間を制御できます。大きなオブジェクトを指すローカル変数は、関数が返されるまでそれを生かし続けます。関数がそれを使用した後にまだ作業が残っている場合、delするか、次のように直接次の呼び出しに渡すことができます。summarize(load_file())。その後、その呼び出しが返されるとすぐに解放されます。
Pythonの内部を学ぶことは、楽しく、多くのことを学びました。これらのことの多くは言葉にするのが難しいです。なぜなら、メモリ管理を理解するための扉を開くと、OSがメモリ割り当てをどのように処理するか、Pythonがスレッドセーフをどのように実現したか、インタープリタがコードをどのように実行するかなど、別の扉に直面するからです。それは楽しい冒険であり、もっと早く始めなかったことを残念に思っています。