Python WebAcademy Blog

Pythonのthreadingとは?待ち時間の多い処理を同時に進める仕組みを初心者向けに解説

|

Pythonのthreadingを、並行処理という言葉に身構えてしまう方にもわかるようにやさしく解説します。待ち時間の正体、Threadとjoinの基本、ThreadPoolExecutorで短く書く方法、GILがあっても速くなる理由、複数スレッドが同じ変数を触ったときに起きる事故とLockでの防ぎ方、そしてmultiprocessingやasyncioとの使い分けまで。動かせるコードと比較表で整理します。

Pythonで書いたスクリプトの実行が終わるのを、じっと待っていた経験はありませんか。処理は進んでいるはずなのに、画面はしばらく何も言ってくれません。

たとえば10件のURLからデータを取ってくるスクリプトを考えてみてください。1件あたり1秒かかるとすると、素直に書けば全部で10秒ほどかかります。

ところが、この10秒の中身のほとんどは計算ではありません。相手のサーバーからの返事を、ただ待っているだけの時間です。

だとしたら、10件ぶんの待ち時間を重ねてしまえばいいわけです。それを叶えてくれるのが、標準ライブラリのthreadingです。

この記事では、threadingの考え方から書き方、そしてつまずきやすい落とし穴までを順番に見ていきます。追加インストールは要りません。

待ち時間ばかりのプログラムは、なぜ遅いのか

まず、遅さには種類があるという話から始めます。ここを分けておくと、あとの説明がすっと入ってきます。

ひとつは、CPUが忙しくて遅い場合です。巨大な数値計算や画像処理のように、機械がずっと計算し続けている状態を指します。

もうひとつは、CPUが暇なのに遅い場合です。通信の返事待ち、ファイルの読み書き待ち、データベースの応答待ちがこれにあたります。

後者では、プログラムは何もしていません。相手からの返事が来るまで、腕を組んで立っているだけです。

この待っている間に別の仕事へ手を伸ばせたら、全体の時間は縮みます。スレッドは、この待ち時間を重ねるための仕組みです

通信の待ち時間がどれくらい発生するのかは、実際にHTTPリクエストを書いてみると体感しやすくなります。【関連記事】Pythonのrequestsとは?WebからデータをとってくるHTTP通信の基本を初心者向けに解説

スレッドという言葉の意味をほどく

スレッドは、日本語にすると糸です。プログラムの中を流れる処理の流れを、1本の糸に見立てた呼び方だと思ってください。

ふつうのPythonプログラムには、この糸が1本だけあります。上から下へ、1行ずつ順番に流れていく糸です。

threadingを使うと、この糸を途中で増やせます。増やした糸はそれぞれ勝手に進むので、待っている糸の横で別の糸が仕事を進められるというわけです。

ここで大事なのは、糸を増やしても機械が増えるわけではないという点です。あくまで1台のコンピュータの中で、順番をやりくりしているにすぎません。

だから、糸を増やせば必ず速くなるとは限りません。この線引きは後ほど詳しく扱います。

いちばん小さなスレッドを動かしてみる

理屈より、動かしたほうが早いはずです。まずは2つの処理を同時に走らせる、最小限のコードを見てください。

import threading
import time


def work(name, seconds):
    print(f"{name} 開始")
    time.sleep(seconds)  # 通信やファイル待ちのつもり
    print(f"{name} 終了")


start = time.time()

t1 = threading.Thread(target=work, args=("A", 2))
t2 = threading.Thread(target=work, args=("B", 2))

t1.start()  # ここでAが動き出す
t2.start()  # Aを待たずにBも動き出す

t1.join()   # Aが終わるまで待つ
t2.join()   # Bが終わるまで待つ

print(f"合計 {time.time() - start:.1f} 秒")

このコードを実行すると、合計はおよそ2秒になります。順番に呼べば4秒かかる処理が、半分で済んだわけです。

書き方はいたって単純です。Threadに動かしたい関数をtargetで渡し、その関数への引数をargsにタプルで渡します。

そしてstart()を呼ぶと、その関数が別の糸として動き出します。呼び出した側は、終わるのを待たずに次の行へ進みます。

なおtime.sleepをわざわざ挟んでいるのは、通信待ちの代役です。待ち時間の測り方そのものが気になった方は、こちらもどうぞ。【関連記事】Pythonのtimeモジュールとは?sleepで待つ・処理時間を測る基本を初心者向けに解説

joinを書き忘れると何が起きるのか

初心者の方がいちばん最初につまずくのが、このjoin()です。書き忘れても、エラーは出ません。

