Python Global Interpreter Lock(GIL)とは何ですか?

Python Global Interpreter Lock(GIL)とは何ですか?

Python Global Interpreter Lockまたはhttps://wiki.python.org/moin/GlobalInterpreterLock[GIL]は、簡単に言えば、1つのスレッドのみがPythonインタープリターの制御を保持できるようにするミューテックス(またはロック)です。

つまり、1つのスレッドのみがいつでも実行状態になります。 GILの影響は、シングルスレッドプログラムを実行する開発者には見えませんが、CPUにバインドされたマルチスレッドコードのパフォーマンスのボトルネックになる可能性があります。

GILでは、複数のCPUコアを備えたマルチスレッドアーキテクチャでも、一度に1つのスレッドしか実行できないため、GILはPythonの「悪名高い」機能としての評価を得ています。

この記事では、GILがPythonプログラムのパフォーマンスにどのように影響し、コードに与える影響を軽減する方法を学習します。

GILはPythonでどのような問題を解決しましたか?

Pythonは、メモリ管理に参照カウントを使用します。 Pythonで作成されたオブジェクトには、オブジェクトを指す参照の数を追跡する参照カウント変数があります。 このカウントがゼロに達すると、オブジェクトが占有していたメモリが解放されます。

簡単なコード例を見て、参照カウントの仕組みを説明しましょう。

>>>

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

上記の例では、空のリストオブジェクト + [] +`の参照カウントは3でした。 リストオブジェクトは `+ a +、 `+ b `によって参照され、引数は ` sys.getrefcount()+`に渡されました。

GILに戻る:

問題は、この参照カウント変数が、2つのスレッドが同時に値を増減する競合状態からの保護を必要とすることでした。 これが発生すると、メモリリークが発生し、メモリが解放されないか、さらに悪いことに、そのオブジェクトへの参照がまだ存在している間にメモリが誤って解放される可能性があります。 これにより、Pythonプログラムでクラッシュやその他の「奇妙な」バグが発生する可能性があります。

この参照カウント変数は、スレッド間で共有されるすべてのデータ構造に_locks_を追加して、一貫性のない変更が行われないようにすることで安全に保つことができます。

ただし、各オブジェクトまたはオブジェクトのグループにロックを追加すると、複数のロックが存在し、別の問題であるデッドロックが発生する可能性があります(デッドロックは複数のロックがある場合にのみ発生します)。 もう1つの副作用は、ロックの取得と解放の繰り返しによってパフォーマンスが低下することです。

GILは、インタープリター自体に対する単一のロックであり、Pythonバイトコードを実行するにはインタープリターロックを取得する必要があるというルールを追加します。 これにより、デッドロック(ロックが1つしかないため)が防止され、パフォーマンスのオーバーヘッドが大きくなりません。 ただし、CPUにバインドされたPythonプログラムは事実上シングルスレッドになります。

GILは、Rubyなどの他の言語のインタープリターによって使用されますが、この問題の唯一の解決策ではありません。 一部の言語では、ガベージコレクションなどの参照カウント以外のアプローチを使用することで、スレッドセーフなメモリ管理のためのGILの要件を回避しています。

一方、これは、これらの言語が、JITコンパイラーなどの他のパフォーマンスを向上させる機能を追加することにより、GILのシングルスレッドパフォーマンスの損失を補う必要があることを意味します。

なぜGILがソリューションとして選ばれたのですか?

それでは、なぜPythonで使用されているように見えるアプローチが妨害されているのでしょうか? Pythonの開発者による悪い決定でしたか?

さて、https://youtu.be/KVKufdTphKs?t = 12m11s [Larry Hastingsの言葉]では、GILの設計上の決定がPythonを今日と同じくらい人気のあるものの1つにしています。

Pythonは、オペレーティングシステムにスレッドの概念がなかった時代から存在していました。 Pythonは、開発を迅速化し、より多くの開発者がPythonの使用を開始するために、使いやすいように設計されました。

Pythonで機能が必要な既存のCライブラリ用に、多くの拡張機能が作成されていました。 一貫性のない変更を防ぐために、これらのC拡張機能には、GILが提供するスレッドセーフなメモリ管理が必要でした。

