Pythonの並行処理

コンピュータは時代とともに進化し、より高速に動作させるための方法がどんどん出てきています。一度に1つの命令を実行するのではなく、同時に複数の命令を実行することができたらどうでしょうか。そうすれば、システムの性能は大幅に向上することになる。

並行処理によって、Pythonプログラムは一度に多くのリクエストを処理できるようになり、時間の経過とともに目覚ましい性能向上を実現できます。

この記事では、Pythonプログラミングの文脈における並行処理について、さまざまな形で提供されていることを説明し、実際にパフォーマンスの向上を確認するために、簡単なプログラムを高速化します。

並行処理とは?

2つ以上の事象が同時進行している場合、それは同時に起こっていることを意味します。実生活では、多くのことが常に同時に起こるので、同時並行性は一般的です。しかし、コンピューティングの世界では、同時並行性という点では少し異なります。

コンピュータでは、並行処理とは、コンピュータが仕事の断片やタスクを同時に実行することです。通常、コンピュータはある作業を実行しながら他の人が順番を待ち、完了するとリソースが解放され、次の作業が開始されます。しかし、同時並行処理が行われると、実行される作業片は他の作業片の完了を必ずしも待つ必要がないため、このようなことは起こりません。同時に実行されるのです。

並行処理と並列処理

同時並行とはタスクを同時に実行することだと定義しましたが、並列性と比べてどうなのでしょうか、また、並列性とは何なのでしょうか。

並列処理とは、計算処理の高速化を目的として、複数の計算や操作を同時または並列に行うことです。

並行処理も並列処理も複数の作業を同時に行うことですが、並行処理が1つのプロセッサだけで行われるのに対し、並列処理は複数のCPUを利用して作業を並列に行うことで実現されるのが特徴です。

スレッド vs プロセス vs タスク

一般的には、スレッド、プロセス、タスクは仕事の断片や単位を指すことがありますが、詳細にはあまり似ていません。しかし、詳細にはあまり似ていない。

スレッドはコンピュータ上で実行可能な最小の実行単位である。スレッドはプロセスの一部として存在し、通常は互いに独立していません。つまり、同じプロセス内の他のスレッドとデータやメモリを共有します。スレッドは、軽量プロセスとも呼ばれることがあります。

たとえば、文書処理アプリケーションでは、あるスレッドがテキストの書式設定を担当し、別のスレッドが自動保存を処理し、別のスレッドがスペルチェックを行うことができる。

プロセスは、実行可能なジョブまたは計算されたプログラムのインスタンスです。コードを書いて実行すると、コードを通じてコンピュータに指示したすべてのタスクを実行するためのプロセスが作成されます。プロセスは単一のプライマリースレッドを持つことも、その中に複数のスレッドを持つこともでき、それぞれが独自のスタック、レジスタ、プログラムカウンターを持つ。しかし、それらはすべてコード、データ、およびメモリを共有します。

プロセスとスレッドの一般的な違いには、次のようなものがあります。

  • プロセスは分離して動作しますが、スレッドは他のスレッドのデータにアクセスすることができます。
  • プロセス内のスレッドがブロックされた場合、他のスレッドは実行を継続することができますが、ブロックされたプロセスはキュー内の他のプロセスの実行を保留にします。
  • スレッドは他のスレッドとメモリを共有するが、プロセスはそうではなく、各プロセスは独自のメモリ割り当てを持っている。

タスクは、メモリにロードされるプログラム命令のセットです。

マルチスレッドとマルチプロセシングとAsyncioの比較

スレッドとプロセスについて説明した後、コンピュータが同時に処理を実行するさまざまな方法について掘り下げてみましょう。

マルチスレッドとは、CPU が複数のスレッドを同時に実行する能力を指します。この考え方は、1つのプロセスをさまざまなスレッドに分割し、並行して、あるいは同時に実行することです。このように分担することで、プロセス全体の実行速度が向上します。例えば、MS Wordのようなワープロでは、使用時に様々なことが進行しています。

マルチスレッドにより、プログラムは、書かれているコンテンツを自動保存し、コンテンツのスペルチェックを行い、またコンテンツのフォーマットを行うことができます。マルチスレッドにより、これらすべてが同時に行われ、ユーザーは保存やスペルチェックが行われる前に文書を完成させる必要がありません。

