【Python】並列処理を初心者向けに徹底解説!スレッドから非同期処理まで具体例つきで紹介

はじめに

Pythonでプログラミングを始めてみると、同時に複数の作業を効率よく進める方法に興味を持つ方が多いでしょう。
ファイルの読み書きをしながら別の処理を同時に走らせたい、あるいはウェブ上のデータ取得と計算処理を並行して動かしたい、といったニーズが考えられます。

こうしたときには並列処理の仕組みを使うことで、動作をサクサクと感じられるプログラムを作ることができます。
ただし、Pythonにはグローバルインタプリタロック(GIL)と呼ばれる仕組みが存在し、一筋縄ではいかない部分もあるのが実情です。

このようなPython特有の仕様をしっかり理解し、正しい方法で並列処理を行うことで、作業を効率的にこなせるプログラム開発につなげられます。
ここでは、Pythonの並列処理の基礎から代表的な手法とコード例を紹介していきます。

この記事を読むとわかること

  • 並列処理並行処理の違い
  • Pythonにおけるスレッドとプロセスの仕組み
  • threading モジュールの使い方
  • multiprocessing モジュールの活用例
  • asyncio を使った非同期処理の方法
  • 実務で活用するときの具体的なシーンと注意点

Pythonの並列処理とは?

Pythonの並列処理は、複数の処理を同時またはほぼ同時に実行しているように見せるためのテクニックです。
大きく分けると以下の3パターンがあります。

  • スレッドを使った並列処理
  • プロセスを使った並列処理
  • 非同期処理 (async/await)

これらを使い分けることで、さまざまな状況に対応できます。たとえば、ファイル入出力やネットワーク通信などの待ち時間が大きいタスクがある場面ではスレッドや非同期I/Oが有効です。
一方で、大量の計算を行うCPU負荷の高いタスクであれば、マルチプロセスでコアをうまく活用する方法が重宝されます。

並列処理と並行処理の違い

一般的には、並列処理(Parallelism)と並行処理(Concurrency)は混同されがちです。
ただし、ざっくり言うと下記のようなイメージで捉えておくとわかりやすいでしょう。

  • 並列処理 (Parallelism): 複数のコアやCPUを使い、物理的に同時に処理を行う
  • 並行処理 (Concurrency): 単一コアでタスクを切り替えしながら見かけ上同時に動かす

Pythonでは、GILの存在により単一プロセス・マルチスレッドの形では、厳密な意味でCPUコアを同時に使う本当の「並列」はできません。
一方、マルチプロセスを使えばGILの制限を回避し、複数コアを活用できます。

Pythonで並列処理が求められる場面

プログラムを作っていて「並列処理が必要だな」と感じる場面は多々あります。
どんな場面が想定されるのか、初心者の方にもイメージしやすいように例を挙げてみます。

  • ネットワーク通信やファイル読み込みなど、処理待ち時間が長いタスクを複数同時に実行したい
  • 大量のデータを扱う計算処理を、複数コアで効率よく分散したい
  • ユーザーからのリクエストを並行して処理したいウェブアプリケーションを作りたい

実務でも、APIサーバーの開発や、データ処理パイプライン、画像・動画解析などの分野で並列処理は大活躍します。
ただ、一歩進んだ使い方をするには知識も必要になります。

PythonのGIL(グローバルインタプリタロック)とは?

Pythonで並列処理を検討するうえで GIL (Global Interpreter Lock)という仕組みを外せません。
GILは同時に複数のPythonバイトコードを実行できないようにするものです。

GILは、メモリアクセスやオブジェクト管理を簡素化するためのしくみですが、CPUをフル活用した並列処理の実現が難しくなるというデメリットがあります。

マルチスレッドで実行していても、実際には一度にひとつのスレッドしか動けない仕組みです。
そのため、CPU集約的な処理を高速化しようとスレッドを増やしても、思ったほど性能が向上しないことがあります。

一方、待ち時間が多いタスク(ネットワークやファイルI/Oなど)であれば、GILがあってもスレッドを使うメリットが大きいです。
待ち時間の間に別のスレッドが動くため、実行体感が向上しやすいからです。

