PythonProgrammingPython Developer

なぜ、ホストシステムに複数のコアが利用可能であるのに、単一の**Python**プロセス内の複数の**スレッド**が1つのCPUコアで順次実行されるのですか?

Hintsage AIアシスタントで面接を突破

質問への回答

Pythonグローバルインタプリタロック(GIL)は、Pythonオブジェクトへのアクセスを保護するミューテックスであり、同時に1つのスレッドのみがPythonバイトコードを実行できることを保証します。この設計上の決定は、CPythonでメモリ管理を簡素化し、オブジェクト参照カウントの競合状態を防ぐためになされました。その結果、マルチコアプロセッサ上でも、スレッドは並行してPythonコードを実行できず、代わりに単一コア上で実行が急速に切り替わるため、CPUバウンドなマルチスレッドは非効率です。

import threading import multiprocessing import time def cpu_intensive_task(n): """純粋なPythonのCPUバウンド操作""" count = 0 for i in range(n): count += i ** 2 return count # スレッディング制限のデモ start = time.time() threads = [threading.Thread(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f"スレッディング時間: {time.time() - start:.2f}s") # 出力はGIL競合のため、約4倍のシングルスレッド時間を示します。 start = time.time() processes = [multiprocessing.Process(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for p in processes: p.start() for p in processes: p.join() print(f"マルチプロセッシング時間: {time.time() - start:.2f}s") # 出力はシングルスレッド時間の約1倍(4倍のスピードアップ)を示します。

実生活の状況

問題: 私たちの分析プラットフォームは、複雑な正規表現抽出と統計計算を伴う10GBのログファイルを処理する必要がありました。エンジニアリングチームは、16コアのサーバー上で16のスレッドを持つconcurrent.futures.ThreadPoolExecutorを使用したスレッディングベースのワーカープールを実装しました。驚くべきことに、CPU使用率は6-7%(1コア)に留まり、処理には3時間かかりましたが、順次処理では45分で済みました。GILは順次実行を強制するとともに、スレッド切り替えのオーバーヘッドを追加していました。

解決策1: C拡張を使用した最適化されたスレッディング** 計算負荷の高い処理をNumPy操作に移行し、実行中にGILを解放するC加速ライブラリを利用することを検討しました。

利点: コードの変更が最小限; 共有メモリによりシリアル化コストを排除; スレッドがアドレス空間を共有するため、メモリフットプリントが小さくなる。

欠点: NumPyでサポートされている操作に限定される; カスタムアルゴリズムは依然としてPythonバイトコードの実行が必要; C拡張の相互作用のデバッグが複雑を増す。

解決策2: multiprocessingを使用したプロセスベースの並列性 multiprocessing.Poolまたはconcurrent.futures.ProcessPoolExecutorに切り替えることを検討し、別々のPythonインタプリタを起動しました。

利点: すべてのCPUコアを利用した真の並列性; CPUバウンドタスク向けの線形スケーラビリティ; 隔離によりGILの競合を完全に防ぐ。

欠点: より高いメモリ使用量(各プロセスが別々のPythonインタプリタを約50-100MBロードする); プロセス間通信のためにデータをピクル化/アンピクル化する必要がある; プロセス起動のレイテンシオーバーヘッド。

選択した解決策: 私たちはmultiprocessingを使用してチャンクデータ処理を選択しました。ログは16セグメントに分割され、ProcessPoolExecutorによって処理され、結果がマージされました。チャンク化戦略により、IPC頻度が減少することでpickleオーバーヘッドが最小化されました。

結果: 処理時間は3時間から4分に短縮され(45倍のスピードアップ)、CPU利用率はすべての16コアで98%に達しました。各プロセスあたりのメモリ使用量は800MB増加しました(合計12.8GB)、これは私たちの128GBサーバーで許容範囲でした。私たちは複数のバッチジョブにわたって起動コストを分散するためにプロセスプールシングルトンを実装しました。

候補者が見落としがちなこと


なぜGILはI/Oバウンドのスレッディングパフォーマンスに影響を与えないのか?

多くの候補者は、スレッドPython内で完全に無用であると誤解しています。GILは、I/O操作(ネットワークリクエスト、ディスク読み取り、データベースクエリ)中と、明示的にそれを解放するC拡張を呼び出すとき(NumPy行列操作など)に解放されます。スレッドがI/Oのためにブロックされている間、他のスレッドPythonコードを実行できます。したがって、スレッディングは、ウェブスクレイピングや、asyncioベースのサーバーでの何千もの同時接続の処理など、同時I/O処理に非常に効果的です。


PyPyJythonのような代替Python実装はGILを持っていますか?**

候補者はしばしば、GILの削除は異なるインタプリタを使用することの単純な問題だと考えます。PyPy(JITコンパイルされたPython)もスレッドの安全性を維持するためにGILを実装していますが、その異なるオブジェクトモデルはスレッド切り替えをより効率的にすることができます。しかし、JythonJVM上で実行)やIronPython.NET CLR上で実行)にはGILがありません。なぜなら、これらは基盤となる仮想マシンのガベージコレクションとスレッド原則に依存しており、JVMスレッドでの真のスレッドレベルの並列性を可能にしているからです。


新しいプロセスを生成せずに手動でGILを解放できますか?

多くの開発者はC拡張における手動のGIL管理を認識していません。CythonやCコードを書く際に、長時間実行される計算の周囲でPy_BEGIN_ALLOW_THREADSおよびPy_END_ALLOW_THREADSマクロを使用して明示的にGILを解放できます。さらに、Python 3.12+では、1つのプロセス内でサブインタプリタごとに別々のGILを持つ(PEP 684)機能が導入されましたが、これは実験的なinterpretersモジュールを必要とし、インタプリタ間でオブジェクトを直接共有しません。