Python の GIL 排除のために Software Transactional Memory が注目されている理由

あるいは、Pythonは参照カウント方式だからGILを排除できないという誤解に対する回答。

参照カウントってアトミックなインクリメント・デクリメントさえあればセマフォとか使わないでも並列化できるんで、パフォーマンスが滅茶苦茶落ちるということはない。参照カウントに対する修正が頻発するんで、同じオブジェクトを複数のスレッドが頻繁に操作したらコア同士で1次キャッシュの取り合いになって性能上がらないけど、現状よりはだいぶマシだ。

Python で GIL の除去が難しいのは、PythonJava よりも高級なアトミック性をもつ言語だからだ。例えば、

# d1, d2 は両方ローカル変数にある辞書.
d1.update(d2)

これは、Python VMレベルでは、 d1 の参照、 update メソッドの参照、 d2 の参照、メソッドの呼び出し、という処理になるんだけど、このうち dict.update というメソッドは d1, d2 両方に対してアトミック性を保証していて、なおかつ次のようなコードが別スレッドで動作していてもデッドロックしないことが保証される。

d2.update(d1)

これは、辞書の update メソッドがGILをつかんだまま実行されるからで、 d1.update(d2) の update メソッド実行中にスレッドが切り替わって d2.update(d1) が実行されないからだ。

さて、 GIL を外すことを考えよう。すぐに思いつくのは、オブジェクトに対する基本的な操作がアトミック性を保証するように、オブジェクトのメソッドを Java でいう synchronized にしていくことだ。すなわち、オブジェクトにセマフォをくっつけて、操作するときにはそのセマフォをロックする。シングルスレッドでの性能低下やメモリ効率の悪化はあるものの、これで次のようなコードは正しく実行されることだろう。

#あるスレッド
d1['foo'] = 'bar'

#別スレッド
d1['spam'] = 'egg'

上記の例では、 dict.__setitem__ が d1 のロックを取得することで、 d1 に対する操作がシリアライズされて実行される。しかし、次の場合はどうだろう?

# あるスレッド
d1.update(d2)

# 別のスレッド
d2.update(d1)

update メソッドが、自身のロックを先に取得して、次に引数のロックを取得した場合、あるスレッドは d1 のロックを取得したあとに d2 のロックを取得しようとし、別のスレッドは d2 のロックを取得したあとに d1 のロックを取得しようとするので、デッドロックし得る。

この問題は、例えば引数のチェックを行った後に、自身のアドレスと引数のアドレスの若い方から先にロックするというルールで回避できるかもしれない。実際、JythonJavaのロック機構を使ってGILから解放されている。(それでも、JythonはいくらかCPythonよりも整合性の条件が緩い場面がある。サンプルは後述するPyPyのBlog記事を参照)しかし、CPythonを実装するときに全ての箇所でデッドロックとパフォーマンスに注意するのは非常に難しい作業になる。

そこで今注目されているのが、STM (Software Transactional Memory) だ。(PyPy Status Blog: We need Software Transactional Memory) 全てのバイトコードを実行する前にトランザクションを開始し、全ての変更を記録し、最後にコミットする。失敗したらロールバックしてバイトコードの実行をやり直す。上手くいけば、小粒度のロックよりもメンテナンスが容易なまま、許容範囲内のパフォーマンス低下でGILを回避できるかもしれない。CPythonで実装するには結構なAPIの変更が掛かる大仕事だけど、 PyPy なら C言語のコードを生成するときに On/Off を切り替えることができるので、STMの実用性を検証するのにはうってつけだ。


最後になるが、CPython で並列処理をしたい場合、標準ライブラリの multiprocessing などでプロセスを分ける方法と、C言語などで書かれた処理を実行している間GILを開放するという方法があり、これは大抵の用途にフィットする。
もし並列計算したいプログラムの大部分が単純なC言語の関数を実行しているのであればGILを開放すればいいのであり、逆に Pythonバイトコードの実行に多くの時間を割いているプログラムを高速化するのが目的なら Cython などを使えば並列化しなくてもすぐに30倍くらいは速くなるから並列化と同じ効果が望める。そしてプロセス分離はタスクの粒度が細かすぎない場合は大抵上手くいく。

CPythonが上手く扱えないのは細かい粒度の並列実行だけど、それが必要になるケースは大抵最初からパフォーマンスが求められることが明らかであり、最初からC++Javaなどの言語を使ってPythonはそれに対する「糊」として使うべきだろう。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。