スレッドを使った並列処理(threading

スレッドの特徴

Pythonでスレッドを扱うときは、標準ライブラリの**threading** モジュールを使うのが一般的です。
スレッドは同じプロセス空間を共有するため、データのやり取りが容易というメリットがあります。

一方で、スレッド同士が同じ変数やリソースを操作するので、 競合 (レースコンディション)が発生しないよう注意しなければなりません。
データを正しく保護するために、ロックやセマフォといった仕組みを使うことが多くなります。

スレッドの基本的なコード例

次の例では、複数のスレッドを立ち上げて同時に処理を実行するイメージを示します。
実際には、GILの影響で厳密にはCPUがひとつのスレッドしか実行できませんが、I/O操作のように待ち時間が生じる作業が多い場合には、体感的にも効率が上がります。

import threading
import time

def worker(task_id):
    print(f"タスク {task_id} を開始しました")
    time.sleep(2)
    print(f"タスク {task_id} が終了しました")

def main():
    threads = []
    for i in range(5):
        t = threading.Thread(target=worker, args=(i,))
        threads.append(t)
        t.start()
    
    for t in threads:
        t.join()
    print("全てのタスクが完了しました")

if __name__ == "__main__":
    main()
  • threading.Thread(...) でスレッドを生成
  • start() でスレッドの実行開始
  • 最後に join() で全スレッドの終了を待ち合わせ

上のコードでは、5つのタスクをほぼ同時に動かしているように見えるでしょう。
実際には一つのCPUコア上で切り替えが起こっているので、同時に進んでいるかのように感じられます。

スレッドを使う上での注意点

スレッドを使うときはデータ競合に注意する必要があります。
たとえば共有する変数を同時に変更すると、思わぬバグにつながることがあります。

Pythonでは threading.Lockthreading.RLock、あるいは threading.Semaphore などを使って、クリティカルセクションを保護するのが一般的です。
ただ、ロックを多用するとプログラムの動きが複雑になり、デッドロックやパフォーマンス低下を招く恐れがあります。

そのため、スレッドを使うなら「共有データを最小限にする」「ロックの範囲をできるだけ小さくする」といった方針を守ると、混乱を避けやすいでしょう。

マルチプロセスを使った並列処理(multiprocessing

マルチプロセスの特徴

multiprocessing モジュールを使うと、スレッドではなくプロセスを増やすことで並列処理を行います。
プロセスごとにPythonインタプリタが独立して動くため、GILの制限を受けません

CPUコアが複数ある環境であれば、同時に複数のプロセスを実行することで、物理的に並列で処理を進められます。
ただし、プロセス間でデータを共有するためにはシリアライズ(ピクル化)などの仕組みが必要であり、やり取りに手間がかかるのがデメリットです。

基本的な使用例

次の例では、multiprocessing.Process を使って複数プロセスを起動し、それぞれで計算処理を行います。

import multiprocessing
import time

def heavy_computation(task_id):
    print(f"プロセス {task_id} を開始します")
    total = 0
    for i in range(10_000_000):
        total += i
    time.sleep(1)
    print(f"プロセス {task_id} が終了しました。合計値: {total}")

def main():
    processes = []
    for i in range(4):
        p = multiprocessing.Process(target=heavy_computation, args=(i,))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()
    print("全てのプロセスが完了しました")

if __name__ == "__main__":
    main()
  • multiprocessing.Process(target=..., args=...) で独立したPythonプロセスを生成
  • プロセス間ではメモリ空間が分離されるので、各プロセスのデータは独立
  • CPUコアを最大限使いたい場合にはマルチプロセスが有効

この例ではループ計算を大きく回しており、CPU負荷が高い処理を行っています。
マルチスレッドよりもマルチプロセスの方が高速化しやすいケースの代表例です。

プロセス間通信(IPC)の方法

プロセス同士でデータをやり取りしたい場合は、QueuePipe などを用います。
たとえば multiprocessing.Manager() を使えば、管理された共有リストや共有ディクショナリにアクセスできます。

一方で、データをプロセス間で受け渡しする際にはシリアライズやデシリアライズが行われるため、大きなデータを頻繁にやりとりするとかえって速度が落ちることもあります。
そのため、マルチプロセスを使うなら、できるだけ各プロセスを独立したタスク単位に分割し、なるべく小さなコミュニケーションですむように設計するのがポイントです。

非同期処理(asyncio

非同期I/Oのメリット

asyncio はPythonで非同期I/Oを扱うためのフレームワークです。
ネットワーク通信やファイル操作など、待ち時間が発生しやすい処理を効率よく並行実行したいときに活躍します。

スレッドを増やす方法とは違い、ひとつのイベントループの中でタスクを切り替えるため、オーバーヘッドが比較的小さいのが特長です。
複数の操作を同時進行で進めているように見せながら、実際にはあらゆる待ち時間をうまく活用して次の処理に切り替えます。

asyncioの基本的な書き方

asyncio では、async def で定義した関数(コルーチン)を await で呼び出し、イベントループ上で実行します。
次の例では、複数のネットワーク通信を擬似的に再現した関数を同時に動かすイメージを示します。

import asyncio
import random

async def fetch_data(task_id):
    print(f"タスク {task_id} のデータ取得を開始します")
    await asyncio.sleep(random.uniform(1, 3))  # ネットワーク処理の待ち時間をシミュレート
    print(f"タスク {task_id} のデータ取得が完了しました")

async def main():
    tasks = [fetch_data(i) for i in range(5)]
    await asyncio.gather(*tasks)  # 複数のコルーチンを並行実行
    print("すべてのデータ取得が完了しました")

if __name__ == "__main__":
    asyncio.run(main())
  • async def で定義した関数がコルーチンオブジェクトになる
  • await asyncio.sleep(...) などで待ち時間を明示し、その間は別タスクが動く
  • asyncio.gather(...) を使うと、複数コルーチンをまとめて並行実行

このコードでは「いくつものタスクが待ち時間をまたぎながら同時進行している」かのように見えます。
GILの制約はありますが、待ちの発生する処理を非同期化すれば、実行効率を上げられるわけです。

asyncioが得意な領域

asyncio は以下のような用途でよく使われます。

  • ウェブクローラー: 複数のサイトを並行してアクセス
  • ウェブソケット: リアルタイム通信でイベントを捌く
  • API呼び出し: 別のサーバーから大量にデータを取得するとき

このように、待ち時間が長い処理を多数束ねるような場面で真価を発揮します。
反対に、CPU負荷が高い計算を並列化して高速化したい場合は、マルチプロセスの方が相性がよいでしょう。

並列処理をより簡単にするconcurrent.futures

ThreadPoolExecutorとProcessPoolExecutor

Pythonの標準ライブラリには、concurrent.futures という仕組みが用意されています。
これは、スレッドプールとプロセスプールを簡単に扱えるエグゼキュータを提供します。

  • ThreadPoolExecutor: スレッドプールを使った並列タスク実行
  • ProcessPoolExecutor: プロセスプールを使った並列タスク実行

次の例は、ThreadPoolExecutorを使って複数のURLに並行してアクセスするイメージです。

import concurrent.futures
import requests

def fetch_url(url):
    res = requests.get(url)
    return url, len(res.content)

def main():
    urls = [
        "https://www.example.com",
        "https://www.python.org",
        "https://www.github.com",
        "https://www.pypa.io"
    ]

    with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
        future_to_url = {executor.submit(fetch_url, url): url for url in urls}

        for future in concurrent.futures.as_completed(future_to_url):
            url = future_to_url[future]
            data_url, size = future.result()
            print(f"{data_url} の取得完了。データサイズ: {size}")

if __name__ == "__main__":
    main()
  • executor.submit(関数, 引数...) で並行して実行できるタスクを渡す
  • concurrent.futures.as_completed(...) で実行完了した順に結果を取得
  • スレッドやプロセスを自前で生成・管理する手間が減る

ProcessPoolExecutor を使えばCPU集約的な処理を別プロセスで並列実行することもできます。
このように、concurrent.futures は「スレッドかプロセスか」という考え方を統一的なAPIで扱える点が便利です。

実務でよくある活用シーン

ウェブアプリケーションのバックエンド

ウェブアプリケーションのバックエンドでは、リクエストごとに一定量の処理を行い、レスポンスを返す必要があります。
スレッドや非同期処理を活用してリクエストを並行して捌くことで、応答の待ち時間を短縮しやすくなります。

一方、ヘビーな演算タスクが絡む場合にはマルチプロセスが選ばれることもあります。
たとえば画像変換や機械学習の推論を行う場面で、プロセスを分けて負荷分散する方法が検討されるでしょう。

バッチ処理やデータ分析

大きなCSVファイルをいくつも処理するときや、数百万行単位のデータを集計するときには、マルチプロセスの並列化が効果的です。
計算量の多いタスクでもCPUコアをフル活用しやすいので、処理が速く終わる傾向があります。

ただし、データ通信のオーバーヘッドやメモリ使用量の問題にも気をつける必要があります。
実際のデータ量やサーバー構成に応じて、最適な並列化手法を選ぶのが大切です。

ファイル処理やネットワーク通信

ファイル読み書きやネットワーク通信のようにI/O待ち時間が大きい処理をたくさん抱えている場合、スレッドやasyncioが有効です。
Pythonプログラムが一つのタスクで待ち状態に入っているあいだに、他のタスクを実行できるためです。

大量のファイルを一気に処理するのはよくあるシーンです。
スレッドを使って並列に読み込みを行い、読み込んだ内容を順次メモリに貯めてまとめて書き込む、といった方法をとることもあります。

パフォーマンス計測の基本

計測の前に考えること

並列処理を導入する前に、「本当に高速化が必要な部分はどこか?」を明確にしましょう。
下手に並列化すると、ロックやプロセス間通信などのコストが増え、かえって遅くなるケースもあります。

  • 対象タスクがI/O待ち中心なのか?
  • CPUをがっつり使う処理なのか?
  • どの程度の並列度が適切か?

これらを把握するためには、まずシングルスレッドでの実装でベースラインを計測することが大切です。
それから並列化したバージョンを比較し、「本当に高速化しているか」を検証すると良いでしょう。

簡単な計測の方法

time モジュールや timeit モジュールを使って処理時間を計測するのが一般的です。
例えば、以下のようにコードをまとめて実行した前後で time.perf_counter() の差を取れば、概算の処理時間を把握できます。

import time

start = time.perf_counter()

# 並列処理を含むプログラム本体を実行
my_parallel_function()

end = time.perf_counter()
print(f"処理時間: {end - start:.2f} 秒")

もっと大規模なシステムなら、ログを活用して各処理ステップの時間を記録し、外部ツールで分析する方法もあります。

よくある落とし穴

GILを理解していない

先ほども触れたように、PythonにはGILがあるため、「スレッドを増やせばCPUの使用率が上がって早くなる」というわけではありません。
特にCPU計算量の多い処理で、GILを意識しないままスレッドを増やすと、パフォーマンス向上が期待ほど得られず戸惑うことになります。

ロックまわりの複雑化

スレッドを使う場合、データ競合を防ぐためにロックやセマフォを使う場面が出てきます。
ロックの使い方が複雑になりすぎると、デッドロックやパフォーマンス低下を引き起こす可能性が高まります。

たとえば、複数のロックを順番に獲得するコードが複数箇所に散らばっている場合にデッドロックが発生しやすいです。
ロックを行う順序のルールを決める、あるいはミューテックスの利用を最小限に抑えるなどの工夫が必要になります。

プロセス間通信のオーバーヘッド

マルチプロセスで大量のデータを頻繁にやり取りする構成は、シリアライズやデシリアライズに時間がかかります。
結果として、シングルプロセス+スレッド版より遅くなる可能性もあるのです。

プロセスを使うときは、それぞれのプロセスが比較的独立して計算を完結できるような場面が向いています。
その設計を意識するだけで、プロセス間のやり取りを最低限に抑えられるでしょう。

非同期とマルチスレッドの混在

asyncio ベースのコードのなかに、さらにスレッドを使う処理を混ぜることも可能です。
ですが、その際はイベントループに戻ってくるまでの制御やスレッドとコルーチンが干渉し合わないように注意が必要です。

混在環境では、コードの見通しが急速に悪くなる可能性があります。
可能なら、非同期I/Oで統一するか、マルチプロセスで分けるか、といった設計方針をはっきり決めておくほうが管理しやすいでしょう。

並列処理を導入する前に抑えたいポイント

  • 処理の性質を見極める
    • I/O待ちが多いのか、CPU負荷が高いのかで、使うべき手法が変わる
  • ベースラインを測定する
    • シングルスレッドでどの程度の処理時間なのかを確認し、効果を比較
  • スレッドかプロセスか、非同期か
    • GILの影響とオーバーヘッドを考慮しながら選択
  • データアクセスを整理する
    • 共有データが最小限になるように設計し、競合を減らす

具体的な設計例

CPU負荷が高い画像処理を複数同時に行うケース

たとえば、大量の画像にフィルターをかけるアプリを作るとします。
1枚ずつシングルスレッドで処理すると時間がかかるため、並列化を検討したいです。

  • 各画像処理を独立したプロセスで実行する
  • 結果の画像を必要最低限の情報だけメインプロセスに返す
  • 画像を読み込んで処理し、出力ファイルに書き込むという流れを、各プロセスが完結する

このようにすると、GILの制限を回避しながら複数コアをフル活用できます。
プロセス間で大量の画像データをやり取りする必要がある場合は、ファイルパスや一部のメタ情報だけを受け渡すように工夫するとよいでしょう。

大量のREST APIを叩いてデータを取得するケース

外部サービスから大量のデータをまとめて取得する際は、ネットワークの待ち時間が大半を占めます。
CPUパワーはあまり使わないので、非同期処理やスレッドを活用するのが効果的です。

  • asyncio で同時に複数のURLリクエストを行う
  • 取得が終わったらまとめてファイルやデータベースに格納する
  • 待ち時間が発生している最中に別のリクエストをさばける

このようにネットワークI/Oを効率化できれば、全体の実行時間を短縮できる可能性があります。

ログの活用とデバッグ

並列処理のコードは、同時に複数の処理が走るため、デバッグが難しいと感じることが多いです。
そこで、ログを活用して、どのスレッド(あるいはプロセス)がどのタイミングで何をしているのかを記録する工夫が重要になります。

  • スレッドIDやプロセスIDをログ出力に含める
  • 重要なステップやエラー発生時にログを記録する
  • logging モジュールを使ってレベルごと(INFO, WARNING, ERRORなど)に制御する

ログを確認することで、並列化に起因する競合やデッドロックの発生箇所を素早く特定しやすくなるでしょう。

バグ回避のコツ

  • テストを小さく分割する
    • 各スレッド・プロセスが正しく動くか、独立してテストする
  • デバッグ用の制御フラグを用意しておく
    • 並列処理をオフにしてシリアル動作に切り替えられるオプションを準備し、不具合の切り分けに役立てる
  • ログを適度に挟む
    • 各タスクが開始した時刻、終了した時刻をログに出し、問題が起きたタイミングを把握しやすくする

まとめ

Pythonで並列処理を行う方法には、スレッド、プロセス、そして非同期処理の大きく3つの選択肢があります。

  • スレッド(threading
    • 同じメモリ空間を共有できる
    • I/O待ちが多いタスクに効果大
    • GILの制限でCPU負荷の高い処理の高速化には不向き
  • プロセス(multiprocessing
    • メモリ空間は分離されるがGILの制限を受けない
    • CPUを複数コアでフル活用できる
    • プロセス間通信のオーバーヘッドに注意
  • 非同期処理(asyncio
    • 待ち時間が発生しやすいI/Oタスクを効率的に処理
    • イベントループを使ったシンプルな制御
    • CPU集約型の処理には向かない

このような特徴を把握して、実際のアプリケーションに適した方法を選ぶのがポイントです。
ウェブアプリのようにリクエストを並列処理したい場面ではスレッドや非同期I/Oが力を発揮し、大きな計算負荷がある場面ではマルチプロセスが欠かせません。

さらに、GILの存在やロック、プロセス間通信などに関する基本的な仕組みを理解しておかないと、思わぬところで性能が出なかったりバグを生んだりする恐れがあります。
並列化を導入する際は、まず問題を正しく切り分けてシングルスレッド版との比較ログの活用を行い、段階的に導入するのが賢明です。

皆さんのPython開発において、並列処理を適切に活用することで、今よりもスムーズなプログラムを実現できるでしょう。
ぜひ本記事を参考に、自分のプロジェクトに合った方法を検討してみてください。

Pythonをマスターしよう

この記事で学んだPythonの知識をさらに伸ばしませんか?
Udemyには、現場ですぐ使えるスキルを身につけられる実践的な講座が揃っています。