4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

こんにちは、京セラコミュニケーションシステムの関川(@kccs_kohei-sekikawa)です。

最近メモリリークに関するトラブルシューティングをし、改めてメモリ管理に関する知識やリークの特定方法についてまとめようと思い本記事を執筆することにしました。
本記事では、Pythonにおけるメモリ管理を理解するための知識とメモリリークの原因箇所を特定するための手法について基礎的な部分に触れています。

この記事の対象者

  • Pythonのメモリ管理について興味のある方
  • Pythonのメモリリークに関する基礎的な調査手法を知りたい方

開発者がメモリ管理を直接行う必要がない ≠ メモリリークが発生しない

Pythonは「開発者がメモリ管理を直接行う必要がない言語」であるということをよく耳にすると思います。
ただし、これは「メモリリークが発生しない言語」であるという意味とイコールではありません。
ガベージコレクションと呼ばれる機能によって、あくまでも「メモリリークが発生しにくい言語」であるということに注意してください。

メモリリークが発生する原因について理解していくために、最低限理解しておきたいPythonのメモリ管理に関する知識を以下にまとめていきます。

ヒープ領域

Pythonのメモリ領域は、プログラムの実行に必要なデータを格納するために、大きく分けてスタック (Stack) とヒープ (Heap) の2つの領域に分けられます。
メモリリークを理解する上では、とくに後者のヒープ領域に関する知識が重要となるため、要点をまとめていきます。

  1. ヒープ領域とは
    プログラムの実行中に動的にメモリを割り当て、解放するための領域です。
    プログラムが新しいオブジェクトを作成したり、データ構造を拡張したりする際に、ヒープ領域から必要なメモリが割り当てられます。

  2. ヒープ領域の管理
    ヒープ領域は、Pythonの持つ機能によって自動的に管理されます。
    この機能(ガベージコレクション)については、記事の後ろでもう少し詳しく触れたいと思います。

  3. ヒープ領域とメモリリークの関係
    メモリリークが発生するメモリ領域はヒープ領域になります。
    解放できないメモリがヒープ領域に蓄積されていくことで、ヒープ領域の容量が増加し、メモリリークに繋がります。

  4. メモリリークの主な原因
    メモリリークは、解放できないメモリがヒープ領域に蓄積されていく現象です。

    主な原因は以下のとおりです。

    • グローバル変数の不適切な使用: グローバル変数はプログラムのライフサイクル全体を通じて存続するため、不適切な実装により参照が残り続ける原因となってしまいます。
      グローバル変数は、プログラム全体で共有する必要があるデータにのみ使用し、可能な限りはローカル変数を使用するように注意しましょう。
    • オブジェクトの解放忘れ: プログラムが不要になったオブジェクトへの参照を保持し続け、それらのオブジェクトがメモリ内に残り続ける状態となってしまいます。
      非常に初歩的に思えますが、意外とやらかしがちです。とくに、DBコネクタやファイル関連のオブジェクトを使用する際には解放漏れがないか要注意です。
    • 循環参照: 複数のオブジェクトが互いに参照し合う状態です。オブジェクト同士がお互いに参照し合うと参照を解放できず、メモリリークが発生します。
      グローバル変数やオブジェクトの解放漏れが原因ではない場合は、だいたいここに辿り着くことになります。そして、循環参照については非常に奥が深いのでまた別の記事で取り上げたいと思います。
    • 外部ライブラリのメモリリーク: 使用している外部ライブラリにメモリリークの原因が存在する場合があります。

    上記原因のうち、上の3つについては実装面での不備に起因することがほとんどであるため、適切に実装を見直すことで改善が期待できます。
    4つめの「外部ライブラリのメモリリーク」については、ライブラリ側の不具合となるため、実装ではどうにもならない可能性が高いです。対処方法としては、不具合が修正されたバージョンへの切替えなどを検討することになります。

  5. ヒープ領域の監視
    メモリリークを特定するためには、ヒープ領域のメモリ使用状況を監視することが重要です。
    メモリ分析ツールを使用することで、ヒープ領域のメモリ使用状況を可視化し、メモリリークが発生しているかどうかを確認できます。
    分析ツールによるリークの特定手法ついては、記事の後ろでもう少し詳しく触れたいと思います。

ガベージコレクション(Garbage Collection)

本記事では細かい仕様部分にまでは触れませんが、ガベージコレクションに関する要点をまとめていきます。

ガベージコレクションとは、プログラムが不要になったメモリ領域を自動的に解放してくれる機能です。
Pythonのプログラムは実行のために必要なメモリを自動的に確保してくれますが、その後片付けをしてくれる機能がガベージコレクションだと考えるとイメージしやすいでしょう。

Pythonのガベージコレクションには、2つの仕組みがあります。

①参照カウント