公式ドキュメントによれば、join()は呼び出した側のスレッドを、対象のスレッドが終わるまでブロックします。つまり、終わるのを待つための待機命令です。

これを書かないと、メインの流れは先に進んでしまいます。結果として、まだ処理が終わっていないのに合計時間を表示してしまう、といったちぐはぐな動きになります。

スレッドを起動したら、必ずどこかでjoin()を呼ぶ。この習慣だけは、最初のうちから体に入れておくと安全です。

daemonスレッドという例外もある

もうひとつ、daemonという設定があります。これをTrueにしたスレッドは、少し扱いが変わります。

公式ドキュメントには、daemonスレッドだけが残った時点でPythonプログラム全体が終了する、と書かれています。daemonスレッドは、メインの処理が終われば道半ばでも打ち切られます

裏で定期的に何かを監視するような、途中で止まっても困らない処理に向いています。逆に、最後まで終わってほしい処理には使わないでください。

ThreadPoolExecutorなら、もっと短く書ける

スレッドを10本、20本と作りたくなったとき、Threadを並べるのは骨が折れます。そこで使いたいのがconcurrent.futuresのThreadPoolExecutorです。

これは、あらかじめ決めた本数のスレッドを使い回してくれる仕組みです。仕事を投げ込めば、空いているスレッドが順に拾ってくれます。

先ほどの処理を書き直すと、こうなります。

import time
from concurrent.futures import ThreadPoolExecutor


def fetch(n):
    time.sleep(1)  # 1件あたり1秒の通信待ちのつもり
    return f"item-{n}"


start = time.time()

with ThreadPoolExecutor(max_workers=5) as executor:
    results = list(executor.map(fetch, range(10)))

print(results[:3])
print(f"合計 {time.time() - start:.1f} 秒")

10件を5本のスレッドで分担するので、合計はおよそ2秒に収まります。withで囲んでいるため、全部終わるまで自動で待ってくれます。

max_workersを指定しない場合の初期値も決まっています。公式ドキュメントでは、Python 3.13以降はmin(32, (os.process_cpu_count() or 1) + 4)と説明されています。

数を増やせば増やすほど速くなるわけではない点には注意してください。相手のサーバーに負荷をかけますし、こちらのメモリも消費します。

GILがあるのに、なぜ速くなるのか

ここで、Pythonの並行処理を語るときに必ず出てくる話に触れます。GIL、グローバルインタプリタロックと呼ばれる仕組みです。

公式ドキュメントには、CPythonではGILがあるためPythonのコードを同時に実行できるスレッドは1つだけだと書かれています。同時に走っているように見えて、Pythonの処理そのものは1本ずつ順番なのです。

ではなぜ、さきほどのコードは速くなったのでしょうか。答えは、待っている間はGILを手放しているからです。

通信やファイルの読み書きで待ちに入るとき、そのスレッドはGILを他のスレッドへ譲ります。だから待ち時間だけがきれいに重なります。

裏を返せば、ずっと計算し続ける処理では速くなりません。むしろ切り替えのぶん、わずかに遅くなることさえあります。

GILそのものの成り立ちをもう少し知りたい方は、こちらの記事が土台になります。【関連記事】PythonのGIL(グローバルインタプリタロック)とは?仕組みを詳しく解説

Python 3.14で動き出した、GILを外す選択肢

この前提は、いま少しずつ変わりはじめています。Python 3.14では、GILを無効にできるfree-threadedビルドが正式にサポートされました。

これはPEP 779として提案され、2025年6月16日に受理されたものです。PEPの本文では、この段階を正式サポートではあるが依然として任意の選択肢だと位置づけています。

つまり、標準の配布物が明日からGILなしになるわけではありません。通常のビルドとは別に用意された選択肢という位置づけです。

学習の段階では、まず標準のビルドで考え方を身につけるのが近道です。この記事の内容も、そのまま土台として使えます。

複数のスレッドが同じ変数を触ると壊れる

ここからは、スレッドを使うと必ず向き合うことになる問題です。複数の糸が同じデータを書き換えると、値が壊れます。

たとえばカウンタを1ずつ増やす処理を、2本のスレッドで同時に走らせたとします。読んで、足して、書き戻すという3つの動作の途中で切り替わると、片方の更新が消えてしまいます。

実際に起こしてみると、こうなります。

import threading

counter = 0
lock = threading.Lock()


def add_one(value):
    return value + 1


def count_up_unsafe():
    global counter
    for _ in range(200_000):
        counter = add_one(counter)   # 読んで、足して、書き戻す


