「スレッド増やせば速くなるんじゃないの?」は間違い?
Pythonで処理を高速化したいと思ったとき、最初に試したくなるのは「スレッドを使うこと」ではないでしょうか? しかし、意気込んで実装してみたものの、こんな経験はありませんか?
- 「よーし、処理を10個のスレッドに分けて並列化したぞ!...あれ、全然速くならない...むしろ遅くなった?」
- 「タスクマネージャーや
top
を見ても、CPUが1コアしか使われていない...なんで?」
そんな不思議な現象に遭遇し、「Pythonの並列処理は難しい」と諦めかけている方も多いと思います。
実は、この現象の裏には、Pythonが持つGIL(Global Interpreter Lock) という強力な制約が関係しています。さらに、CPUのコア数、プロセス、スレッド、そして大規模計算で登場するノードといったハードウェアの仕組みを正しく理解していないと、並列処理が逆効果になってしまうことさえあるのです。
この記事では、そんなPythonの並列処理の「なぜ?」を解消するために、以下の点を丁寧に、図を交えながら解説していきます。
- GILの仕組みと、なぜマルチスレッドが速くならないのか
- コア・スレッド・プロセス・ノードの役割と関係性
- あなたのPCで、本当に並列処理を活かすための実践的な方法
この記事を読み終える頃には、あなたは自信を持って適切な並列化手法を選択できるようになるでしょう。
諸悪の根源? PythonのGILって一体何?
まず、PythonのマルチスレッドがなぜCPUを使い切れないのか、その最大の理由であるGILについて理解しましょう。
GILとは、「Pythonの中で、一度に1つのことしかできないようロックする仕組み」です。
私たちが書いたPythonコードを実行するとき、CPython(最も一般的に使われているPython実装)は、複数のスレッドが同時にPythonのオブジェクト(変数やデータ構造など)を操作して、メモリの状態が壊れてしまわないように、このGILという仕組みで保護しています。
このロックがあるため、たとえマルチコアCPU上で複数のスレッドを生成したとしても、常に1つのスレッドしかPythonのコードを実行できません。 他のスレッドは、そのロックが解除されるのを待つしかないのです。
たくさんのスレッドがあっても、GILという鍵を持っている1つのスレッドしかCPUのコアでPythonコードを実行できず、他のスレッドは待機状態になります。CPUコアが複数あっても、同時に利用されるのは1つだけです。
スレッドを増やしても速くならない?絶望を味わう実験
百聞は一見に如かず。実際にコードを動かして、このGILの挙動を確認してみましょう。
以下は、無限にCPUを使い続ける単純な関数 busy()
を、10個のスレッドで同時に実行しようとするコードです。
import threading
import time
def busy():
""" CPUを消費し続けるだけの単純な関数 """
while True:
pass
print("10個のスレッドでCPUを消費させます...")
for i in range(10):
thread = threading.Thread(target=busy, name=f"Thread-{i+1}")
thread.start()
# このままでは無限ループなので、適当な時間で終了させるか、
# ターミナルから手動で停止してください。
# time.sleep(30)
このコードを実行した状態で、OSのアクティビティモニタ(Windowsならタスクマネージャー、macOSならアクティビティモニタ、Linuxならtop
やhtop
)を見てみましょう。
(これはhtop
の実行例です。CPUのコアが複数あるにも関わらず、Pythonプロセスが消費しているCPU使用率は100%(1コア分)に張り付いているのが分かります)
期待通りならCPU使用率が1000%(10コア分)近くになりそうですが、実際には100%前後で頭打ちになるはずです。これは、10個のスレッドがGILの周りで順番待ちを繰り返し、結果として1つのCPUコアしかフル活用できていないことを示しています。
Note:
このように計算処理が中心の処理を「CPUバウンド」な処理と呼びます。GILの影響が最も顕著に現れるのが、このCPUバウンドな処理です。
では「プロセス」を使えば速くなるのか?
マルチスレッドがダメなら、どうすれば複数のCPUコアを使い切れるのでしょうか? 答えはマルチプロセスを使うことです。
multiprocessing
モジュールを使うと、GILの制約を受けない独立したプロセスを生成できます。プロセスはそれぞれが独自のメモリ空間とPythonインタープリタを持つため、GILもプロセスごとに独立します。これにより、複数のプロセスが異なるCPUコア上で同時に実行され、真の並列処理が実現できます。
from multiprocessing import Pool
import os
import time
def task(x):
""" CPUに負荷をかける計算タスクの例 """
# 実際にはもっと重い計算を想定
return x * x
if __name__ == '__main__':
# 利用可能なCPUコア数を取得
cpu_cores = os.cpu_count()
print(f"このマシンのCPUコア数: {cpu_cores}")
# CPUコア数に合わせてプロセスプールを作成するのが効率的
with Pool(processes=cpu_cores) as pool:
# 0から999までの値を並列で処理
inputs = range(1000)
start_time = time.time()
results = pool.map(task, inputs)
end_time = time.time()
print(f"マルチプロセス処理にかかった時間: {end_time - start_time:.4f} 秒")
このコードを実行すると、アクティビティモニタ上で複数のPythonプロセスが立ち上がり、CPUコアがそれぞれ均等に使われる様子が観測できるはずです。これこそが、私たちが求めていた並列処理です!
プロセスごとに独立したメモリ空間とGILを持つため、異なるプロセスは別のCPUコアで同時に実行できます。これにより、CPUコアをフル活用した並列処理が可能です。
コア・スレッド・プロセス・ノードの違いを整理しよう
ここで一度、混乱しがちな用語を整理しましょう。これらの関係性を理解することが、適切な並列化設計の鍵となります。
用語 | 説明 | 関係性 |
---|---|---|
CPUコア | 物理的な計算装置そのもの。CPUの「頭脳」にあたる部分で、同時に処理を実行できる数の基本単位。 | プロセスやスレッドは、このコアの上で実行される。 |
スレッド | プロセス内にある、より軽量な処理の実行単位。同じプロセスのメモリ空間を共有する。 | 1つのプロセスは、複数のスレッドを持つことができる。 |
プロセス | OSからメモリを割り当てられた、独立したプログラムの実行単位。個別のメモリ空間を持つ。 | 複数のスレッドを持つことができる。他のプロセスとはメモリを共有しない。 |
ノード | 1台の物理的なコンピュータ(サーバやPC)のこと。HPC(高性能計算)分野では、複数台のノードを連携させて大規模な計算を行う。 | 各ノードには、通常1つ以上のCPUがあり、その中に複数のコアが含まれる。 |
工場で例えると...
これらの関係は、一つの大きな工場に例えると非常に分かりやすくなります。
- ノード (Node) = 工場(建物)1棟
- CPUコア (Core) = 作業員
- プロセス (Process) = 独立した作業ライン(グループ)
- スレッド (Thread) = 作業員の持つ個々の手
工場(ノード)の中に、6人の作業員(コア)がいるとします。
この工場では、**独立した作業ライン(プロセス)**を複数作ることができます。各ラインは他のラインとは関係なく、自分たちの資材(メモリ)だけを使って作業を進めます。
そして、各作業員(コア)は**複数の手(スレッド)**を持っていますが、一度に集中して一つの作業しかできません(GILの制約に似ています)。たくさんの手があっても、同時に別の種類の製品を作ることはできないのです。
本当に生産量を上げたければ、新しい作業ライン(プロセス)を立ち上げ、他のヒマな作業員(コア)に割り当てる必要があります。
工場に作業員(コア)が6人しかいなければ、いくら作業ライン(プロセス)や手の数(スレッド)を増やしても、同時に働けるのは最大で6人まで、ということです。
この例では、6コア(作業員)のノード(工場)で3つのプロセス(作業ライン)が動いています。プロセス2は2つのスレッドを持っていますが、GILのためコア2(作業員C)で順番に処理されます。プロセス1と3はそれぞれ独立してコア0と3を使っています。コア4と5は現在、他のOSタスクなどに備えて待機中です。
自分のPCの性能を確認してみよう
理論が分かったところで、今度はあなたのPCの「作業員(コア)の数」を確認してみましょう。
Linux / macOS の場合
ターミナルで lscpu
コマンドを実行します。
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 8 # ← これが総論理コア数(OSが認識するスレッド数)
On-line CPU(s) list: 0-7
Thread(s) per core: 2 # ← 1物理コアあたりのスレッド数(ハイパースレッディング)
Core(s) per socket: 4 # ← 物理コア数
Socket(s): 1
...
CPU(s)
がOSから見える論理コア数、Core(s) per socket
が物理的なコア数です。multiprocessing
でプロセス数を指定する際は、この物理コア数 (Core(s) per socket
) か論理コア数 (CPU(s)
) を目安にするのが一般的です。
Windows の場合
コマンドプロンプトやPowerShellで以下のコマンドを実行するか、タスクマネージャーの「パフォーマンス」タブで確認できます。
# 物理コア数
> wmic cpu get NumberOfCores
# 論理プロセッサ(コア)数
> wmic cpu get NumberOfLogicalProcessors
また、現在実行中のプロセス数も見てみましょう。
# Linux / macOS
$ ps -ef | wc -l
# Windows PowerShell
> (Get-Process).Count
おそらく数百ものプロセスが表示されるはずです。しかし、これらが全て同時に動いているわけではありません。実際にCPUで計算を実行しているのはごく一部で、残りはI/O待ちやイベント待ちで待機状態にあります。OSがこれらのプロセスを高速に切り替えながら実行することで、あたかも同時に動いているように見せかけているのです。
プロセスやスレッドは、いくつまで作っていいの?
では、プロセスやスレッドは無尽蔵に生成して良いのでしょうか?目安は以下の通りです。
項目 | 目安と特性 |
---|---|
スレッド | 軽量で生成コストが低い。数千個単位で作成可能だが、GILの制約を受ける。メモリ空間を共有するため、データ競合に注意が必要。 |
プロセス | 生成コストがスレッドより重い(メモリやOSリソースを消費)。数百〜数千程度が現実的な上限で、メモリとOSの制限に依存する。 |
CPUコア数を超えた分は? | CPUコア数を超える数のプロセス(CPUバウンドなもの)を生成しても、真の並列 (Parallelism) 処理にはなりません。OSがCPU時間を細かく分割し、複数のプロセスを順番に切り替えて実行する並行 (Concurrency) 処理になるだけです。コンテキストスイッチ(プロセスの切り替え)のコストが増え、かえって性能が低下することもあります。 |
結論として、CPUバウンドな処理のプロセス数は、PCのCPUコア数に合わせるのが最も効率的です。
GILの制約を避け、Pythonを本当に高速化するレシピ
これまでの知識を総動員して、あなたのタスクに最適な高速化手法を選びましょう。
処理タイプ | 特徴 | 推奨される手法 | なぜ有効か |
---|---|---|---|
I/Oバウンド | ネットワーク通信、APIアクセス、ファイル読み書きなど、CPUがデータの到着を「待っている」時間が長い処理。 |
threading / asyncio
|
CPUが待っている間にGILを解放し、他のスレッドが動けるため。見かけ上の処理時間を大幅に短縮できる。 |
CPUバウンド | 大規模な数値計算、画像処理、機械学習のモデル学習など、CPUが常に計算し続けている処理。 |
multiprocessing / concurrent.futures.ProcessPoolExecutor
|
GILの制約をプロセスごと回避し、複数のCPUコアをフル活用できるため。 |
ノードを跨ぐ超大規模計算 | 複数のマシン(クラスタ)を使って、さらに大規模な計算を行う場合。 | MPI (例: mpi4py ) / Dask / Ray |
複数のノード(マシン)に処理を分散させ、ネットワーク経由で協調動作させるため。 |
GILそのものを回避 | Pythonの構文は好きだが、どうしても速度が必要な特定の部分だけを高速化したい場合。 | Cython / Numba / C/C++拡張 | PythonコードをC言語レベルにコンパイルしたり、C言語で書かれた関数を呼び出したりすることで、その部分だけGILの制約から解放するため。 |
まとめ
長くなりましたが、重要なポイントをまとめます。
- PythonのGILは、マルチスレッドにしても同時に1つのスレッドしかCPUを使えないようにする制約です。
- このため、計算が重いCPUバウンドな処理では、マルチスレッドは高速化に繋がりません。
- 真の並列処理でCPUコアを使い切るには、
multiprocessing
を使ったマルチプロセス化が基本戦略となります。 - 生成するプロセス数は、PCのCPUコア数に合わせるのが最も効率的です。
- ネットワーク通信などのI/Oバウンドな処理では、待機時間を有効活用できるマルチスレッドや非同期処理が効果的です。
- さらに大規模な処理や、部分的な高速化には、MPIやCythonといった専門的なツールも選択肢になります。
「スレッドを増やせば速くなる」という単純な考え方から一歩進んで、処理の特性(CPUバウンドかI/Oバウンドか)とハードウェア(CPUコア数)を意識することで、あなたのPythonコードは劇的にパフォーマンスを向上させることができるでしょう。