🎅

マルチスレッドとマルチプロセスの違いを理解する

2024/12/04に公開

やること

プログラムの並列処理について理解する

前提

プログラムの並列処理にはマルチスレッドとマルチプロセスの2種類あります。マルチスレッドは同じプロセス内で動作し、同じメモリ空間を共有するため、スレッド間でのデータ共有を効率的に行うことができます。一方、マルチプロセスはプロセスごとに独立したメモリ空間を持ち、プロセス間通信を行うことができます。

サンタさんに例えてみる

抽象的な説明だとよくわからないので、サンタクロースがトナカイと一緒にプレゼントを配る例で考えてみましょう。その場合、トナカイがスレッド、サンタがプレゼントを配るタスクがプロセス、そりがメモリ空間、プレゼントがメモリということになります。これらの状況を画像で理解してみましょう。

左側がマルチスレッド、右側がマルチプロセスを表しています。マルチスレッドでは一人のサンタさんがトナカイたち(スレッド)を使ってプレゼント(メモリ)を配っているということになります。トナカイたち(スレッド)は、同じそりからプレゼントを取り出して、並行して複数の家に配ります。(この図だとトナカイとそりはひもでつながってることは目をつむってください)このようにして、サンタさんは一人であっても、トナカイたちを利用して効率よく各家にプレゼントを配ることができます。
 一方、マルチプロセスでは複数のサンタさんが独自のそりでプレゼントを配ります。この場合、複数のサンタさんそれぞれが独自のそり(メモリ空間)を持っています。各サンタさんは、他のサンタさんとは完全に独立したそり(メモリ空間)にプレゼント(メモリ)を積んでおり、それぞれの家にプレゼントを配ります。

結局何が違うのか

端的に言えばマルチスレッドはメモリを共有しているのに対し、マルチプロセスはプロセスごとにメモリを管理しているのが主な違いですね。ただ、それだけだと表層的な理解にとどまっていると言わざるをえません。具体例を挙げてもう1歩深く掘り下げてみましょう。

マルチスレッド

マルチスレッドは、同一プロセス内で複数のタスクを並行して実行する場合に適しています。先ほどの図でいうと、一人のサンタさんが複数のトナカイにタスクをやらせているといったイメージです。特に、I/Oバウンドなタスク(ファイルの読み書き、ネットワーク通信など)に効果的です。
例えば、複数のWebページを並行して取得する場合、マルチスレッドが有効です。

import threading
import requests

urls = [
    'https://yahoo.co.jp',
    'https://weathernews.jp/',
    'https://www.smartnews.com/ja',
]

def fetch_url(url):
    response = requests.get(url)
    print(f'{url}: {response.status_code}')

threads = []

# 各URLに対してスレッドを作成してスタート
for url in urls:
    thread = threading.Thread(target=fetch_url, args=(url,))
    thread.start()
    threads.append(thread)

# 全てのスレッドが終了するのを待つ
for thread in threads:
    thread.join()
https://weathernews.jp/: 200
https://www.smartnews.com/ja: 200
https://yahoo.co.jp: 200

マルチプロセス

一方、マルチプロセスは、CPUバウンドなタスクに向いています。PythonにはGlobal Interpreter Lock (GIL)という仕組みがあり、マルチスレッドが同時に実行できないケースがあるようです。multiprocessingモジュールを使ったマルチプロセスでは、各プロセスが独立したPythonインタプリタを持つため、このGILの制約を回避することができます。
例えば、計算量の多い数値処理を並列化して効率を上げる場合、マルチプロセスが適しています。
試しにマルチプロセスを用いない場合と用いた場合で比較してみましょう。

<multiprocessingを用いない場合>

import time

def compute_power(number):
    start_time = time.time()  # 開始時間を記録
    result = number ** 10000000
    end_time = time.time()  # 終了時間を記録
    duration = end_time - start_time
    print(f'{number}^10000000の計算が完了しました, 実行時間: {duration:.4f}秒')

if __name__ == '__main__':
    numbers = [2, 3, 5, 7]

    total_start_time = time.time()  # 総開始時間を記録

    # 各数値に対して順次計算を実行
    for number in numbers:
        compute_power(number)

    total_end_time = time.time()  # 総終了時間を記録
    total_duration = total_end_time - total_start_time
    print(f'総実行時間: {total_duration:.4f}秒')
2^10000000の計算が完了しました, 実行時間: 0.0360秒
3^10000000の計算が完了しました, 実行時間: 2.9670秒
5^10000000の計算が完了しました, 実行時間: 5.0488秒
7^10000000の計算が完了しました, 実行時間: 7.0520秒
総実行時間: 15.1064秒

こちらはリストの数字を逐次的に計算し、それぞれの時間を積算していくので15秒かかりました。

<multiprocessingを用いた場合>

import multiprocessing
import time

def compute_power(number):
    start_time = time.time()  # 開始時間を記録
    result = number ** 10000000
    end_time = time.time()  # 終了時間を記録
    duration = end_time - start_time
    print(f'{number}^10000000の計算が完了しました, 実行時間: {duration:.4f}秒')

if __name__ == '__main__':
    numbers = [2, 3, 5, 7]

    processes = []

    total_start_time = time.time()  # 総開始時間を記録

    # 各数値に対してプロセスを作成してスタート
    for number in numbers:
        process = multiprocessing.Process(target=compute_power, args=(number,))
        process.start()
        processes.append(process)

    # 全てのプロセスが終了するのを待つ
    for process in processes:
        process.join()

    total_end_time = time.time()  # 総終了時間を記録
    total_duration = total_end_time - total_start_time
    print(f'総実行時間: {total_duration:.4f}秒')
2^10000000の計算が完了しました, 実行時間: 0.0442秒
3^10000000の計算が完了しました, 実行時間: 3.2246秒
5^10000000の計算が完了しました, 実行時間: 5.3899秒
7^10000000の計算が完了しました, 実行時間: 7.4410秒
総実行時間: 7.6149秒

こちらは計算を同時並行で走らせるので、トータルの計算時間が約半分になりました。

コメントなど

今回とあるソースコードを読んでいて並列処理を学ぶ機会があったため記事にしてみました。他の人に訊かれたときにぱっと説明できるとかっこいいなと思いました。

ヘッドウォータース

Discussion