マルチスレッド処理では、1つのプロセッサだけが関与し、オペレーティングシステムが現在のプロセッサのタスクをいつ切り替えるかを決定します。

一方、マルチプロセシングは、並列処理を実現するためにコンピュータ上の2つ以上のプロセッサユニットを利用するものです。Pythonは、異なるプログラムに対して異なるプロセスを作成し、それぞれが実行するPythonインタプリタのインスタンスと実行中に利用するメモリ割り当てを持つことによって、マルチプロセッシングを実装しています。

AsyncIOまたは非同期IOは、Python 3で導入された新しいパラダイムで、async/await構文を使用して同時実行コードを記述することを目的としています。IOバウンドや高レベルのネットワーキングを目的とした場合に最適です。

並行処理を使用する場合

並行処理の利点は、CPUに制約のある問題やIOに制約のある問題を解決するときに、最もよく活用されます。

CPU バウンド問題とは、ネットワークやストレージ設備を必要とせず、CPU の能力によってのみ制限される、多くの計算を行うプログラムのことです。

IOバウンド問題は、入出力リソースに依存するプログラムで、CPUより遅い場合もあり、通常使用中であるため、現在のタスクがI/Oリソースを解放するまで待つ必要があります。

CPUやI/Oのリソースが限られていて、プログラムを高速化したい場合は、コンカレントコードを書くのがベストです。

並行処理の使い方

このデモ例では、ネットワーク経由でファイルをダウンロードするという、一般的なI/O境界問題を解決します。非同期コードと並行コードを書き、それぞれのプログラムが完了するまでにかかる時間を比較します。

ImgurのAPIを通じて画像をダウンロードします。まず、アカウントを作成し、APIにアクセスして画像をダウンロードするために、デモアプリケーションを登録する必要があります。

Imgur でアプリケーションをセットアップすると、API にアクセスするために使用するクライアント ID とクライアントシークレットを受け取ります。Pipenvは自動的に.envファイルから変数を読み込むので、この認証情報を.envファイルに保存します。

同期スクリプト

以上の内容で、単純に画像の束を downloads フォルダにダウンロードする最初のスクリプトを作成することができます。

import os
from urllib import request
from imgurpython import ImgurClient
import timeit


client_secret = os.getenv("CLIENT_SECRET")
client_id = os.getenv("CLIENT_ID")


client = ImgurClient(client_id, client_secret)


def download_image(link):
    filename = link.split('/')[3].split('.')[0]
    fileformat = link.split('/')[3].split('.')[1]
    request.urlretrieve(link, "downloads/{}.{}".format(filename, fileformat))
    print("{}.{} downloaded into downloads/ folder".format(filename, fileformat))


def main():
    images = client.get_album_images('PdA9Amq')
    for image in images:
        download_image(image.link)


if __name__ == "__main__":
    print("Time taken to download images synchronously: {}".format(timeit.Timer(main).timeit(number=1)))


このスクリプトでは、Imgur のアルバム識別子を渡し、関数 get_album_images() を使ってそのアルバム内のすべての画像をダウンロードします。これにより画像のリストが得られ、次にこの関数を使って画像をダウンロードし、ローカルなフォルダに保存します。

この簡単な例で、仕事は完了です。Imgurから画像をダウンロードすることはできますが、同時には動作しません。一度に1つの画像をダウンロードしてから、次の画像に移るのです。私のマシンでは、スクリプトが画像をダウンロードするのに48秒かかりました。

マルチスレッドによる最適化

では、マルチスレッドによる並列処理を行い、その性能を確認してみましょう。

# previous imports from synchronous version are maintained
import threading
from concurrent.futures import ThreadPoolExecutor


# Imgur client setup remains the same as in the synchronous version


# download_image() function remains the same as in the synchronous


def download_album(album_id):
    images = client.get_album_images(album_id)
    with ThreadPoolExecutor(max_workers=5) as executor:
        executor.map(download_image, images)


def main():
    download_album('PdA9Amq')


if __name__ == "__main__":
    print("Time taken to download images using multithreading: {}".format(timeit.Timer(main).timeit(number=1)))


上記の例では、Threadpool を作成し、ギャラリーから画像をダウンロードするために 5 つの異なるスレッドをセットアップしています。スレッドは1つのプロセッサで実行されます。

