Python開発者にとって、グローバルインタープリタロック(GIL)という概念はなじみ深いものでしょう。しかし、その背景や詳細については、すべてを把握しているわけではないかもしれません。ここでは、PythonのGILについて詳しく解説します。
GILとは何か?
GILはGlobal Interpreter Lock(グローバルインタープリタロック)の略称です。Pythonの実行はインタープリタに依存しており、このロックはその名前が示す通り、インタープリタ全体に適用されます。Pythonの初期設計では、マルチスレッド環境におけるデータ整合性と状態同期の問題を解決するため、特定の瞬間には1つのスレッドのみがインタープリタ内で実行されるように設計されました。マルチスレッドプログラムが実行される際、GILはこの「単一スレッド実行」を強制します。これは、Pythonにおけるマルチスレッドは、見かけ上の「同時実行」(concurrency)であり、真の「並列実行」(parallelism)ではないことを意味します。
「同時に1つのスレッドしか実行できないのに、どのようにマルチスレッドプログラムが動くのか?」という疑問が生じるかもしれません。その原理はシンプルで、インタープリタの「時分割多重使用」によるものです。複数のスレッドのコードがインタープリタによって順番に実行されます。この切り替えが非常に高速であるため、ユーザーはあたかも複数のスレッドが「同時に」実行されているかのように錯覚します。これは学術的には「並行性」(concurrency)として知られています。
- 並行性(Concurrency): 異なるタスクを交互に実行する能力。
- 並列性(Parallelism): 異なるタスクを文字通り同時に実行する能力。
では、なぜPythonはこのように設計されているのでしょうか?なぜ特定の瞬間には1つのスレッドだけがインタープリタ内で実行されることを保証する必要があるのでしょうか?その答えは、Pythonインタープリタ内の「アトミック操作」におけるスレッド安全性を確保するためです。
スレッド安全性とアトミック操作
スレッド安全性
まず、プロセスとスレッドの違いを明確に理解する必要があります。プロセスはシステムリソース割り当ての最小単位であり、スレッドはプログラム実行の最小単位です。
例えば、プログラムの実行を食事に例えるなら、プロセスは料理が並べられたテーブルであり、スレッドは食事をする人です。
マルチスレッド環境では、各スレッドがデータを共有しない場合、それは常にスレッド安全です。しかし、このようなケースはまれであり、多くの場合、データを共有する必要があります。その場合、適切な同期制御が必要です。
スレッド安全性は通常、共有データが複数のスレッドによって同時に操作される際に発生する、予期せぬ中間結果を防ぐために「特定の瞬間には1つのスレッドのみが共有データを操作できる」状態を指します。
上記の食事の例を続けると、テーブルには3杯のご飯があり、1人が2杯食べましたが、まだ食べ終わっていません。そのため、テーブルのご飯の数はまだ更新されていません。このとき、2人目の人がご飯を食べたいと思ったとします。スレッド安全性の考慮がなければ、2人目の人が直接3杯のご飯を取ろうとしてしまい、エラーが発生する可能性があります。
アトミック操作
アトミック操作とは?
アトミック操作とは、プロセスまたはスレッドの同時実行によって中断されることがない操作です。アトミック操作の特徴は、その操作が「すべて実行されるか、まったく実行されないか」のどちらかであり、途中で中断されることはありません。
グローバルリソースへの書き込み操作が存在する場合、書き込みプロセスのアトミック性が保証されないと、「ダーティリード(汚れた読み込み)」や「ダーティライト(汚れた書き込み)」が発生する可能性があります。
非アトミック操作の例:
import threading
shared_counter = 0
def increment_task():
global shared_counter
for _ in range(50000): # ループ回数を少し増やしてみる
shared_counter += 1
thread_a = threading.Thread(target=increment_task)
thread_b = threading.Thread(target=increment_task)
thread_a.start()
thread_b.start()
thread_a.join()
thread_b.join()
print(f"最終的なカウンタ値: {shared_counter}")
上記のコードを実行すると、`shared_counter`の出力は不定であり、通常100000より小さくなります。しかし、実際にはカウンタは100000回インクリメントされるはずです。
なぜこのような結果になるのでしょうか?これは、`shared_counter += 1`という書き込み操作がアトミックではないためです。この操作は実際には以下の3つのステップを経て実行されます。
- `shared_counter`変数が指す値をメモリから読み込む。
- その値に1を加算する。
- 新しい結果の値を`shared_counter`変数に書き込む。
GILが存在するにもかかわらず、Pythonインタープリタは一定のバイトコード命令数(CPythonでは通常1000命令)が実行されるたびに、現在のスレッドを中断し、別のスレッドにGILを渡す可能性があります。もし、上記3ステップの途中でスレッドが中断され、別のスレッドが`shared_counter`を操作すると、一方の更新が失われる(上書きされる)可能性があります。
アトミック操作の例:
data_list = [5, 2, 4, 1, 3]
def sort_list_atomically():
# Pythonの組み込み関数はC言語で実装されており、通常は単一のGIL保持期間で完了するため、
# このようなリスト操作はPythonレベルではアトミックとみなされがちです。
# ただし、厳密には内部で複数のC関数呼び出しがある可能性もあります。
data_list.sort()
アトミック操作と非アトミック操作の判別
次の関数を例にとります。
item_value = 0
def modify_item():
global item_value
item_value += 1
この関数をPythonの標準disモジュールでコンパイルされたバイトコードを見ると、以下のようになります。
>>> import dis
>>> dis.dis(modify_item)
4 0 LOAD_GLOBAL 0 (item_value)
2 LOAD_CONST 1 (1)
4 INPLACE_ADD
6 STORE_GLOBAL 0 (item_value)
コードのitem_value += 1という1行が、4つの基本的な操作を含む4つのバイトコードにコンパイルされています。
item_valueの値をスタックにロードする- 定数1をスタックにロードする
- スタックのトップにある2つの値を加算する
- 合計値を
item_valueにストアし直す
インタープリタは、あるスレッドが1000バイトコードを実行するたびに、そのスレッドを中断してGILを奪い取ることがあります。もし運が悪ければ、この中断はスレッドがitem_valueをスタックにロードした後、そしてそれをitem_valueにストアし直す前に発生する可能性があります。このプロセスがどのように更新の損失を引き起こすかは容易に理解できます。
import threading
data_val = 0
thread_pool = []
for _ in range(100):
t = threading.Thread(target=modify_item)
thread_pool.append(t)
for t in thread_pool:
t.start()
for t in thread_pool:
t.join()
print(f"最終的なデータ値: {data_val}") # data_val は 100 ではなく、99 や 98 になることがある
通常、このコードは100を出力します。なぜなら100個のスレッドがそれぞれ`data_val`をインクリメントするからです。しかし、あるスレッドの更新が別のスレッドによって上書きされる場合、99や98といった値が表示されることがあります。
したがって、GILが存在しても、共有される可変状態を保護するためには「ロック」を追加する必要があります。
import threading
protected_val = 0
access_lock = threading.Lock()
def safe_modify_item():
global protected_val
with access_lock: # ロックを使用して排他制御を行う
protected_val += 1
# 上記の `modify_item` の代わりに `safe_modify_item` を使用してテストすると、
# 常に期待される結果(例えば100000)が得られます。
GILの利点と欠点
GILの利点は明らかです。GILは、マルチスレッドプログラミングにおいて、データ整合性や状態同期の問題を深く考慮する必要がないことを保証します。これは、インタープリタ自体の実装をシンプルにし、メモリ管理を容易にする効果があります。
GILの欠点は、Pythonのマルチスレッドプログラムが「並行」には実行されるものの、「並列」には実行されないことです。そのため、CPUバウンドなタスクにおいては実行効率が低く、単一スレッドの実行効率を下回ることさえあります。
「なぜ私のマルチスレッドPythonプログラムは、単一スレッドのときよりも遅いのか?」という疑問はよく聞かれます。2つのスレッドを持つプログラムが1つのスレッドよりも速いと考えるのは自然なことです。実際、この問題はGILの存在によって引き起こされます。GILは常に1つのスレッドしか実行させないため、スレッドの切り替えやGILの競合にかかるオーバーヘッドが加わり、結果的にPythonのマルチスレッドは単一スレッドよりも効率が悪くなることがあります。
GILが存在する理由とその歴史
「なぜGILは特定の瞬間には1つのスレッドしか実行しないことを保証する必要があるのか?」「複数の独立したオブジェクトへの同時アクセスを防ぐために、よりきめ細かいロックを追加することはできないのか?」といった疑問が当然湧いてきます。そして、「なぜこれまで誰もそのような試みをしなかったのか?」
これらの疑問には非常に興味深い回答があります。まず明確にしておくべきは、Pythonインタープリタには複数の実装バージョンがあるということです。CPython、Jythonなどです。GILの問題は、主にC言語で実装されたCPythonに存在します。
GILの問題が解決されない理由の一つは、CPythonが初期からGILを設計思想として採用しており、多くのパッケージがCPython、さらにはGIL自体に依存しているためです。これにより、後戻りできない状況(歴史的経緯)が生じています。
マルチコアの活用のため、Pythonはマルチスレッドをサポートし始めました。マルチスレッド間のデータ整合性と状態同期を解決する最も簡単な方法は、ロックを追加することです。そこで、GILという巨大なロックが導入されました。そして、この設定を受け入れた多くのライブラリ開発者は、この特性(Python内部オブジェクトがデフォルトでスレッドセーフであり、追加のメモリロックや同期操作を考慮する必要がないこと)に大きく依存するようになりました。
しかし、この実装方法が非効率であることが徐々に明らかになりました。それでも、GILを分割したり除去したりしようとすると、多数のライブラリコード開発者がGILに深く依存しているため、その除去が非常に困難であることが判明しました。
GILは、現在のスレッドの状態やガベージコレクション用のヒープ割り当てオブジェクトへのアクセスなどを保護します。これは、CPythonの実装の典型的な産物です。現在、GILを使用しない他のPythonインタープリタ(およびコンパイラ)も存在します。
では、なぜGILは放棄されないのでしょうか?多くの人は知らないかもしれませんが、1999年にPython 1.5向けに、Greg Steinによる「フリースレッディング」パッチがこのアイデアを実装しようとしました。このパッチではGILが完全に削除され、よりきめ細かいロックに置き換えられました。しかし、GILの削除はシングルスレッドプログラムの実行速度に一定の代償をもたらしました。シングルスレッド実行時、速度は約40%低下しました。2つのスレッドを使用すると速度向上が見られましたが、それ以上のコア数ではこの恩恵は線形には増加しませんでした。実行速度の低下のため、このパッチは却下され、ほとんど忘れ去られました。
しかし、「フリースレッディング」パッチは示唆に富んでおり、Pythonインタープリタに関する基本的な点を証明しました。GILの削除は非常に困難であるということです。このパッチがリリースされた時代、インタープリタはより多くのグローバル状態に依存するようになり、現在のGILを削除することはさらに困難になっています。
スレッドベースのプログラミングは間違いなく困難です。エンコード中に常に新しい問題が静かに発生します。そのため、一部の著名な言語設計者や研究者は、特定のスレッドモデルを結論付けています。マルチスレッドアプリケーションを記述したことがある人なら誰でも知っているように、開発もデバッグもシングルスレッドアプリケーションより数倍難しいものです。プログラマが通常持つシーケンシャル実行の思考パターンは、並列実行パターンとは適合しません。GILの存在は、意図せず開発者が困難な状況に陥るのを助けてきました。マルチスレッドを使用しても同期が必要な場合でも、GILは実際に異なるスレッド間のデータ一貫性の問題を維持するのに役立っています。
したがって、簡潔に言えば、GILの存在は主に歴史的経緯によるものです。もし最初からやり直すことができたとしても、マルチスレッドの問題にはやはり直面するでしょうが、少なくとも現在のGIL方式よりも洗練された方法が採用されたかもしれません。
GILの影響を回避する方法
マルチプロセス(multiprocessing)の使用(推奨)
multiprocessingライブラリの登場は、GILのためにthreadingライブラリが非効率であるという欠点を補うために生まれました。threadingが提供するインターフェースを完全にコピーしており、移行を容易にしています。唯一の違いは、マルチスレッドではなくマルチプロセスを使用する点です。各プロセスは独自の独立したGILを持つため、プロセス間のGILの競合は発生しません。
もちろん、multiprocessingも万能薬ではありません。その導入は、プログラム実装時のプロセス間データ通信と同期の困難さを増大させます。カウンタを例にとると、複数のスレッドで同じ変数を累積する場合、threadingではグローバル変数を宣言し、threading.Lockのコンテキストで3行を囲むだけで済みます。しかし、multiprocessingではプロセス間でお互いのデータを見ることができないため、メインプロセスでQueueを宣言し、putしてgetするか、共有メモリの方法を使うしかありません。この追加の実装コストは、元々困難なマルチスレッドプログラミングをさらに困難なものにします。
他のインタープリタの使用(非推奨)
GILがCPythonの産物であると前述しましたが、他のインタープリタの方が優れているのでしょうか?その通り、JythonやIronPythonのようなインタープリタは、実装言語の特性上、GILの助けを必要としません。しかし、JavaやC#をインタープリタの実装に使用しているため、多くのC言語モジュールの有用な特性を利用する機会を失っています。そのため、これらのインタープリタは常にマイナーな存在でした。結局のところ、機能とパフォーマンスは初期段階では前者が選択されるため、「Done is better than perfect(完璧よりも完成が優先される)」という考えが適用されます。
GILとミューテックス(排他ロック)
GILは開発者が記述したコードを保護するものではないことに注意が必要です。これは、特定の瞬間には1つのPythonスレッドしか実行されないにもかかわらず、そのスレッドがあるデータ構造を操作している最中に、他のスレッドがそのスレッドを中断する可能性があるためです。このような現象が発生すると、プログラムの状態が破壊され、関連するデータ構造の一貫性が失われる可能性があります。すべてのスレッドが公平に実行されるように、Pythonインタープリタは各スレッドにおおよそ等しいプロセッサ時間を割り当てます。この割り当て戦略を実現するため、Pythonシステムは特定のスレッドが実行中にそれを一時停止し、別のスレッドを続行させることがあります。Pythonシステムがいつスレッドを一時停止するかを事前に知ることはできないため、プログラム内の特定操作がアトミック操作であるかを制御することはできません。
スレッド内のデータ競合を防ぎ、開発者がデータ構造を保護できるようにするため、Pythonはthreadingモジュールで最もシンプルで有用なツールであるLockクラス(排他ロックに相当)を提供しています。
開発においては、排他ロックを使用してオブジェクトを保護することで、複数のスレッドが同時にあるオブジェクトにアクセスしても、そのオブジェクトが破壊されないようにできます。これは、特定の瞬間には1つのスレッドしかこのロックを獲得できないためです。つまり、アクセスしようとするオブジェクトを隔離するのです。スレッド分離の意義は、現在のスレッドが自身が作成したオブジェクトを正しく参照し、他のスレッドが作成したオブジェクトを参照しないようにすることにあります。
まとめると、PythonのGILは機能とパフォーマンスのトレードオフの産物であり、その存在には合理性があり、変更が困難な客観的な要因もあります。以下にいくつかの簡単な要点をまとめます。
- GILの存在により、I/Oバウンドなシナリオでのみマルチスレッドが良好なパフォーマンスを発揮します。
- 並列計算性能が要求されるプログラムの場合、コア部分をC言語モジュールとして記述するか、いっそのこと他の言語で実装することを検討できます。
- Pythonプログラミングにおいて、コンピュータのマルチコアを活用してプログラムの実行効率を向上させたい場合、マルチスレッドの代わりにマルチプロセスを使用します。
- GILが存在しても、GILはPythonインタープリタの状態のみを保護するため、非アトミック操作に対しては、Pythonでマルチスレッドプログラミングを行う際も、スレッド安全性を確保するために排他ロック(
threading.Lockなど)を使用する必要があります。 - GILはしばらくの間存在し続けるでしょうが、継続的に改善が加えられる可能性があります。