これは非常にシンプルなアルゴリズムで動く仕組みです。
プログラム中でオブジェクト(変数、関数、クラスetc.)への参照がなくなった(=参照カウントが0になった)場合に、そのオブジェクトは不要であると判断され、オブジェクトに割り当てられているメモリを解放します。
参照カウントとは「そのオブジェクトが参照されている数」を記録した数字です。参照カウントはオブジェクトごとに用意されています。

参照カウントが増える代表的な処理は以下の3つです。

  1. 変数への代入
    新しい変数にオブジェクトを代入すると、そのオブジェクトの参照カウントが1増えます。

    a = 10  # a の参照カウントが 1 になる
    b = a   # a の参照カウントが 2 になる
    
  2. オブジェクトの要素への参照
    リスト、タプル、辞書などのオブジェクトの要素を参照すると、その要素の参照カウントが1増えます。

    my_list = [1, 2, 3]
    a = my_list[0]  # my_list[0] の参照カウントが 1 増える
    
  3. 関数への引数渡し
    関数にオブジェクトを引数として渡すと、そのオブジェクトの参照カウントが1増えます。

    def my_function(x):
        print(x)
    
    a = 10
    my_function(a)  # a の参照カウントが 1 増える
    

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

世代別ガベージコレクションは、オブジェクトの生存期間に基づいて、オブジェクトを世代に分類するアルゴリズムで動く仕組みです。

  • 世代0: 最近作成されたオブジェクトが属します。この世代のオブジェクトは、頻繁に解放されます。
  • 世代1: 世代0から生き残ったオブジェクトが属します。この世代のオブジェクトは、世代0よりも生存期間が長いです。
  • 世代2: 世代1から生き残ったオブジェクトが属します。この世代のオブジェクトは、もっとも生存期間が長いオブジェクトです。

各世代を定期的にスキャンし、参照されていないオブジェクトを解放します。世代が上がるにつれて、ガベージコレクションの頻度は低くなります。
循環参照が発生しているオブジェクトは世代が上がっていくことにより、スキャン頻度が下がります。そのため、循環参照しているオブジェクトであっても、頻繁にチェックされることはなく、メモリリークが発生する可能性が低くなります。
ただし、上記はあくまでもメモリ使用の緩和に過ぎないため、循環参照によるメモリリークを完全に防ぐことはできません。
循環参照を完全に防ぐためには、不要になったオブジェクトの参照を明示的に解放するような実装の修正が必要となります。

分析ツールによるリークの特定手法

ここからは、メモリリークの傾向が見られるプログラムに対し、原因箇所を特定するための手法について触れていきます。
分析ツールにはさまざまなものがありますが、いくつか使用してみた中で初心者に一番適していると感じたmemory_profiler1というツールをご紹介します。

memory_profilerでは、Pythonスクリプトの実行中のメモリ使用量をサンプリングし、関数ごとのメモリ使用量を測定できます。
測定できる単位は関数となっており、オブジェクトレベルの詳細な情報は測定不可である点はあらかじめ留意してください。
「特定の関数内でのメモリ使用量を把握したい場合」や「メモリリーク箇所の特定をしたい場合」に役立つツールです。
また、ライブラリをインストールし、計測したい関数の定義位置にデコレータ(@profile)を追加するだけで分析できるため、使用難度も非常に低い点が魅力です。

では、ここからは実際の使い方について説明します。

本記事での開発環境について
OS: Ubuntu(WSL 1)
Python: 3.9.6
パッケージ管理: Poetry

事前準備

memory_profilerをインストールします。

poetry add memory_profiler

pipでインストールする場合は以下の通りです。

# memory_profilerをインストール
pip install -U memory_profiler
  
# psutil(依存モジュール)をインストール
pip install psutil

サンプルスクリプトでの動作確認

メモリ割り当ての動作を確認できるサンプルとして以下のようなスクリプトを用意します。

def memory_profile_sample():
    # 大きめのリスト
    a = [i for i in range(100000)]
    # 大きめのリストを削除する
    del a

memory_profile_sample()

aという変数に10万個の整数を要素に持つリストが代入されることで、メモリが消費されます。
そして、del aでリストaへの参照が削除されることにより、ガベージコレクションは、aが参照していたメモリ領域を解放できるようになります。

では、memory_profilerで実際のメモリ消費状況を監視してみましょう。
以下のようにコードに追加を加えます。

# memory_profilerモジュールからprofileデコレータをインポート
from memory_profiler import profile

# @profile デコレータを追加
@profile(stream=open('memory_profile.log', 'w'))
def memory_profile_sample():
    # 大きめのリスト
    a = [i for i in range(100000)]
    # 大きめのリストを削除する
    del a

memory_profile_sample()

今回は、@profileデコレータでstream=open('memory_profile.log', 'w')と指定し、計測結果をmemory_profile.logファイルに書き込むように設定しています。
stream引数を指定しない場合、計測結果は標準エラー出力(stderr)に出力されます。
ファイルに書き込むほうが後々の分析などに便利であるためオススメです。

