2014年12月7日日曜日

PythonのGILについて簡単に調べてみました

PythonのGIL


Pythonで平行処理をやろうと思った時に、いろいろやり方があるのですがどの方法が一番効率的かどうか考えた時に、いつも
「GIL」様
が、お顔をお出しになって
「じゃぁ、どうやれって言うんだよ」
って感じになりますよね?

えっ?ならない...そうですか

世間ではあまりよく思われていない「GIL」だと思いますがでも私はPythonが好きなので頑張ってみました。 今回はサンプルコードとか作ってる時間がありませんでしたので、ほとんど説明だけですので、ごめんなさい。

GIL(Global Interpreter Lock)とは



全然違います、

Pythonインタープリターが内部で利用しているスレッドの同期プリミティブで、GILを取得できたスレッドがコードの実行を行うことができます、最初はスクリプトを実行したスレッドは1つ、mainスレッドだけなのでシングルスレッド動作させた場合はGILに対しての競合は起こらずスムーズに実行されます。

試してみる


以下のサンプルコードを実行した時間を計測してみる。シングルスレッド版は単純にcount関数を2回呼ぶだけの平凡なコード、2スレッド版はマルチコアなのでスレッドを2つ作成し平行に実行するように修正を加えたコード。
コードはCPUバンドなコードなので、I/Oの要素は含んでないことに注意。

def count(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':
    count(100000000)
    count(100000000)
from threading import Thread

def count(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':
    t1 = Thread(target=count, args=(100000000,))
    t1.start()
    t2 = Thread(target=count, args=(100000000,))
    t2.start()
    t1.join();
    t2.join();


* 実行結果

~/Code/lwl/Python/GIL $ time python sample1.py
18.181 secs
~/Code/lwl/Python/GIL $ time python sample2.py
30.090 secs
~/Code/lwl/Python/GIL $ 

Mac Book Air Core i5 1.6GHz 2Coreで実行しましたが、2スレッドの方が遅いではないですか。

どうなってるんだよ?!


私もGILを知るまで上のようなコードを書いていました、しっかり計測したことがなかったのですがこんな感じで遅くなっていたのでしょう、機械の無駄使いですね、すいませんでした。
同一プロセス内でスレッドを複数起動しても、GILの取得で内部競合を起こしてしまい遅くなってしまっている為です。

GILの取得方法


じゃぁ、GILがどういう風に取得されるか、これはそのスクリプトがCPUバンドなのかI/Oバンドなのかでちょっと違うようです。

* スクリプトがCPUバンドの場合

上のサンプルのようにCPU時間しか使わないようなプログラムの場合、一定期間にcheckが入ります、一定期間とはPythonインタープリターが内部で持っているticksという(単位時間ではない)カウンタを持っていてそのticksが100(適当...)を計測した際にGILの取得チェックが行われます。面白いのが、一定時間ではなく、一定のコードを実行したらticksが1カウントされるのです。さらに、その区間は割り込みさえ受け付けません。

poko-no-MacBook-Air:~ cuomo$ python
Python 2.7.8 (default, Oct  2 2014, 23:45:37)
[GCC 4.2.1 Compatible Apple LLVM 6.0 (clang-600.0.51)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> nums = xrange(1000000000)
>>> -1 in nums
^C^C^C^C^C^CTraceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyboardInterrupt
>>>

Ctrl+Cを押してもticks区間の場合帰ってきません。なるほど


* スクリプトがI/Oバンドの場合

ブロッキングI/Oを多く発行するようなスクリプトの場合、I/O要求を出す前に、スレッドがGILをリリースしI/Oから戻った時にGILを取得するような動きをするので、最終的に、I/Oとticksのせめぎ合いのような感じになりますね、長いI/Oばかりの処理が多いとするといくらスレッドをI/O用に生成したところで戻って来るまで待つことになりますし、I/Oが早いとしてもCPUのticksで性能が出ないので、どちらにしても悪いような気がします

* あともう一つGILとシグナルについて

厄介な話として、シグナルがペンディングされた場合、シグナルハンドラーを処理できるスレッドがmainスレッドに限られてしまうこと。もしその時に複数のスレッドが実行されていた場合、それぞれのスレッドが1tick実行するたたびにcheckが実行され、そこでGILの取得と解放が検査されるようになってしまうこと、最終的にmainスレッドに制御が移るまでシグナルはペンディングされたままになってしまいます。 ようするに、シグナルが届いたからといって、優先的にmainスレッドにコンテキストスイッチしないということです。

まとめると

  • Pythonインタープリターはスレッドのスケジューラーを持っていない
  • GILを取得したスレッドに実行権限がある
  • CPUバンドの場合、一定期間のticks(コードの塊)でスレッドの実行がスイッチされる
  •  I/Oバンドの場合、I/Oの発行でGILのリリース、I/Oからの戻りでGILの取得が実行される
  • シグナルはmainスレッドしか実行できない
ちょっと簡単すぎますけどこんな感じのPythonだと思いますが、そもそも、同一プロセス(同一インタープリターといったほうがいいかな)内でThreadを生成しても性能がでないという結果に落ち着くと思いますが、皆さんは如何でしょうか?(笑)

でもmultiprocessingがあるじゃないですか


なので世のPythonistaの方達がmultiprocessingを使いなさいと教えてくれました。早い話が、forkを使った方法でGILの競合を無くしてしまえという方法です。

from multiprocessing import Process

def count(n):
    while n > 0:
        n -= 1

if __name__ == '__main__':
    p1 = Process(target=count, args=(100000000,))
    p2 = Process(target=count, args=(100000000,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()


* 実行結果
~/Code/lwl/Python/GIL $ time python sample3.py
10.061 secs
~/Code/lwl/Python/GIL $ 

そこそこ性能が出てますね、1プロセス1スレッドにしてしまえばGILを取り放題ですね。 forkして、OSのスケジューラに任せた方が性能がでるってことです、スクリプトが遅い責任をカーネルのスケジューラのせいにできるのでなにかあった時に適当な言い訳を上司に言えますよ(笑)

複数プロセスにした場合、プロセス間でデータを共有するためにIPCを利用したりしなければならないので、ちょっとその辺りが面倒になりますが、ValueクラスとかArrayクラスなどを利用すれば共有メモリでデータの共有を簡単にしてくれるのでそんなに気にならないと感じます(自分は使ったことがありませんが...)。

デメリットとすればプロセステーブルを消費してしまうのと、プロセス生成のオーバーヘッド、メモリリソースの空間的な問題ですが、昨今のコンピュータなら気にすることないレベルですね。

いろいろ、グダグダ書いてしまいしたが、やり方を変えれば性能が出せることと、GILなんか怖くないよってはなし、しゃべりすぎました、間違ってたら指摘してください...

時間があったらもうちょっと突っ込んで調べます、お許しを...PHPやりたい



0 件のコメント:

コメントを投稿