GILは実装が簡単で、Pythonに簡単に追加できました。 管理する必要があるロックは1つだけなので、シングルスレッドプログラムのパフォーマンスが向上します。

スレッドセーフではないCライブラリの統合が容易になりました。 そして、これらのC拡張機能は、Pythonがさまざまなコミュニティですぐに採用される理由の1つになりました。

ご覧のとおり、GILはCPython開発者がPythonの初期の段階で直面した困難な問題に対する実用的な解決策でした。

マルチスレッドPythonプログラムへの影響

典型的なPythonプログラム(またはその点でコンピュータープログラム)を見ると、パフォーマンスがCPUに依存しているものとI/Oに依存しているものに違いがあります。

CPUにバインドされたプログラムは、CPUを限界に押し上げているプログラムです。 これには、行列の乗算、検索、画像処理などの数学的計算を行うプログラムが含まれます。

I/Oにバインドされたプログラムは、ユーザー、ファイル、データベース、ネットワークなどからの入力/出力を待つことに時間を費やすプログラムです。 I/Oにバインドされたプログラムは、入力/出力の準備ができる前にソースが独自の処理を行う必要があるため、ソースから必要なものを取得するまでかなりの時間待機する必要がある場合があります。入力プロンプトまたは独自のプロセスで実行されているデータベースクエリに入力する内容を考えているユーザー。

カウントダウンを実行する単純なCPUバウンドプログラムを見てみましょう。

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

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

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

4つのコアを持つシステムでこのコードを実行すると、次の出力が得られました。

$ python single_threaded.py
Time taken in seconds - 6.20024037361145

次に、2つのスレッドを並行して使用して同じカウントダウンを行うようにコードを少し変更しました。

# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

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

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

そして、もう一度実行したとき:

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

ご覧のとおり、両方のバージョンが完了するまでにほぼ同じ時間がかかります。 マルチスレッドバージョンでは、GILにより、CPUにバインドされたスレッドがパラレルで実行されなくなりました。

GILは、スレッドがI/Oを待機している間、スレッド間でロックが共有されるため、I/Oにバインドされたマルチスレッドプログラムのパフォーマンスに大きな影響を与えません。

しかし、スレッドが完全にCPUにバインドされているプログラム、たとえば、スレッドを使用して部分的にイメージを処理するプログラムは、ロックのためにシングルスレッドになるだけでなく、上記の例のように実行時間が長くなります、完全にシングルスレッドであるように記述されたシナリオと比較して。

この増加は、ロックによって追加および取得されるオーバーヘッドの結果です。

GILがまだ削除されていないのはなぜですか?

Pythonの開発者はこれに関して多くの苦情を受け取りますが、Pythonと同じくらい人気のある言語は、後方互換性の問題を引き起こさずにGILの削除ほど重要な変更をもたらすことができません。

GILは明らかに削除でき、これは開発者と研究者によって過去に何度も行われましたが、これらの試みはすべて、GILが提供するソリューションに大きく依存する既存のC拡張機能を破壊しました。

もちろん、GILが解決する問題には他の解決策もありますが、それらの一部はシングルスレッドおよびマルチスレッドのI/Oバインドプログラムのパフォーマンスを低下させ、一部は非常に困難です。 結局のところ、新しいバージョンがリリースされた後、既存のPythonプログラムの実行速度を遅くしたくないでしょうか?

Pythonの作成者およびBDFLであるGuido van Rossumは、2007年9月に彼の記事https://www.artima.com/weblogs/viewpost.jsp?thread=214235でコミュニティに回答しました。 GILを削除します」]:

_ 「Py3kへの一連のパッチを歓迎します。_if if_シングルスレッドプログラム(およびマルチスレッドですがI/Oにバインドされたプログラム)のパフォーマンスは低下しません __

そして、この条件はそれ以降に行われたどの試みによっても満たされていません。

Python 3で削除されなかったのはなぜですか?

Python 3には多くの機能をゼロから開始するプロセスがあり、その過程で既存のC拡張機能の一部が壊れたため、Python 3で動作するように変更して更新する必要がありました。 これが、Python 3の初期バージョンでコミュニティによる採用が遅くなった理由です。

しかし、なぜGILが一緒に削除されなかったのですか?