この状態でスクリプトを実行するとmemory_profile.logに以下のような情報が出力されます。

Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     7     14.9 MiB     14.9 MiB           1   @profile(stream=open('memory_profile.log', 'w'))
     8                                         def memory_profile_sample():
     9                                             # 大きめのリスト
    10     18.8 MiB      3.9 MiB      100003       a = [i for i in range(100000)]
    11                                             # 大きめのリストを削除する
    12     15.3 MiB     -3.5 MiB           1       del a

ヘッダーの内容については以下の通りです。

  • Line # : プロファイル対象のコードの行番号
  • Mem usage : その行の実行後に使用されたメモリ量(バイト単位)
  • Increment : 前の行からのメモリ使用量の増加量(バイト単位)
  • Occurrences : その行が実行された回数
  • Line Contents : プロファイル対象のコードの行の内容

ログファイルの内容から、10行目でメモリが増加し、12行目で減少していることが分かります。

続いて、先ほどのスクリプトを以下のように修正し、メモリリークが発生する実装にした上でメモリを監視してみます。

from memory_profiler import profile

leaked_list = []  # グローバル変数としてリストを定義

@profile(stream=open('memory_profile.log', 'w'))
def memory_leak_sample():
    # 大きめのリスト
    a = [i for i in range(100000)]

    # グローバル変数にリストへの参照を追加
    leaked_list.append(a)
    
    # 大きめのリストを削除する
    del a

if __name__ == "__main__":
    for _ in range(3):  # 複数回実行してリークを顕著にする
        memory_leak_sample()

上記の実装では、グローバル変数leaked_listがメモリリークの原因となっています。
memory_leak_sample関数内で作成されるリストaは、leaked_list.append(a)によってグローバル変数leaked_listに追加されます。del aa変数への参照はなくなりますが、leaked_listaへの参照を保持し続けているため、aのために確保されたメモリ領域は解放されません。
関数が繰り返し呼び出されるたびに、leaked_listに新たなリストが追加され続け、メモリ使用量が増加していくため、メモリリークが発生します。

スクリプトを実行するとmemory_profile.logに以下のような情報が出力されます。

1回目の実行:
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     5     14.9 MiB     14.9 MiB           1   @profile(stream=open('memory_profile.log', 'w'))
     6                                         def memory_leak_sample():
     7                                             # 大きめのリスト
     8     18.8 MiB      3.9 MiB      100003       a = [i for i in range(100000)]
     9                                         
    10                                             # グローバル変数にリストへの参照を追加
    11     18.8 MiB      0.0 MiB           1       leaked_list.append(a)
    12                                             
    13                                             # 大きめのリストを削除する
    14     18.8 MiB      0.0 MiB           1       del a

2回目の実行:
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     5     18.8 MiB     18.8 MiB           1   @profile(stream=open('memory_profile.log', 'w'))
     6                                         def memory_leak_sample():
     7                                             # 大きめのリスト
     8     22.7 MiB      3.9 MiB      100003       a = [i for i in range(100000)]
     9                                         
    10                                             # グローバル変数にリストへの参照を追加
    11     22.7 MiB      0.0 MiB           1       leaked_list.append(a)
    12                                             
    13                                             # 大きめのリストを削除する
    14     22.7 MiB      0.0 MiB           1       del a

3回目の実行:
Line #    Mem usage    Increment  Occurrences   Line Contents
=============================================================
     5     22.7 MiB     22.7 MiB           1   @profile(stream=open('memory_profile.log', 'w'))
     6                                         def memory_leak_sample():
     7                                             # 大きめのリスト
     8     26.5 MiB      3.9 MiB      100003       a = [i for i in range(100000)]
     9                                         
    10                                             # グローバル変数にリストへの参照を追加
    11     26.5 MiB      0.0 MiB           1       leaked_list.append(a)
    12                                             
    13                                             # 大きめのリストを削除する
    14     26.5 MiB      0.0 MiB           1       del a

14行目のdel aa変数の参照を削除していますが、メモリが減少していないことが分かります。
また、2回目以降の関数実行で8行目のリスト作成部分で毎回メモリが増加するため、メモリリークが発生していることが分かります。

このように、memory_profilerを使用することで、メモリリーク箇所を特定することが可能です。

まとめ

Pythonのメモリ管理を理解していくにあたり必要と感じた基本的な知識とリークの特定手法についてまとめました。
今後、メモリ管理に関するさらに深い部分について掘り下げた記事なども投稿したいと思います。

おしらせ

弊社X(旧:Twitter)では、Qiita投稿に関する情報や各種セミナー情報をお届けしております。情報収集や学びの場を求める皆さん!ぜひフォローしていただき、最新情報を手に入れてください:grinning:

  1. memory-profiler 0.61.0

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?