【Python】スレッドの基礎から活用方法までをわかりやすく解説
はじめに
Pythonでは、同時に複数の作業を実行したい場面でスレッドという仕組みがよく使われます。
たとえば、ウェブサイトからデータを取得しつつ別の処理を続けたい場合などにスレッドが便利です。
特に、作業の待ち時間が長い処理を並行して行うと、全体の作業時間を短くできることがあります。
しかし、スレッドを使うときには注意点も存在します。
並行に処理が進むので、変数の書き換えが競合するリスクがあるからです。
この記事では、初めてスレッドを学ぶ皆さんが仕組みと活用方法を理解できるよう、基本的な概念から実務での応用方法までを段階的に説明していきます。
この記事を読むとわかること
- Pythonスレッドとは何か
- スレッドを実務でどのように活用できるのか
- スレッドを作成・制御するための具体的なコード例
- スレッド同士の競合を防ぐための方法と注意点
- マルチスレッドとマルチプロセスの違い
ここでは、難しい専門用語をなるべく使わずに説明します。
コード例をいくつか提示していきますが、初心者の方にも読みやすいような例に限定しています。
Pythonスレッドとは何か
Pythonスレッドとは、ひとつのPythonプログラム内で同時並行的に動く複数の“作業の流れ”を指します。
プログラムをスレッドに分割すれば、同じ時間帯に複数の処理が進むように見えるのが特徴です。
普段、Pythonではひとつのプログラムが順番に命令を実行しますが、スレッドを使うと複数の命令が切り替わりながら進むようになります。
スレッドの基本概念
スレッドを「軽量なプロセス」と呼ぶこともあります。
ひとつのOSプロセスの中で、複数のスレッドがメモリ空間を共有しながら実行されるイメージです。
たとえば、Webアプリケーションのバックエンドで、同時に複数のユーザーからリクエストを受け取るときなどに役立ちます。
スレッドを使うと待ち時間の多い処理があっても、他のスレッドが同時に動くため、ユーザー全体の待ち時間を減らしやすくなります。
実務におけるスレッド活用シーン
Pythonスレッドが実務で活用されるシーンを、初心者の方でもイメージしやすいように挙げてみます。
チャットやメッセージ受信
ユーザーからのメッセージが届くのを待ちつつ、バックグラウンドでログ取得や解析を進めるとき
ファイルの読み書き
大きなファイルを読み込んだり、ネットワーク越しにデータをダウンロードしながら、他の操作を処理したいとき
複数の外部API呼び出し
いくつかのサーバーAPIを呼び出して結果を取得する必要がある場合、それぞれのレスポンス待ち時間を並行させたいとき
いずれも、何かの待ち時間がある処理が複数発生するなら、スレッド化すると便利なことが多いです。
Pythonでスレッドを作成する方法
Pythonでは、標準ライブラリのthreading
モジュールを使うことで簡単にスレッドを作成できます。
スレッドを使う最も基本的な方法は、Thread
クラスを利用してコードを並行実行させる手順を定義することです。
Threadクラスを使った基本の書き方
Pythonのthreading
モジュール内にあるThread
クラスをインスタンス化し、そのstart()
メソッドを呼び出すとスレッドの実行が始まります。
スレッドに実行させたい処理は、ターゲット(target
)となる関数を用意しておき、その関数をThread
クラスに渡します。
下記のような基本構文で、スレッドを2つ作成し同時に開始できます。
import threading import time def worker1(): print("worker1がスタートしました") time.sleep(2) print("worker1が終了しました") def worker2(): print("worker2がスタートしました") time.sleep(1) print("worker2が終了しました") # スレッドを作成 thread1 = threading.Thread(target=worker1) thread2 = threading.Thread(target=worker2) # スレッドを開始 thread1.start() thread2.start() # メインの処理がここにある場合でも、並行してworker1とworker2が動きます print("メインスレッドも進行中")
上記のコードでは、worker1()
とworker2()
が同時並行で動きます。
print()
の出力順は毎回同じになるわけではなく、実行タイミングによって変わる場合があります。
このように、スレッドを使うことで複数の処理を同時進行できるのが大きな利点です。
スレッドの終了を待機する方法
並行処理が終わらないうちにメインスレッドが終了すると、全体の処理が終わったかどうかがわかりにくいケースがあります。
そこで、特定のスレッドの処理が終わるまで待ちたい場合には、join()
メソッドを使います。
thread1.join() thread2.join() print("全てのワーカーが終了しました")
こうすることで、thread1
とthread2
の実行が完了するまで、メインスレッドは先に進みません。
ファイル処理や外部APIの呼び出しなど、最後にすべての結果を集約したいときに便利です。
複数スレッド間の並行処理
スレッドは、同じプログラム空間の中でデータを共有します。
複数のスレッドが同じ変数を読み書きすることもあるので、変数の上書きタイミングで競合が起きる可能性があります。
これをレースコンディションと呼ぶことがあります。
ロックと同期
レースコンディションを回避するために、threading
モジュールにはロック(Lock
オブジェクト)が用意されています。
ロックを獲得しているスレッド以外は、そのロックが守る範囲のコードに入れないようにする仕組みです。
次の例では、共有変数count
を複数のスレッドが変更するので、ロックで保護しています。
import threading count = 0 lock = threading.Lock() def increment(): global count for _ in range(100000): lock.acquire() try: count += 1 finally: lock.release() threads = [] for _ in range(5): t = threading.Thread(target=increment) threads.append(t) t.start() for t in threads: t.join() print("最終的なcountの値:", count)
ここでロックを使わなかった場合、内部的に変数count
が正しく更新されず、期待通りの数値にならないことがあります。
ロックは1箇所だけでなく、必要な箇所で適切に使わないと予期せぬ不具合が起きる可能性があるため注意が必要です。
複数のスレッドが同時に同じデータにアクセスする場合、ロックの使い方を誤るとプログラムが停止したり、正しく動かないことがあります。 必要な範囲でのみロックを取得するなど、使い所を明確にしておくことが大切です。
Pythonスレッドの注意点
スレッドを使うなら、Python特有の仕組みである GIL (Global Interpreter Lock)にも知っておく必要があります。
GIL (Global Interpreter Lock)
PythonのGILとは、同時に動くスレッドがあっても、内部的にはある一定のタイミングでのみPythonインタプリタが操作できるようにするロック機構です。
この結果、CPUをフルに使ったような計算(数値計算など)ではスレッドを増やしても処理速度が思ったほど速くならないケースがよくあります。
一方で、ネットワーク通信やファイルI/Oのように待ち時間が長い処理では、スレッドを使うメリットが大きいです。
CPUバウンドとIOバウンド
上記のGILに関連して、CPUバウンドとIOバウンドという用語を理解しておくと便利です。
CPUバウンド
CPUの計算処理がメインとなるタスクです。画像処理や科学技術計算などの負荷が高い計算タスクが該当します。GILの影響で、スレッドを増やしても実行速度があまり向上しないことがあります。
IOバウンド
ネットワーク通信やファイル読み書きなど、CPUというより外部リソースの待ち時間が大きいタスクです。スレッドを増やせば、その待ち時間を隠蔽しながら他のスレッドを動かせるため、全体として作業効率が上がる可能性があります。
このように、PythonスレッドはIOバウンドのタスクと相性が良いことを押さえておくと実務にも活かしやすいです。
マルチスレッドとマルチプロセスの違い
スレッド以外にも、Pythonにはマルチプロセスで並行処理を行う手段があります。
たとえば、multiprocessing
モジュールを使えば、CPUバウンドなタスクを並行に実行しやすくなるケースがあります。
違いと使い分け
マルチスレッド
同じプロセス内で動き、メモリ空間を共有する。IOバウンド処理で多用される。
マルチプロセス
プロセスごとに独立したメモリ空間を持つため、GILの制約から比較的自由になる。ただし、プロセス間でデータをやりとりする仕組みがやや複雑になりがち。
実務においては、「CPU負荷の高い計算はマルチプロセス」「ネットワーク待ちやファイルIOが多い場合はマルチスレッド」といった使い分けが意識されることが多いです。
ワーカースレッドを活用する
複数のタスクを効率よく割り振る場合、ThreadPoolExecutor
(concurrent.futures
モジュール内)を使う方法があります。
これは事前に用意したスレッドプールでタスクを管理し、必要に応じてスレッドを再利用してくれる仕組みです。
ThreadPoolExecutorのサンプル
下記の例では、複数の処理を並行に行い、結果をまとめて取得します。
import concurrent.futures import time def task(n): time.sleep(1) return n * 2 with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: futures = [executor.submit(task, i) for i in range(10)] results = [f.result() for f in futures] print(results)
この方法ではスレッドを自分で生成・破棄する手間を減らし、複数のタスクを同時に実行したいときに便利です。
IOバウンドな作業を一括で処理するときなどに、多くの実行時間短縮が期待できます。
Pythonスレッドをデバッグするコツ
スレッドプログラムは、複数の処理が同時に動くので、慣れていないとバグの原因がつかみにくいことがあります。
ログの出力を工夫する
スレッドごとにメッセージに識別子を入れ、動作の流れを追いやすくする
ロックの取得範囲を厳密にする
ロックをかけっぱなしにすると、ほかのスレッドが進まなくなり、思わぬ停止につながることがあります
小さな単位でテストする
まずはスレッド1本、2本を使ったテストを細かく行い、徐々に拡張していく
これらを意識すると、スレッドを使ったコードの問題点を早期に発見しやすくなります。
スレッドの競合バグは小さなコード例でも見つかりにくいことがあります。少しずつテストを積み重ねながら本番環境に適用していくのが安全です。
まとめ
今回はPythonスレッドの基本から実務に活かすための活用例まで、段階的に紹介してきました。
スレッドを使うと、待ち時間が発生する処理を並行して走らせることができ、効率が上がる場面が多々あります。
一方で、変数の競合を防ぐためにロックが必要だったり、PythonのGILによる制限を意識したりなど、知っておくべき注意点もあるのが現実です。
これらを踏まえて、次回からは皆さんが作っているプログラムの中でどこに待ち時間が潜んでいるか、どのような単位でスレッドを分割すればメリットがあるかを考えてみてください。
IOバウンドが多い処理であれば、スレッドを賢く利用することで全体の作業をスムーズに進められるはずです。
マルチプロセスとの使い分けも意識しながら、開発に役立ててみてください。