【Python】threadingとは?スレッドを使った並列処理を初心者向けに解説
はじめに
Pythonで複数の処理を同時に進めたいときに便利な方法として、threadingモジュールを使ったマルチスレッドがよく挙げられます。
ひとつのプログラムの中で並列的に作業を実行できるため、たとえばネットワークアクセスやファイル入出力などの待ち時間を有効に使えます。
しかし初心者の皆さんにとって、並列処理やスレッドという言葉は少しとっつきにくいイメージがあるかもしれません。
この記事では、Pythonでマルチスレッドを扱う際の基本的な考え方や、典型的な使い方をわかりやすく解説していきます。
この記事を読むとわかること
- threadingモジュールを使ったスレッドの基本的な動かし方
- マルチスレッドを活用した場合のメリットと注意点
- 具体的なコード例から学ぶスレッド制御の方法
- 実務で想定されるユースケースと活用のポイント
ここからは、スレッドによる並列処理の考え方を順番に見ていきます。
なるべく専門用語をかみくだいて、コード例も含めながら紹介しますので、まだPythonに慣れていない方でも安心して読み進めてみてください。
マルチスレッドとは何か
Pythonプログラムを作るうえで、スレッドやマルチスレッドという用語は避けて通れない局面があります。
まずは「そもそもスレッドって何だろう?」というところから見てみましょう。
スレッドという概念
ひとつのプログラムを動かすとき、通常は一連の処理が上から下へ順番に実行されていきます。
この流れを「メインスレッド」と呼ぶことがあります。
一方、マルチスレッドでは、メインスレッド以外にも複数のスレッドを立ち上げ、それぞれ並行して処理を進めることができます。
たとえば以下のようなイメージを思い浮かべてみてください。
- メインスレッド:ユーザーからの入力を受け取る
- サブスレッドA:ネットワークからデータを取得する
- サブスレッドB:取得したデータを加工・処理する
このように複数の作業が同時進行できると、実行時間を短縮したり、待ち時間を減らしてプログラムを軽快に動作させたりできます。
Pythonには、このスレッドを管理するための便利なモジュールが最初から用意されており、それがthreadingです。
マルチスレッドのメリット
マルチスレッドを用いると、いわゆる並列処理によって作業を効率化できる可能性があります。
一見すると「並列処理=処理速度を加速させる魔法のテクニック」という印象を受けるかもしれませんが、実際には以下のようなメリット・狙いがあります。
- 待ち時間の削減:ファイルの読み込みやネットワークアクセスなどが並列で進むため、メインの処理がブロックされにくい
- ユーザー体験の向上:GUIアプリケーションやWebアプリケーションなどの応答が止まらず、使いやすい印象を与えられる
スレッドを活用するメリットは、作業を同時に分割して進行させられる点にあるといえます。
マルチスレッドの限界と注意点
ただし、Pythonには GIL (Global Interpreter Lock)と呼ばれる仕組みが存在しており、本質的なCPUコアの並列実行は制限される場合があります。
CPUをフルに使う計算集中型の処理では、マルチスレッドによる速度向上が期待しにくい場面もあるのです。
一方で、入出力が多いタスクなどでは、GILがあってもマルチスレッドの恩恵を受けられる場合があります。
またスレッドをたくさん立ち上げると、どの処理がいつ完了するのか予測がつきにくくなり、データの衝突や競合(コンフリクト)が起こりやすくなります。
複雑なバグを生む原因にもなるため、スレッドを増やしすぎるのは得策ではないことも多いです。
次のセクションからは、具体的にPythonのthreadingモジュールを使う方法を見ていきましょう。
Pythonでのthreadingモジュールの使い方
ここでは、threadingモジュールの基本構文をもとに「どのようにスレッドを作り、動かして、終了させるか」について紹介します。
初心者の皆さんが最初に押さえておきたいポイントとしては、threading.Threadクラスの利用方法と、スレッド間でのデータのやり取り方法です。
スレッドの作成と開始
threadingモジュールには、Threadというクラスが含まれています。
スレッドを立ち上げるときは、Threadクラスのインスタンスを生成し、start()
メソッドを呼び出す流れになります。
import threading import time def worker(name): print(f"{name}開始") time.sleep(2) print(f"{name}終了") # Threadオブジェクトを作成 thread_a = threading.Thread(target=worker, args=("スレッドA",)) thread_b = threading.Thread(target=worker, args=("スレッドB",)) # スレッドを開始 thread_a.start() thread_b.start() # メインスレッドがサブスレッドの終了を待機 thread_a.join() thread_b.join() print("全スレッド終了")
上記のコードでは、worker
という関数を2つのスレッドで並行して呼び出しています。
thread_a.start()
や thread_b.start()
が実行された瞬間に、2つのスレッドがそれぞれ動き出します。
メインスレッドは最後に thread_a.join()
と thread_b.join()
を使って、サブスレッドが完了するまで待機します。
もし join()
を呼ばずに放置すると、メインスレッドの処理が先に終わった時点でプログラム全体が終了してしまうため、サブスレッドが途中で止まることがあるかもしれません。
そのため、必要に応じてjoin()
を使い、スレッドの完了を待ってから次の処理に進むことがよく行われます。
スレッドクラスを継承して使う方法
Threadオブジェクトに渡すのは target=関数
だけでなく、Threadクラスを継承した独自クラスを作る方法もあります。
下記のように書くと、スレッドごとの処理をカプセル化できるので、少し大きめのプログラムでもコードを整理しやすくなるでしょう。
import threading import time class MyWorker(threading.Thread): def __init__(self, name): super().__init__() self.name = name def run(self): print(f"{self.name}の処理を開始") time.sleep(2) print(f"{self.name}の処理を終了") # Threadクラスを継承したオブジェクトを生成 thread_a = MyWorker("WorkerA") thread_b = MyWorker("WorkerB") thread_a.start() thread_b.start() thread_a.join() thread_b.join() print("すべてのスレッド処理が終了しました")
run()
メソッドの定義がスレッド処理の中核になる、というところがポイントです。
start()
が呼ばれると、内部的に run()
が呼び出される仕組みになっています。
このようにクラスを継承して使うことで、スレッドごとに固有の属性やメソッドを管理しやすくなります。
特に実務でスレッド数が増えて複雑になる場合は、クラスとしてまとめておくと可読性が上がるかもしれません。
スレッド間のデータ共有と競合に関する注意点
マルチスレッドを実装するときに大きな課題となるのが、スレッド同士で同じデータを操作することによる競合です。
これをうまく制御しないと、一部の変数が途中で書き換えられてしまったり、想定外のタイミングで別のスレッドの処理が割り込んできたりして、意図しないバグに発展します。
共有データの衝突例
具体例として、以下のようなコードを考えてみましょう。
import threading counter = 0 # 共有変数 def increment(): global counter for _ in range(100000): counter += 1 thread_a = threading.Thread(target=increment) thread_b = threading.Thread(target=increment) thread_a.start() thread_b.start() thread_a.join() thread_b.join() print(counter)
上の例では、理想的には counter
が 200000 になることを期待します。
しかし実行結果としては、200000にならず、何度試しても違う値になることが多いかもしれません。
これは、counter += 1
の実行途中で他のスレッドが割り込むと、counter
の値が中途半端な状態のまま上書きされる可能性があるためです。
こうした状況を避けるためには、何らかの方法で排他制御(同時に変更が起きないように制御)を行う必要があります。
Lockオブジェクトを使った排他制御
threadingモジュールでは、Lockオブジェクトを使って共有変数へのアクセスを排他的に行うことができます。
まずLockを作り、共有データにアクセスするときにlock.acquire() でロックを獲得し、操作が終わったら lock.release() でロックを解放する形です。
import threading lock = threading.Lock() counter = 0 def increment(): global counter for _ in range(100000): lock.acquire() counter += 1 lock.release() thread_a = threading.Thread(target=increment) thread_b = threading.Thread(target=increment) thread_a.start() thread_b.start() thread_a.join() thread_b.join() print(counter)
Lockを使うことで、ある瞬間にはひとつのスレッドだけが counter
を書き換えられるように制御されます。
これにより、counter
が本来の期待通りの値に落ち着きやすくなります。
ただし、ロックを多用すると今度は同時実行性が失われやすくなり、スレッドが待ち状態になる時間が長くなります。
実装のバランスが難しいですが、基本的な考え方として「共有データにアクセスする部分はロックを使って保護する」と覚えておくとよいでしょう。
スレッドの停止や制御をスマートに行う方法
スレッドを起動する方法はわかっても、「スレッドを止めたいときはどうすればいいの?」という疑問が湧くこともあります。
ここでは、スレッドの停止や制御の方法を簡単に見ていきましょう。
スレッドを強制的に停止する方法はない
Pythonの標準的なthreadingモジュールには、スレッドを外部から強制終了する仕組みが存在しません。
つまり、一度動き出したスレッドを「終了しろ!」と命令してすぐに止める手段はないのです。
強制的にスレッドを落とす方法があると、ロックが解放されなかったり、重要な処理が中断されたりして、プログラムの安定性が損なわれる恐れがあります。
そのため、基本的には「スレッドの中の関数を動かし、特定の条件が満たされたら自主的に終了する」という流れを作るのが一般的です。
イベントフラグによるスレッド停止の一例
threadingモジュールには、Event という便利なクラスがあります。
Eventは、フラグ(True/False)を立てたり下ろしたりすることで、スレッド間でシグナルをやり取りできる仕組みです。
これを使って、スレッドに「そろそろ止まってほしい」という合図を送るやり方があります。
import threading import time stop_event = threading.Event() def worker(): while not stop_event.is_set(): print("処理を実行中...") time.sleep(1) print("スレッドを終了します") thread_a = threading.Thread(target=worker) thread_a.start() # 5秒待ってから停止フラグを立てる time.sleep(5) stop_event.set() thread_a.join() print("メイン側の処理完了")
上記のコードでは、worker()
関数の中で stop_event.is_set()
を常に監視しています。
set()
が呼ばれるとフラグが立ち、 is_set()
がTrueを返すようになるため、ループを抜けて終了処理に向かう仕組みです。
この方法なら、乱暴にスレッドを殺すのではなく、スレッド側に「もう止まってほしい」と合図を送り、自主的に終了させられます。
こうすることで、スレッド内で必要な後処理をきちんと行い、データ競合を最小限に抑えながら安全に止められます。
スレッドの活用シーンと具体例
それでは、どのような場面でスレッドを使うと有効なのでしょうか。
実務で想定されるシーンを踏まえつつ、具体例を挙げてみます。
ネットワーク通信が発生する処理
たとえばWeb APIにアクセスしてデータを取得したり、外部のサーバーと通信を行ったりする処理では、レスポンスが返ってくるまでの待ち時間が生じます。
この待ち時間を有効に使うために、別のスレッドで通信処理を行い、その間にメインスレッドでは別の処理を進めることが考えられます。
ユーザーにとっては、操作が止まらずスムーズに感じられるケースが多いでしょう。
特にGUIアプリケーションであれば、メイン画面の描画やユーザー操作の受付を行いつつ、サブスレッドで通信を並行して行えば、アプリの動作が止まったように見えにくくなります。
ファイルの読み込みや書き込みが多い処理
大きなファイルを読み込んだり、外部ストレージに書き込んだりする作業も待ち時間が発生しやすいです。
複数のファイルを並行して処理したいときは、スレッドを活用すると効率が上がることがあります。
一方で、同じファイルに書き込みを行う場合は、先ほど説明したようにデータの競合を避けるための対策が必要です。
スレッドを使うときは、同じリソースを操作するスレッド同士の競合を常に意識しておきましょう。
GUIプログラムでの応答性確保
メインスレッドで時間のかかる処理を実行すると、ユーザーの画面操作がブロックされてアプリケーションがフリーズしているように見えてしまうことがあります。
これを避けるために、重い処理をサブスレッドに任せ、メインスレッドは画面の描画やユーザーイベントの処理を担当します。
こうした構成にすることで、ユーザーがアプリを操作している間も、遅延を感じにくいスムーズな体験を提供できるかもしれません。
スレッドデバッグのポイント
マルチスレッドを活用すると、思いもよらないタイミングで複数のスレッドが干渉し合い、バグが起こるリスクがあります。
どのようにデバッグやトラブルシューティングを進めればよいでしょうか。
ロギングを活用する
スレッドが同時に動くと、どちらが先に動作したか分かりにくい場面が多くなります。
そこで有効なのが、ロギングを使うことです。
print()
でもある程度確認できますが、Pythonの logging
モジュールでログレベルや出力フォーマットを設定すると、より細かく動作の流れを追跡できます。
以下のようにログを出力しておくと、時間順にどのスレッドが何をしたのか把握しやすくなります。
import threading import logging import time logging.basicConfig(level=logging.DEBUG, format='%(threadName)s: %(message)s') def worker(): logging.debug("開始") time.sleep(2) logging.debug("終了") thread_a = threading.Thread(target=worker, name="ThreadA") thread_b = threading.Thread(target=worker, name="ThreadB") thread_a.start() thread_b.start() thread_a.join() thread_b.join()
ロギングを活用すれば、スレッドがどのような順序で動いたのか簡単に可視化できます。
最小の再現コードを作る
複数のスレッドが錯綜する大規模なプログラムだと、バグの原因がどこなのか発見しにくいです。
怪しい箇所だけを切り出した最小限の再現コードを用意し、段階的にテストをする方法が有効です。
デバッグの段階では、余計な要素をそぎ落とし、疑わしい部分だけを集中して動かすのがおすすめです。
それでも原因が見えにくければ、さらに細かくログを仕込んで、どのタイミングでデータが予想外に変わっているかを調査するとよいでしょう。
マルチスレッドとマルチプロセスの違い
Pythonで並列処理をする方法としては、スレッドだけでなくマルチプロセスというアプローチもよく話題になります。
ここでは両者の違いを簡単に触れておきます。
プロセスとスレッドの違い
- プロセス:OS上で独立した動作をする単位。メモリ空間を基本的には共有しない
- スレッド:プロセス内部で動作する、実行の流れの単位。メモリ空間を共有している
マルチプロセスでは、各プロセスがほぼ独立した環境で動くため、GILの制約を回避しやすく、CPUをフルに使って並列実行できる可能性があります。
一方で、プロセス間の通信はやや複雑になりやすく、メモリの消費量も増える傾向があります。
スレッドのメリットは、メモリを共有するためデータのやり取りが比較的簡単であることです。
反面、共有データを慎重に扱わないと競合が起こるリスクがあります。
どちらを選ぶべきか
どちらを選ぶかは、処理の性質やシステム構成によって異なります。
以下のような指標を参考にしてみてください。
- I/O待ちが多い処理(ネットワーク、ファイル操作など):マルチスレッドでも効果を得やすい
- CPUをガッツリ使う計算処理(数値計算、画像処理など):マルチプロセスで並列化を狙うほうが実行速度の向上を期待しやすい
ただし、実装や運用のしやすさなども含めて総合的に判断する必要があります。
初心者のうちはスレッドから学んでみるのもよいかもしれません。
よくある質問とトラブル例
スレッドを使いはじめると、いくつかの疑問が浮かんだり、思わぬトラブルにぶつかったりすることがあります。
ここでは、ありがちなケースをいくつか紹介します。
同時に走らせるスレッド数に上限はあるの?
理論上は多数のスレッドを立ち上げることが可能ですが、あまりに多いと以下のような問題が起こりやすくなります。
- スレッドの切り替えが頻繁に起こり、逆にパフォーマンスが落ちる
- メモリ使用量やCPU負荷が大きくなり、システムが不安定になる
実際には数個から十数個のスレッドであれば問題ない場合が多いですが、100個、1000個と増やすような使い方は避けるのが無難です。
スレッドでGUIを操作すると止まってしまう
GUIフレームワークによっては、メインスレッド以外からUI要素を直接更新するとトラブルが起こりやすいことがあります。
UIスレッドと呼ばれるメインループを持つ仕組みでは、メインスレッド以外からUI操作を行わないというルールがよく設定されています。
こうしたフレームワークのルールを守りつつ、サブスレッドでデータを処理し、その結果をメインスレッドに渡してUIを更新する、という流れを考える必要があります。
スレッド内で例外が起きたらどうなる?
サブスレッド内で例外が発生すると、そのスレッドだけが終了してしまい、メインスレッドからはその様子が見えにくいことがあります。
join()
のタイミングで例外をキャッチできるわけではないので、必要ならサブスレッド内で適切に例外処理を行うか、ログを取るようにしておくとよいでしょう。
トラブルを未然に防ぐコツ
スレッド関連のバグは再現性が低く、一度ハマると抜け出しにくい印象があるかもしれません。
以下のようなポイントに気をつけるだけで、かなりトラブルを減らせることがあります。
スレッドの数を必要最小限にする
スレッド数が増えるほど、デバッグが難しくなるだけでなく、リソース競合が起こるリスクも高まります。
並行処理を行う目的が明確でない場合は、無闇にスレッドを立ち上げないほうが安全といえます。
スレッド安全なデータ構造を使う
threadingモジュールとは別に、Pythonの標準ライブラリにはqueue.Queueなどのスレッド安全なデータ構造が用意されています。
複数のスレッドが同じキューを介してデータをやり取りするよう設計すれば、煩雑なロックの管理をある程度減らすことができます。
タイムアウトを考慮する
スレッドがずっと待ち状態になってしまい、プログラムが進まなくなることがあります。
そうした事態を防ぐために、Lockの獲得やjoinにタイムアウトを設定できることを知っておくと役に立ちます。
# joinにタイムアウトを指定 thread_a.join(timeout=5.0) if thread_a.is_alive(): print("スレッドAが5秒以内に終了しなかった")
上記のようにタイムアウトを活用すると、スレッドが思わぬ理由で止まらなくなったときでも、プログラム全体としては次の処理に移りやすくなります。
まとめ
この記事では、Pythonのthreadingモジュールを使ったマルチスレッド処理について、初心者の皆さんでもイメージしやすいように解説してきました。
スレッドを活用する際に大切なポイントは、以下の通りです。
threading.Thread
を使ってスレッドを生成し、start()
で動かす- データ競合や同時書き込みを防ぐにはLockやEventなどの同期機能を活用する
- スレッドを使いすぎると管理が複雑になり、パフォーマンスが低下することもある
- スレッド間でやり取りするデータ構造や例外処理の管理を適切に行う
PythonではGILの影響でCPU集中的な処理の高速化は限定的かもしれませんが、I/Oの待ち時間を有効活用したり、ユーザーインターフェースを止めずに裏側で処理したりといった使い方では、大いに力を発揮できます。
マルチスレッドを試しに実装してみると、複数の処理が同時進行する仕組みが体感でき、プログラムの世界が少し広がるかもしれません。
とはいえ、競合やデバッグの難しさに悩むことも多いので、まずは小さなコードから試し、動きのイメージをつかむのがおすすめです。
Pythonのスレッドを理解することは、並行処理全般の概念を学ぶ第一歩になることがあります。
スレッド関連の設計を工夫して、より快適でわかりやすいプログラムを目指してみてはいかがでしょうか。