このBlogは移転しました。今後は aish.dev を御覧ください。

Pythonの粗大ゴミ

なんかgcネタが続いてしまうが、先日のPython Hack-a-thon で発表した中で、「ジェネレータオブジェクトが解放されない場合がある」というのは、あまり知られていないようだ。Python公式ドキュメントを確認してみると、どうやらこちらにも書かれていない。知らないとハマってしまう場合もあるので、もうちょっと詳しく解説しておこう。

ガベージコレクションで解放されないオブジェクト

まず、ちょっと復習しておこう。Pythonガベージコレクション機構では、__del__() メソッドを持ったオブジェクトで循環参照を作ってしまうと、そのオブジェクトは自動的には解放されなくなってしまう。

例えば、次のように __del__() メソッドを持つクラスを定義する。

class UnCollectable:
    "__del__()メソッド付きクラス"
    def __del__(self):
        print "Deleted"

普通なら、UnCollectableオブジェクトは、そのオブジェクトへの参照が全てなくなった時点で削除される。

>>> # UnCollectableオブジェクトを含むリストを作成
... listobj = [UnCollectable()]
>>> # リストオブジェクトへの参照を削除
... del listobj
Deleted

しかし、いったん循環参照ができてしまうと

>>> # リストオブジェクトとUnCollectableで循環参照を作成
... listobj = [UnCollectable()]
>>> listobj[0].listobj = listobj
>>>
>>> # リストオブジェクトへの参照を削除しリストとUnCollectableを到達不能に
... del listobj
>>>
>>> # ガベージコレクタを起動
... gc.collect()
0

このオブジェクトは自動的には削除されなくなってしまう。__del__() メソッドを持つオブジェクトが循環参照に含まれている場合、オブジェクトを削除する順番によっては__del__()メソッド実行時にエラーが発生してしまう可能性があるためだ。

そのようなオブジェクトは削除するのではなく、gcモジュールのgarbageというリストに格納するようになっている。上の例では、

>>> print(gc.garbage)
[<__main__.UnCollectable instance at 0x02C44530>]

となっているはずだ。

こうなってしまったオブジェクトを解放するには、明示的に循環参照を解消する必要がある。

>>> del gc.garbage[0].listobj  # 循環参照を解消
>>> del gc.garbage[:] # gc.garbageをクリア
Deleted

もう一つの粗大ゴミ

循環参照でオブジェクトが解放されないケースにはもう一つ、tryブロックを持つジェネレータがある。

def uncollectable(arg):
    try:
        yield 1
        yield 2
        yield 3
    finally:
        print "finally"

普通なら、ジェネレータが終了したり、ジェネレータが不要になったりしたらfinallyブロックが実行され、ジェネレータオブジェクトも解放される。

>>> genobj = uncollectable(0)
>>> genobj.next()
1
>>> genobj.next()
2
>>> del genobj
finally

しかし、このジェネレータが循環参照の一部になっていたらどうだろう?

>>> listobj = []
>>> genobj = uncollectable(listobj)
>>>
>>> # 循環参照を作成
... listobj.append(genobj)
>>>
>>> genobj.next()
1
>>> # 参照を削除
... del listobj
>>> del genobj
>>>
>>> import gc
>>> gc.collect()
0
>>> gc.garbage
[<generator object uncollectable at 0x02C1B300>]

このジェネレータオブジェクトは削除されず、gc.garbageに格納されてしまう。先ほどの__del__()の場合と同様に、finallyブロックで実行される処理でエラーが発生してしまう場合があるからだ。

こうなってしまうと、このジェネレータを削除するのは少々面倒だ。こんな感じで削除するしかない

>>> del gc.garbage[0].gi_frame.f_locals['arg'][:]
>>> del gc.garbage[:]
finally

CPythonの実装詳細に依存した、あまり書きたくない処理である。

通常、ジェネレータが循環参照に参加することはあまりないので問題になることは少ないのであるが、いったん問題になってしまうとなかなかやっかいだ。

このように解放されなくなってしまう現象は、try-finally ブロックだけではなく、try-exceptブロックやwithブロックでも発生する。

対策としては、try/withブロックを持つジェネレータでは循環参照を作らないように気を付けるしかない。このようなジェネレータでは、参照するデータはできるだけ絞る、不要なブロックは使わない、ということは覚えておいた方が良いだろう。