このバージョンのコードは19秒かかります。これは、同期バージョンのスクリプトのほぼ3倍の速さです。

マルチプロセッシングによる最適化

ここでは、同じスクリプトに対して複数のCPUを使用するMultiprocessingを実装して、そのパフォーマンスを確認します。

# previous imports from synchronous version remain
import multiprocessing


# Imgur client setup remains the same as in the synchronous version


# download_image() function remains the same as in the synchronous


def main():
    images = client.get_album_images('PdA9Amq')


pool = multiprocessing.Pool(multiprocessing.cpu_count())
    result = pool.map(download_image, [image.link for image in images])


if __name__ == "__main__":
    print("Time taken to download images using multiprocessing: {}".format(timeit.Timer(main).timeit(number=1)))


このバージョンでは、マシンの CPU コアの数を含むプールを作成し、画像をダウンロードする関数をプール全体にマッピングします。これにより、コードが CPU 間で並列に実行され、このマルチプロセッシング バージョンのコードは複数回実行した後で平均 14 秒を要しました。

これは、スレッドを使用するバージョンよりわずかに速く、非同期バージョンより大幅に速いです。

AsyncIOによる最適化

同じスクリプトをAsyncIOを使って実装し、そのパフォーマンスを見てみましょう。

# previous imports from synchronous version remain
import asyncio
import aiohttp


# Imgur client setup remains the same as in the synchronous version


async def download_image(link, session):
    """
    Function to download an image from a link provided.
    """
    filename = link.split('/')[3].split('.')[0]
    fileformat = link.split('/')[3].split('.')[1]


async with session.get(link) as response:
        with open("downloads/{}.{}".format(filename, fileformat), 'wb') as fd:
            async for data in response.content.iter_chunked(1024):
                fd.write(data)


print("{}.{} downloaded into downloads/ folder".format(filename, fileformat))


async def main():
    images = client.get_album_images('PdA9Amq')


async with aiohttp.ClientSession() as session:
        tasks = [download_image(image.link, session) for image in images]


return await asyncio.gather(*tasks)


if __name__ == "__main__":
    start_time = timeit.default_timer()


loop = asyncio.get_event_loop()
    results = loop.run_until_complete(main())


time_taken = timeit.default_timer() - start_time


print("Time taken to download images using AsyncIO: {}".format(time_taken))


この新しいスクリプトには、いくつかの目立った変更があります。まず、画像をダウンロードするために通常の requests モジュールを使用せず、代わりに aiohttp を使用しています。これは、 requests が Python の httpsockets モジュールを使用しているため、AsyncIO と互換性がないためである。

ソケットは本質的にブロッキングする。つまり、一時停止して、後で実行を継続することができない。aiohttp` はこれを解決し、真に非同期なコードを実現するのに役立つ。

async` というキーワードは、この関数がコルーチン (Co-operative Routine) であることを表しています。コルーチンは協調的にマルチタスクします。つまり、いつ一時停止して他の人に実行させるかを選択するのです。

ここでは、ダウンロードしたい画像へのすべてのリンクのキューを作成するプールを作成します。コルーチンはイベントループに入れることで起動され、完了するまで実行される。

このスクリプトを何度か実行した結果、AsyncIOバージョンは、アルバム内の画像をダウンロードするのに平均で14秒かかりました。これは、マルチスレッド版や同期版のコードよりもかなり速く、マルチプロセッシング版とほぼ同じです。

性能比較

| 同期|マルチスレッド|マルチプロセシング|Asyncio||||など
| — | — | — | — |
| 48秒|19秒|14秒|14秒|||。

結論

この記事では、並行処理について、そして並行処理とどのように比較するかを説明しました。また、マルチスレッドやマルチプロセシングなど、Pythonコードに並行処理を実装するために使用できるさまざまな方法を検討し、それらの違いについても説明しました。

上記の例から、同時実行が同期的な方法よりもコードの実行を高速化するのに役立つことがわかります。経験則では、マルチプロセシングはCPUに拘束されるタスクに最適で、マルチスレッディングはI/Oに拘束されるタスクに最適です。

この記事のソースコードはGitHubで公開されていますので、参考にしてください。

タイトルとURLをコピーしました