GILを削除すると、シングルスレッドパフォーマンスのPython 2に比べてPython 3が遅くなり、その結果がどうなるか想像できます。 GILのシングルスレッドパフォーマンスの利点について議論することはできません。 その結果、Python 3にはまだGILがあります。

しかし、Python 3は既存のGILに大きな改善をもたらしました。

「CPUバインドのみ」および「I/Oバインドのみ」のマルチスレッドプログラムに対するGILの影響について説明しましたが、一部のスレッドがI/Oバインドで、一部がCPUバインドのプログラムについてはどうでしょうか。

このようなプログラムでは、PythonのGILは、CPUにバインドされたスレッドからGILを取得する機会を与えないことにより、I/Oにバインドされたスレッドを枯渇させることが知られていました。

これは、Pythonに組み込まれたメカニズムにより、スレッドが連続使用の*一定の間隔*後にGILを強制的に解放し、誰もGILを取得しなかった場合、同じスレッドがその使用を継続できるためです。

>>>

>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

このメカニズムの問題は、ほとんどの場合、CPUにバインドされたスレッドが他のスレッドが取得する前にGIL自体を再取得することでした。 これはDavid Beazleyによって調査され、視覚化はhttp://www.dabeaz.com/blog/2010/01/python-gil-visualized.html [こちら]にあります。

この問題は、GIL取得リクエストの数を調べるhttps://mail.python.org/pipermail/python-dev/2009-October/093321.html [メカニズムを追加]したAntoine Pitrouによって2009年にPython 3.2で修正されました。ドロップされた他のスレッドによって、他のスレッドが実行の機会を得る前に現在のスレッドがGILを再取得することを許可していません。

PythonのGILに対処する方法

GILが問題を引き起こしている場合、ここでいくつかのアプローチを試すことができます。

*マルチプロセッシングとマルチスレッド:*最も一般的な方法は、スレッドの代わりに複数のプロセスを使用するマルチプロセッシングアプローチを使用することです。 各Pythonプロセスは独自のPythonインタープリターとメモリスペースを取得するため、GILは問題になりません。 Pythonにはhttps://docs.python.org/2/library/multiprocessing.html [+ multiprocessing +]モジュールがあり、次のようなプロセスを簡単に作成できます。

from multiprocessing import Pool
import time

COUNT = 50000000
def countdown(n):
    while n>0:
        n -= 1

if __name__ == '__main__':
    pool = Pool(processes=2)
    start = time.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end = time.time()
    print('Time taken in seconds -', end - start)

私のシステムでこれを実行すると、次の出力が得られました。

$ python multiprocess.py
Time taken in seconds - 4.060242414474487

マルチスレッドバージョンと比較して、まともなパフォーマンスの向上ですか?

プロセス管理には独自のオーバーヘッドがあるため、時間は上記の半分になりませんでした。 複数のプロセスは複数のスレッドよりも重いため、これがスケーリングのボトルネックになる可能性があることに留意してください。

代替Pythonインタープリター: Pythonには複数のインタープリター実装があります。 CPython、Jython、IronPython、およびPyPyは、それぞれC、Java、C#、およびPythonで記述されており、最も一般的なものです。 GILは、CPythonである元のPython実装にのみ存在します。 あなたのプログラムとそのライブラリが、他の実装のいずれかで利用可能であれば、それらを同様に試すことができます。

*ちょっと待ってください:*多くのPythonユーザーは、GILのシングルスレッドパフォーマンスの利点を活用しています。 マルチスレッドプログラマは、Pythonコミュニティの最も優秀な人々がCPythonからGILを削除するために取り組んでいるので、心配する必要はありません。 そのような試みの1つは、https://github.com/larryhastings/gilectomy [Gilectomy]として知られています。

Python GILは多くの場合、神秘的で難しいトピックと見なされています。 ただし、Pythonistaとしては、C拡張機能を記述している場合、またはプログラムでCPUにバインドされたマルチスレッドを使用している場合にのみ、通常Pythonistaの影響を受けることに注意してください。

その場合、この記事では、GILが何であるか、そして自分のプロジェクトでGILをどのように扱うかを理解するために必要なすべてを提供する必要があります。 また、GILの低レベルの内部動作を理解したい場合は、David Beazleyによるhttps://youtu.be/Obt-vMVdM8s[Understanding the Python GIL]トークをご覧になることをお勧めします。