def count_up_safe():
    global counter
    for _ in range(200_000):
        with lock:                   # ここは1本ずつしか通れない
            counter = add_one(counter)

count_up_unsafeを2本のスレッドで動かすと、期待値の400000より小さい数が出ることがあります。手元で4回試したところ、383066と314775になった回があり、残りの2回はぴたりと400000でした。

これが競合状態と呼ばれる現象です。厄介なのは、いつも起きるとは限らない点にあります。

一方のcount_up_safeは、何度動かしても400000から動きません。手元では動いたのに本番でだけ数字が合わない、という不具合の多くはこの差から生まれます。

Lockで順番を作る

この事故を防ぐのがLockです。鍵のかかった小部屋を用意し、そこには1本のスレッドしか入れないようにします。

公式ドキュメントによると、acquireとreleaseを持つオブジェクトはwith文のコンテキストマネージャとして使えます。だからwith lock:と書くだけで、入るときに鍵をかけ、抜けるときに開ける処理が自動で行われます。

共有している変数を複数のスレッドが書き換えるときは、必ずLockで囲む。これは例外のないルールだと考えて構いません。

ただし、囲む範囲は最小限にしてください。広く囲みすぎると1本ずつしか通れない区間が伸び、せっかくの並行処理が台無しになります。

threadingとmultiprocessingとasyncioの使い分け

Pythonには、同時に処理を進める道具が3つあります。名前が似ていて混乱しやすいので、性質を並べて整理します。

道具 向いている処理 増えるもの 書きやすさ
threading 通信やファイルの待ちが多い処理 スレッド(同じメモリを共有) 既存の関数をそのまま渡せる
multiprocessing 計算そのものが重い処理 プロセス(メモリは別々) データの受け渡しに一手間かかる
asyncio 大量の通信を同時にさばく処理 イベントループ上のタスク async/awaitへの書き換えが必要

選び方はおおむね決まっています。待ち時間の多い処理はthreading、計算の重い処理はmultiprocessing、と覚えておけば大きく外しません。

数千件の通信をさばくような規模になると、asyncioのほうが軽く動きます。スレッドは1本ごとにメモリを消費するため、数を増やしすぎると頭打ちになるからです。

それぞれの詳しい中身は、別の記事にまとめてあります。計算を分散させたい場合はPythonのマルチプロセシングとマルチスレッドの違いとは?を、非同期の書き方を知りたい場合はPythonのasyncioとは?async/awaitで待ち時間を減らす仕組みを初心者向けに解説をご覧ください。

実務で気をつけていること

私は10年ほどエンジニアとして開発に関わってきましたが、スレッド絡みの不具合は再現が難しい部類の代表格です。ログを仕込んだ途端に再現しなくなる、という経験も何度かしました。

いちばん痛かったのは、共有の辞書へ複数スレッドから書き込んでいたバッチ処理です。月に1回だけ件数が合わないという症状で、原因にたどり着くまで数週間かかりました。

だからいまは、順番を工夫しています。各スレッドには結果を返させるだけにして、集計はメインの流れで1本にまとめるという書き方です。

この形にすると、共有して書き換える変数がそもそも生まれません。ThreadPoolExecutormapが返す結果をまとめて受け取る書き方は、まさにこれにあたります。

そしてもうひとつ。スレッドを使う前に、本当に同時に動かす必要があるのかを疑うようにしています。

3秒が1秒になっても、体感は大きく変わらないことがあります。速さと引き換えに、調査しづらいバグを抱え込む価値があるかどうかは、毎回考える価値がある問いです。

まとめ

threadingは、待ち時間を重ねるための道具です。通信やファイルの読み書きのように、返事を待っている時間が長い処理でこそ効きます。

書き方の骨格はThreadstart()join()の3つで、本数が増えるならThreadPoolExecutorに任せると読みやすくなります。GILがあるため計算そのものは速くなりませんが、待ち時間には効くという線引きを覚えておいてください。

そして共有データを書き換えるときはLockで守る。この一点さえ守れば、危ない使い方の大半は避けられます。

まずは手元のスクリプトで、待ち時間の多い部分を探すところから始めてみてはいかがでしょうか。ここまでお読みいただきありがとうございました。

参考情報

次のアクション

記事で学んだ内容を実際に動かしてみよう

Python WebAcademyでは、ブラウザ上でコードを書きながら基礎から実践まで体系的に学べます。

Python WebAcademyの学習画面

あわせて読む

関連記事

ブログ一覧へ

Python学習ロードマップ

まずはこの3講座から

記事で気になったテーマを、順番に手を動かしながら学べます。

ロードマップを見る