並行処理でPythonプログラムを高速化する

並行処理でPythonプログラムを高速化する

`+ asyncio +`について多くの話を聞いたことがありますhttps://realpython.com/python37-new-features/[Pythonに追加されています]が、他の並行性メソッドと比較したり、並行性とはプログラムをどのように高速化できるか、適切な場所に来ました。

この記事では、次のことを学びます:

  • *並行性*とは

  • *並列処理*とは

  • 「+ threading 」、「 asyncio 」、「 multiprocessing +」など、* Pythonの同時実行メソッド*の比較

  • *プログラムで同時実行を使用するタイミング*および使用するモジュール

この記事では、Pythonの基本的な知識があり、少なくともバージョン3.6を使用して例を実行していることを前提としています。 Real Python GitHub repoからサンプルをダウンロードできます。

無料ボーナス: link:[5 Thoughts On Python Mastery]、Python開発者向けの無料コースで、Pythonのスキルを次のレベルに引き上げるのに必要なロードマップと考え方を示します。

  • __クイズに挑戦:*インタラクティブな「Python Concurrency」クイズで知識をテストします。 完了すると、学習の進捗状況を経時的に追跡できるようにスコアを受け取ります。

link:/quizzes/python-concurrency/[クイズに挑戦»]

並行性とは

並行性の辞書定義は同時発生です。 Pythonでは、同時に発生しているものは異なる名前(スレッド、タスク、プロセス)で呼び出されますが、高レベルでは、すべてが順番に実行される一連の命令を参照します。

私はそれらを異なる思考の流れと考えるのが好きです。 それぞれを特定のポイントで停止することができ、それらを処理しているCPUまたは脳は別のポイントに切り替えることができます。 それぞれの状態は保存されるため、中断された場所からすぐに再開できます。

Pythonが同じ概念に対して異なる単語を使用する理由を疑問に思うかもしれません。 スレッド、タスク、およびプロセスは、高レベルで表示した場合にのみ同じであることがわかります。 詳細を掘り下げると、それらはすべてわずかに異なるものを表しています。 例が進むにつれて、それらがどのように異なるかがわかります。

それでは、その定義の同時部分について話しましょう。 細部に目を向けると、実際にはこれらの一連の思考を文字通り同時に実行するのは「+ multiprocessing 」だけなので、少し注意する必要があります。 ` Threading `と ` asyncio +`は両方とも単一のプロセッサで実行されるため、一度に1つだけ実行されます。 彼らは全体的なプロセスをスピードアップするために交代する方法を賢く見つけています。 異なる思考の流れを同時に実行することはありませんが、それでもこの並行性と呼びます。

スレッドまたはタスクの順番は、「+ threading 」と「 asyncio 」の大きな違いです。 ` threading +`では、オペレーティングシステムは実際に各スレッドを認識し、いつでも別のスレッドの実行を開始するために割り込みを行うことができます。 これはhttps://en.wikipedia.org/wiki/Preemption_%28computing%29#Preemptive_multitasking[pre-emptive multitasking]と呼ばれます。これは、オペレーティングシステムが切り替えを行うためにスレッドを横取りできるためです。

プリエンプティブマルチタスクは、スレッド内のコードが切り替えを行うために何もする必要がないという点で便利です。 また、「いつでも」というフレーズのために難しいこともあります。 この切り替えは、単一のPythonステートメントの途中で発生する可能性があります。`+ x = x + 1 + `のような些細なステートメントでも可能です。

一方、「+ Asyncio +」はhttps://en.wikipedia.org/wiki/Cooperative_multitasking[cooperative multitasking]を使用します。 タスクは、スイッチアウトの準備ができたときに通知することで協力する必要があります。 つまり、これを実現するには、タスク内のコードをわずかに変更する必要があります。

この追加の作業を事前に行うことの利点は、タスクがどこでスワップアウトされるかを常に知っていることです。 Pythonステートメントがマークされていない限り、Pythonステートメントの途中でスワップアウトされることはありません。 これにより、デザインの一部がどのように簡素化されるかについては、後で説明します。

並列処理とは

これまで、単一のプロセッサで発生する同時実行性について見てきました。 クールで新しいラップトップのCPUコアはどうですか? それらをどのように利用できますか? `+ multiprocessing +`が答えです。

`+ multiprocessing +`を使用すると、Pythonは新しいプロセスを作成します。 ここでのプロセスは、ほぼ完全に異なるプログラムと考えることができますが、技術的には、通常、リソースにはメモリ、ファイルハンドルなどが含まれるリソースのコレクションとして定義されます。 それについて考える1つの方法は、各プロセスが独自のPythonインタープリターで実行されることです。

これらは異なるプロセスであるため、マルチプロセッシングプログラムの一連の思考はそれぞれ異なるコアで実行できます。 別のコアで実行すると、実際に同時に実行できることを意味します。これはすばらしいことです。 これを行うことから生じるいくつかの複雑さはありますが、Pythonはほとんどの場合、それらをスムーズにするという非常に良い仕事をします。

並行処理と並列処理の概要がわかったので、それらの違いを確認して、それらがなぜ役立つのかを見てみましょう。

Concurrency Type Switching Decision Number of Processors

Pre-emptive multitasking (threading)

The operating system decides when to switch tasks external to Python.

1

Cooperative multitasking (asyncio)

The tasks decide when to give up control.

1

Multiprocessing (multiprocessing)

The processes all run at the same time on different processors.

Many

これらのタイプの同時実行性はそれぞれ有用です。 スピードアップに役立つプログラムの種類を見てみましょう。

同時実行性はいつ有用ですか?

並行性は、2種類の問題に対して大きな違いをもたらす可能性があります。 これらは一般にCPUバウンドおよびI/Oバウンドと呼ばれます。

I/Oバウンドの問題は、外部リソースからの入出力(I/O)を頻繁に待機する必要があるため、プログラムの速度を低下させます。 プログラムがCPUよりもかなり遅いもので動作しているときに頻繁に発生します。

CPUよりも遅いものの例は大勢ですが、プログラムはありがたいことにそれらのほとんどと対話しません。 プログラムが最も頻繁にやり取りするのは、ファイルシステムとネットワーク接続です。

それがどのように見えるか見てみましょう:

I/Oバインドプログラムのタイミング図、width = 1737、height = 537

上の図では、青いボックスはプログラムが作業を行っている時間を示し、赤いボックスはI/O操作が完了するまでの時間を示しています。 インターネット上の要求はCPU命令よりも数桁長くかかる可能性があるため、この図は縮尺どおりではありません。そのため、プログラムはほとんどの時間を待機することになります。 これは、ブラウザがほとんどの場合に実行していることです。

一方、ネットワークと対話したりファイルにアクセスしたりせずに重要な計算を行うプログラムのクラスがあります。 プログラムの速度を制限するリソースはCPUであり、ネットワークまたはファイルシステムではないため、これらはCPUにバインドされたプログラムです。

CPUにバインドされたプログラムに対応する図を次に示します。

CPUバウンドプログラムのタイミング図、width = 1737、 height = 486

次のセクションの例を見ていくと、CPUにバインドされたプログラムとI/Oにバインドされたプログラムでは、さまざまな形式の同時実行がうまくまたは悪くなることがわかります。 プログラムに並行性を追加すると、余分なコードと複雑さが追加されるため、潜在的な高速化に余分な労力が必要かどうかを判断する必要があります。 この記事の終わりまでに、その決定を開始するのに十分な情報が得られるはずです。

この概念を明確にするための簡単な要約を次に示します。

I/O-Bound Process CPU-Bound Process

Your program spends most of its time talking to a slow device, like a network connection, a hard drive, or a printer.

You program spends most of its time doing CPU operations.

Speeding it up involves overlapping the times spent waiting for these devices.

Speeding it up involves finding ways to do more computations in the same amount of time.

まず、I/Oにバインドされたプログラムを見ていきます。 次に、CPUにバインドされたプログラムを処理するコードを確認します。

I/Oバウンドプログラムを高速化する方法

まず、I/Oにバインドされたプログラムと、ネットワークを介したコンテンツのダウンロードという一般的な問題に焦点を当てます。 この例では、いくつかのサイトからWebページをダウンロードしますが、実際にはネットワークトラフィックである可能性があります。 ウェブページを視覚化して設定する方が簡単です。

同期バージョン

このタスクの非並行バージョンから始めます。 このプログラムにはhttp://docs.python-requests.org/en/master/[+ requests +]モジュールが必要であることに注意してください。 おそらくhttps://realpython.com/python-virtual-environments-a-primer/[virtualenv]を使用して、実行する前に `+ pip install requests +`を実行する必要があります。 このバージョンでは、並行性はまったく使用されません。

import requests
import time


def download_site(url, session):
    with session.get(url) as response:
        print(f"Read {len(response.content)} from {url}")


def download_all_sites(sites):
    with requests.Session() as session:
        for url in sites:
            download_site(url, session)


if __name__ == "__main__":
    sites = [
        "https://www.jython.org",
        "http://olympus.realpython.org/dice",
    ] * 80
    start_time = time.time()
    download_all_sites(sites)
    duration = time.time() - start_time
    print(f"Downloaded {len(sites)} in {duration} seconds")

ご覧のとおり、これはかなり短いプログラムです。 + download_site()+`はURLからコンテンツをダウンロードし、サイズを出力するだけです。 指摘すべき小さなことの1つは、 `+ requests +`のhttp://docs.python-requests.org/en/master/user/advanced/#session-objects [+ Session +`]オブジェクトを使用していることです。

`+ requests `から ` get()`を直接使用することもできますが、 ` Session `オブジェクトを作成すると、 ` requests +`で高度なネットワークトリックを実行して、本当に高速化できます。

`+ download_all_sites()`は ` Session +`を作成し、サイトのリストを順に調べて、それぞれを順番にダウンロードします。 最後に、このプロセスにかかった時間を出力するので、次の例でどれだけの並行性が私たちを助けたのかを見ることができます。

このプログラムの処理図は、前のセクションのI/Oバウンド図によく似ています。

*注:*ネットワークトラフィックは、秒ごとに異なる多くの要因に依存しています。 ネットワークの問題が原因で、これらのテストの時間は実行ごとに2倍になるのを見てきました。

同期バージョンがロックする理由

このバージョンのコードの素晴らしいところは、簡単なことです。 作成とデバッグは比較的簡単でした。 考えるのも簡単です。 思考の流れは1つしかないので、次のステップとその動作を予測できます。

同期バージョンの問題

ここでの大きな問題は、これから提供する他のソリューションに比べて比較的遅いことです。 これが、私のマシンでの最終出力の例です。

$ ./io_non_concurrent.py
   [most output skipped]
Downloaded 160 in 14.289619207382202 seconds

*注:*結果は大きく異なる場合があります。 このスクリプトを実行すると、時間は14.2秒から21.9秒まで変化することがわかりました。 この記事では、3回の実行のうち最速のものを時間として使用しました。 方法の違いはまだ明らかです。

ただし、遅くなることは必ずしも大きな問題ではありません。 実行中のプログラムが同期バージョンで2秒しかかからず、めったに実行されない場合、同時実行性を追加する価値はおそらくないでしょう。 ここでやめることができます。

プログラムが頻繁に実行されるとどうなりますか? 実行に数時間かかる場合はどうなりますか? `+ threading +`を使用してこのプログラムを書き換えて、並行性に進みましょう。

`+ threading +`バージョン

おそらくご想像のとおり、スレッド化されたプログラムの作成にはより多くの労力が必要です。 ただし、単純な場合に余分な労力がほとんどかからないことに驚くかもしれません。 同じプログラムが `+ threading +`でどのように見えるかを以下に示します。

import concurrent.futures
import requests
import threading
import time


thread_local = threading.local()


def get_session():
    if not hasattr(thread_local, "session"):
        thread_local.session = requests.Session()
    return thread_local.session


def download_site(url):
    session = get_session()
    with session.get(url) as response:
        print(f"Read {len(response.content)} from {url}")


def download_all_sites(sites):
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        executor.map(download_site, sites)


if __name__ == "__main__":
    sites = [
        "https://www.jython.org",
        "http://olympus.realpython.org/dice",
    ] * 80
    start_time = time.time()
    download_all_sites(sites)
    duration = time.time() - start_time
    print(f"Downloaded {len(sites)} in {duration} seconds")

`+ threading `を追加すると、全体的な構造は同じになり、いくつかの変更を加えるだけで済みます。 ` download_all_sites()+`は、サイトごとに1回関数を呼び出すことから、より複雑な構造に変更されました。

このバージョンでは、「+ ThreadPoolExecutor 」を作成していますが、これは複雑なもののようです。 それを分解してみましょう: ` ThreadPoolExecutor ` = ` Thread ` + ` Pool ` + ` Executor +`。

あなたはすでに `+ Thread `の部分について知っています。 これは先ほど述べた一連の考え方に過ぎません。 ` Pool `の部分は、面白くなってきます。 このオブジェクトは、それぞれが同時に実行できるスレッドのプールを作成します。 最後に、「 Executor +」は、プール内の各スレッドを実行する方法とタイミングを制御する部分です。 プールでリクエストを実行します。

便利なことに、標準ライブラリはコンテキストマネージャとして `+ ThreadPoolExecutor `を実装しているため、 ` with `構文を使用して ` Threads +`のプールの作成と解放を管理できます。

`+ ThreadPoolExecutor `を取得したら、その便利な ` .map()+`メソッドを使用できます。 このメソッドは、リスト内の各サイトで渡された関数を実行します。 優れた点は、管理しているスレッドのプールを使用して自動的にそれらを同時に実行することです。

他の言語、またはPython 2から来た人は、 + Thread.start()+のようなもので、 + threading + を扱うときに慣れている詳細を管理する通常のオブジェクトと関数がどこにあるのか疑問に思うでしょう。 `、 + Thread.join()+ 、および + Queue + `。

これらはすべてそこにあり、それらを使用してスレッドの実行方法をきめ細かく制御できます。 しかし、Python 3.2からは、標準ライブラリに「+ Executors +」と呼ばれる高レベルの抽象化が追加され、きめ細かな制御が必要ない場合に詳細の多くを管理します。

この例のもう1つの興味深い変更は、各スレッドが独自の `+ requests.Session()`オブジェクトを作成する必要があることです。 ` requests +`のドキュメントを見ているとき、それを伝えるのは必ずしも簡単ではありませんが、https://github.com/requests/requests/issues/2766 [この問題]を読むと、あなたが必要とすることはかなり明らかですスレッドごとに個別のセッション。

これは、 `+ threading `の興味深い困難な問題の1つです。 オペレーティングシステムは、タスクが中断されて別のタスクが開始されるタイミングを制御しているため、スレッド間で共有されるデータは保護するか、スレッドセーフにする必要があります。 残念ながら、 ` requests.Session()+`はスレッドセーフではありません。

データの内容と使用方法に応じて、データアクセスをスレッドセーフにするための戦略がいくつかあります。 その1つは、Pythonの `+ queue `モジュールの ` Queue +`のようなスレッドセーフなデータ構造を使用することです。

これらのオブジェクトはhttps://docs.python.org/2/library/threading.html#lock-objects [+ threading.Lock +]のような低レベルのプリミティブを使用して、1つのスレッドのみがコードのブロックにアクセスできるようにします。同時に少しのメモリ。 `+ ThreadPoolExecutor +`オブジェクトを経由して間接的にこの戦略を使用しています。

ここで使用する別の戦略は、スレッドローカルストレージと呼ばれるものです。 `+ Threading.local()`は、グローバルに見えるが個々のスレッドに固有のオブジェクトを作成します。 あなたの例では、これは ` threadLocal `と ` get_session()+`で行われます:

threadLocal = threading.local()


def get_session():
    if not hasattr(threadLocal, "session"):
        threadLocal.session = requests.Session()
    return threadLocal.session

この問題を明確に解決するために、 `+ ThreadLocal `は ` threading +`モジュールにあります。 少し奇妙に見えますが、作成するのはこれらのオブジェクトのうちの1つだけであり、スレッドごとに作成するのではありません。 オブジェクト自体が、異なるスレッドから異なるデータへのアクセスを分離します。

`+ get_session()`が呼び出されると、ルックアップされる ` session `は、実行中の特定のスレッドに固有のものになります。 そのため、各スレッドは、最初に ` get_session()+`を呼び出したときに単一のセッションを作成し、その後、その存続期間を通じて後続の各呼び出しでそのセッションを使用します。

最後に、スレッド数の選択に関する簡単なメモ。 サンプルコードでは5つのスレッドを使用していることがわかります。 この数値を自由に試して、全体の時間がどのように変化するかを確認してください。 あなたはダウンロードごとに1つのスレッドを持っていることが最も速いと期待するかもしれませんが、少なくとも私のシステムではそうではありませんでした。 5〜10スレッドのどこかで最速の結果が見つかりました。 それよりも高くすると、スレッドを作成および破棄するための余分なオーバーヘッドにより、時間を節約できなくなります。

ここで難しい答えは、スレッドの正しい数は、あるタスクから別のタスクへの定数ではないということです。 いくつかの実験が必要です。

「+ threading +」バージョンがロックされる理由

これは速い! これが私のテストの最速の実行です。 非同時バージョンには14秒以上かかったことを思い出してください。

$ ./io_threading.py
   [most output skipped]
Downloaded 160 in 3.7238826751708984 seconds

その実行タイミング図は次のとおりです。

複数のスレッドを使用して、Webサイトに同時に複数のオープンリクエストを送信し、プログラムが待機時間をオーバーラップして、最終結果をより速く取得できるようにします。 イッピー! それが目標でした。

  • `+ threading +`バージョンの問題*

さて、例からわかるように、これを実現するにはもう少しコードが必要であり、スレッド間でどのデータが共有されているかを本当に考える必要があります。

スレッドは、微妙で検出が困難な方法で対話できます。 これらの相互作用により、競合状態が発生する可能性があり、その結果、ランダムで断続的なバグが頻繁に発生するため、見つけるのは非常に困難です。 競合状態の概念に不慣れな方は、以下のセクションを拡大して読んでください。

`+ asyncio +`バージョン

「+ asyncio 」のサンプルコードを調べる前に、「 asyncio +」の仕組みについて詳しく説明します。

  • `+ asyncio +`の基本*

これは、 `+ asycio +`の簡易バージョンになります。 ここには多くの詳細が記載されていますが、それがどのように機能するかという考えはまだ伝わっています。

`+ asyncio +`の一般的な概念は、イベントループと呼ばれる単一のPythonオブジェクトが、各タスクを実行する方法とタイミングを制御することです。 イベントループは各タスクを認識し、その状態を把握しています。 実際には、タスクが存在する可能性のある状態は多数ありますが、ここでは、2つの状態のみを持つ単純化されたイベントループを考えてみましょう。

準備完了状態は、タスクに実行すべき作業があり、実行準備ができていることを示します。待機状態は、タスクがネットワーク操作などの外部の処理が完了するのを待っていることを意味します。

単純化されたイベントループは、これらの状態ごとに1つずつ、2つのタスクリストを保持します。 実行可能なタスクの1つを選択し、実行を再開します。 このタスクは、イベントループに制御を協調的に戻すまで完全に制御されます。

実行中のタスクがイベントループに制御を戻すと、イベントループはそのタスクを準備完了リストまたは待機リストに配置し、待機リスト内の各タスクを調べて、I/O操作によって準備完了になったかどうかを確認します。完了します。 実行可能リストにあるタスクは、まだ実行されていないことがわかっているため、まだ準備ができていることがわかります。

すべてのタスクが再び正しいリストに分類されると、イベントループは次に実行するタスクを選択し、プロセスが繰り返されます。 単純化されたイベントループは、最も長く待機していたタスクを選択して実行します。 このプロセスは、イベントループが終了するまで繰り返されます。

`+ asyncio `の重要なポイントは、タスクが意図的にそうしなければ制御を放棄しないことです。 操作の途中で中断されることはありません。 これにより、 ` threading `よりも ` asyncio +`でリソースを少し簡単に共有できます。 コードをスレッドセーフにすることを心配する必要はありません。

これは、「+ asyncio +」で何が起こっているのかについての高レベルのビューです。 さらに詳細が必要な場合は、https://stackoverflow.com/a/51116910/6843734 [このStackOverflowの回答]で、さらに掘り下げたい場合に役立つ詳細を提供します。

*`+ async +`および `+ await +`*

次に、Pythonに追加された2つの新しいキーワード、「+ async 」と「 await 」について説明します。 上記の議論に照らして、タスクがイベントループに制御を戻すことができる魔法として「 await +」を見ることができます。 コードが関数呼び出しを待つとき、それは呼び出しがしばらくかかるものであり、タスクが制御を放棄する可能性が高いことを示すシグナルです。

`+ async `は、定義しようとしている関数が ` await +`を使用していることを伝えるPythonのフラグと考えるのが最も簡単です。 asynchronous generatorsのように、これが厳密に当てはまらない場合もありますが、多くの場合に当てはまり、簡単なモデルを提供します。始めましょう。

次のコードで見ることができるこの例外の1つは、通常待機するオブジェクトからコンテキストマネージャーを作成する「+ async with +」ステートメントです。 セマンティクスは少し異なりますが、考え方は同じです。このコンテキストマネージャーにスワップアウトできるものとしてフラグを立てることです。

想像できると思いますが、イベントループとタスク間の相互作用の管理には多少の複雑さがあります。 「+ asyncio 」で始まる開発者にとって、これらの詳細は重要ではありませんが、「 await 」を呼び出す関数には「 async +」でマークする必要があることを覚えておく必要があります。 そうしないと、構文エラーが発生します。

コードに戻る

「+ asyncio 」とは何かを基本的に理解できたので、サンプルコードの「 asyncio 」バージョンを見て、その仕組みを理解しましょう。 このバージョンではhttps://aiohttp.readthedocs.io/en/stable/[` aiohttp `]が追加されることに注意してください。 実行する前に ` pip install aiohttp +`を実行する必要があります。

import asyncio
import time
import aiohttp


async def download_site(session, url):
    async with session.get(url) as response:
        print("Read {0} from {1}".format(response.content_length, url))


async def download_all_sites(sites):
    async with aiohttp.ClientSession() as session:
        tasks = []
        for url in sites:
            task = asyncio.ensure_future(download_site(session, url))
            tasks.append(task)
        await asyncio.gather(*tasks, return_exceptions=True)


if __name__ == "__main__":
    sites = [
        "https://www.jython.org",
        "http://olympus.realpython.org/dice",
    ] * 80
    start_time = time.time()
    asyncio.get_event_loop().run_until_complete(download_all_sites(sites))
    duration = time.time() - start_time
    print(f"Downloaded {len(sites)} sites in {duration} seconds")

このバージョンは、前の2つよりも少し複雑です。 それは同様の構造を持っていますが、 `+ ThreadPoolExecutor +`を作成するよりもタスクを設定する作業が少し多くあります。 例の一番上から始めましょう。

*`+ download_site()+`*

上部の + download_site()+`は、関数定義行の `+ async +`キーワードと実際に `+ sessionを呼び出す際の + async with + キーワードを除いて、 + threading + `バージョンとほとんど同じです。 .get()+ `。 スレッドローカルストレージを使用するのではなく、ここで `+ Session +`を渡すことができる理由は後で説明します。

*`+ download_all_sites()+`*

`+ download_all_sites()`は、 ` threading +`の例からの最大の変更を見る場所です。

セッションはすべてのタスクで共有できるため、セッションはコンテキストマネージャーとしてここで作成されます。 すべてのタスクは同じスレッドで実行されているため、タスクはセッションを共有できます。 セッションが悪い状態にある間、あるタスクが別のタスクを中断する方法はありません。

そのコンテキストマネージャー内で、 `+ asyncio.ensure_future()`を使用してタスクのリストを作成し、タスクの開始も処理します。 すべてのタスクが作成されると、この関数はすべてのタスクが完了するまで「 asyncio.gather()+」を使用してセッションコンテキストを維持します。

`+ threading `コードはこれと似たようなことをしますが、詳細は ` ThreadPoolExecutor `で便利に処理されます。 現在、 ` AsyncioPoolExecutor +`クラスはありません。

ただし、ここでは詳細に小さな重要な変更が1つあります。 作成するスレッドの数について話したことを覚えていますか? 「+ threading +」の例では、最適なスレッド数は明らかではありませんでした。

`+ asyncio `の素晴らしい利点の1つは、 ` threading +`よりもはるかに優れた拡張性があることです。 各タスクはスレッドよりもはるかに少ないリソースと作成時間で済むため、より多くのリソースを作成して実行するのが適切です。 この例では、ダウンロードするサイトごとに個別のタスクを作成するだけで、非常にうまく機能します。

*`+ __ main __ +`*

最後に、 `+ asyncio `の性質は、イベントループを開始し、実行するタスクを伝える必要があることを意味します。 ファイルの下部にある「 main 」セクションには、「 get_event_loop()」と「 run_until_complete()+」のコードが含まれています。 他に何もないとしても、彼らはそれらの関数の命名において素晴らしい仕事をしました。

Python 3.7に更新した場合、Pythonコア開発者はこの構文を簡略化しました。 `+ asyncio.get_event_loop()。run_until_complete()`の舌ツイスターの代わりに、 ` asyncio.run()+`を使用できます。

「+ asyncio +」バージョンがロックされる理由

本当に速いです! 私のマシンでのテストでは、これは十分なマージンを持ってコードの最速バージョンでした:

$ ./io_asyncio.py
   [most output skipped]
Downloaded 160 in 2.5727896690368652 seconds

実行タイミング図は、「+ threading +」の例で起こっていることと非常によく似ています。 I/Oリクエストはすべて同じスレッドによって実行されるというだけです。

Asyncioソリューションのタイミング図、width = 933、height = 537

`+ ThreadPoolExecutor `のような素敵なラッパーがないため、このコードは ` threading +`の例よりも少し複雑になります。 これは、パフォーマンスを向上させるために少し余分な作業を行う必要がある場合です。

また、「+ async 」と「 await +」を適切な場所に追加しなければならないのは、さらに面倒だという一般的な議論があります。 少しですが、それは事実です。 この議論の裏側は、特定のタスクがいつスワップアウトされるかを考えるように強制することです。これは、より良い、より高速な設計を作成するのに役立ちます。

スケーリングの問題もここで大きく見えます。 各サイトのスレッドで上記の「+ threading 」の例を実行すると、少数のスレッドで実行するよりも著しく遅くなります。 数百のタスクで「 asyncio +」の例を実行しても、速度はまったく低下しませんでした。

  • `+ asyncio +`バージョンの問題 *

この時点で、 `+ asyncio `にはいくつかの問題があります。 ` asycio `を最大限に活用するには、ライブラリの特別な非同期バージョンが必要です。 サイトのダウンロードに「 requests 」を使用した場合、「 requests 」はブロックされていることをイベントループに通知するように設計されていないため、はるかに遅くなります。 この問題は、時間が経つにつれてますます小さくなり、より多くのライブラリが ` asyncio +`を採用しています。

もう1つのより微妙な問題は、タスクの1つが協調しない場合、協調マルチタスクのすべての利点が失われることです。 コードの軽微なミスにより、タスクが実行されずに長時間プロセッサが停止し、実行が必要な他のタスクが不足する可能性があります。 タスクが制御を渡さない場合、イベントループが割り込む方法はありません。

それを念頭に置いて、並行性に対する根本的に異なるアプローチ、「マルチプロセッシング」に進みましょう。

`+ multiprocessing +`バージョン

以前のアプローチとは異なり、コードの「+ multiprocessing +」バージョンは、クールで新しいコンピューターに搭載されている複数のCPUを最大限に活用します。 または、私の場合、私の不器用な古いラップトップが持っていること。 コードから始めましょう:

import requests
import multiprocessing
import time

session = None


def set_global_session():
    global session
    if not session:
        session = requests.Session()


def download_site(url):
    with session.get(url) as response:
        name = multiprocessing.current_process().name
        print(f"{name}:Read {len(response.content)} from {url}")


def download_all_sites(sites):
    with multiprocessing.Pool(initializer=set_global_session) as pool:
        pool.map(download_site, sites)


if __name__ == "__main__":
    sites = [
        "https://www.jython.org",
        "http://olympus.realpython.org/dice",
    ]* 80
    start_time = time.time()
    download_all_sites(sites)
    duration = time.time() - start_time
    print(f"Downloaded {len(sites)} in {duration} seconds")

これは `+ asyncio `の例よりもはるかに短く、実際は ` threading `の例と非常によく似ていますが、コードに飛び込む前に、 ` multiprocessing +`が何をするかを簡単に見てみましょう。

*`+ multiprocessing +` in a Nutshell*

この時点まで、この記事の同時実行の例はすべて、コンピューターの単一のCPUまたはコアでのみ実行されます。 この理由は、CPythonの現在の設計とグローバルインタープリターロック(GIL)と呼ばれるものに関係しています。

この記事では、https://realpython.com/python-gil/[GIL]の方法と理由については詳しく説明しません。 この例の同期、 + threading +、および `+ asyncio +`バージョンはすべて単一のCPUで実行されることを知るだけで十分です。

標準ライブラリの「+ multiprocessing +」は、その障壁を取り除き、複数のCPUでコードを実行するように設計されています。 高レベルでは、Pythonインタープリターの新しいインスタンスを作成して各CPUで実行し、プログラムの一部を実行して実行します。

ご想像のとおり、別のPythonインタープリターを起動するのは、現在のPythonインタープリターで新しいスレッドを開始するほど速くありません。 これは重量のある操作であり、いくつかの制限と困難が伴いますが、正しい問題のためには大きな違いを生む可能性があります。

  • `+ multiprocessing +`コード*

コードには、同期バージョンからいくつかの小さな変更があります。 最初のものは `+ download_all_sites()`にあります。 単に繰り返し ` download_site()`を呼び出す代わりに、 ` multiprocessing.Pool `オブジェクトを作成し、 ` download_site `を反復可能な ` sites `にマッピングします。 これは、「 threading +」の例からおなじみのはずです。

ここで行われるのは、 `+ Pool `が多数の個別のPythonインタープリタープロセスを作成し、それぞれがiterableのいくつかのアイテム(この場合はサイトのリスト)で指定された関数を実行することです。 メインプロセスと他のプロセス間の通信は、 ` multiprocessing +`モジュールによって処理されます。

`+ Pool `を作成する行は注目に値します。 まず、 ` Pool `で作成するプロセスの数は指定しませんが、これはオプションのパラメーターです。 デフォルトでは、 ` multiprocessing.Pool()+`はコンピューターのCPUの数を決定し、それに一致します。 これはしばしば最良の答えであり、私たちの場合です。

この問題では、プロセスの数を増やしても処理が速くなりませんでした。 これらすべてのプロセスのセットアップと破棄のコストは、I/O要求を並行して行う利点よりも大きいため、実際には速度が低下しました。

次に、その呼び出しの `+ initializer = set_global_session `部分があります。 ` Pool `の各プロセスには独自のメモリ空間があることに注意してください。 これは、 ` Session `オブジェクトのようなものを共有できないことを意味します。 関数が呼び出されるたびに新しい「 Session +」を作成するのではなく、プロセスごとに作成します。

この場合のために、 `+ initializer `関数パラメーターが構築されます。 戻り値を ` initializer `からプロセス ` download_site()`によって呼び出される関数に返す方法はありませんが、グローバルな ` session +`変数を初期化して、それぞれの単一セッションを保持できます処理する。 各プロセスには独自のメモリ空間があるため、各プロセスのグローバルは異なります。

それだけです。 コードの残りの部分は、前に見たものと非常に似ています。

  • `+ multiprocessing +`バージョンがロックされる理由*

この例の「+ multiprocessing +」バージョンは、設定が比較的簡単で、追加のコードをほとんど必要としないため、素晴らしいです。 また、コンピューターのCPUパワーを最大限に活用します。 このコードの実行タイミング図は次のようになります。

  • `+ multiprocessing +`バージョンの問題 *

このバージョンの例では追加のセットアップが必要であり、グローバルな `+ session +`オブジェクトは奇妙です。 各プロセスでどの変数にアクセスするかを考えるのに時間をかける必要があります。

最後に、この例の `+ asyncio `および ` threading +`バージョンより明らかに遅いです:

$ ./io_mp.py
    [most output skipped]
Downloaded 160 in 5.718175172805786 seconds

I/Oバウンドの問題が実際に「+ multiprocessing +」が存在する理由ではないため、それは驚くことではありません。 次のセクションに進み、CPUバウンドの例を見ると、さらに多くのことがわかります。

CPUバウンドプログラムを高速化する方法

ここで少しギアをシフトしましょう。 これまでの例では、すべてI/Oに関連した問題を扱ってきました。 次に、CPUに関連する問題を調べます。 ご覧のように、I/Oに関連する問題は、ネットワークコールなどの外部操作が完了するのを待つことにほとんどの時間を費やします。 一方、CPUバウンドの問題はI/O操作をほとんど行わず、全体の実行時間は必要なデータを処理できる速度の要因です。

この例の目的のために、CPUでの実行に時間がかかるものを作成するために、やや愚かな関数を使用します。 この関数は、0から渡された値までの各数値の2乗和を計算します。

def cpu_bound(number):
    return sum(i* i for i in range(number))

多数渡すことになりますので、しばらく時間がかかります。 これは、方程式の根の計算や大規模なデータ構造のソートなど、実際に有用な処理を行い、かなりの処理時間を必要とするコードのプレースホルダーにすぎないことを忘れないでください。

CPUバウンド同期バージョン

次に、非同時バージョンの例を見てみましょう。

import time


def cpu_bound(number):
    return sum(i *i for i in range(number))


def find_sums(numbers):
    for number in numbers:
        cpu_bound(number)


if __name__ == "__main__":
    numbers = [5_000_000 + x for x in range(20)]

    start_time = time.time()
    find_sums(numbers)
    duration = time.time() - start_time
    print(f"Duration {duration} seconds")

このコードは、毎回異なる大きな数で20回、 `+ cpu_bound()+`を呼び出します。 これはすべて、単一CPU上の単一プロセスの単一スレッドで実行されます。 実行タイミング図は次のようになります。

CPUバウンドプログラムのタイミング図、width = 1737、 height = 486

I/Oバウンドの例とは異なり、CPUバウンドの例は通常、実行時間がかなり一貫しています。 これは私のマシンで約7.8秒かかります:

$ ./cpu_non_concurrent.py
Duration 7.834432125091553 seconds

明らかに、これよりもうまくいくことができます。 これはすべて、同時実行性のない単一のCPUで実行されます。 それを改善するためにできることを見てみましょう。

`+ threading `および ` asyncio +`バージョン

`+ threading `または ` asyncio +`を使用してこのコードを書き直すと、これがどれくらい速くなると思いますか?

「まったくない」と答えた場合は、自分にクッキーを与えます。 「遅くなります」と答えた場合は、2つのCookieを自分に与えます。

その理由は次のとおりです。上記のI/Oバウンドの例では、全体的な時間のほとんどが、遅い操作が完了するのを待つことに費やされました。 `+ threading `と ` asyncio +`は、順番に実行するのではなく、待っていた時間を重ねることができるようにして、これを高速化しました。

ただし、CPUにバインドされた問題では、待機はありません。 CPUは、問題を解決するためにできるだけ速くクランクを切ります。 Pythonでは、スレッドとタスクの両方が同じプロセスの同じCPUで実行されます。 これは、1つのCPUが非並行コードのすべての作業に加えて、スレッドまたはタスクのセットアップという余分な作業を行っていることを意味します。 10秒以上かかります。

$ ./cpu_threading.py
Duration 10.407078266143799 seconds

このコードの `+ threading +`バージョンを作成し、https://github.com/realpython/materials/tree/master/concurrency-overview [GitHub repo]に他のサンプルコードとともに配置しました。これを自分でテストしてください。 ただし、まだ見ていません。

CPUバウンド `+ multiprocessing +`バージョン

これで、「+ multiprocessing 」が本当に輝く場所にたどり着きました。 他の同時実行ライブラリとは異なり、「 multiprocessing +」は重いCPUワークロードを複数のCPUで共有するように明示的に設計されています。 その実行タイミング図は次のとおりです。

CPUバウンドマルチプロセッシングソリューションのタイミング図、width = 1893、height = 1407

コードは次のようになります。

import multiprocessing
import time


def cpu_bound(number):
    return sum(i* i for i in range(number))


def find_sums(numbers):
    with multiprocessing.Pool() as pool:
        pool.map(cpu_bound, numbers)


if __name__ == "__main__":
    numbers = [5_000_000 + x for x in range(20)]

    start_time = time.time()
    find_sums(numbers)
    duration = time.time() - start_time
    print(f"Duration {duration} seconds")

このコードのほとんどは、非並行バージョンから変更する必要がありませんでした。 `+ import multiprocessing `を実行してから、番号をループすることから ` multiprocessing.Pool `オブジェクトを作成し、 ` .map()+`メソッドを使用して個々の番号をワーカープロセスに送信するように変更する必要がありました。自由。

これは、I/Oにバインドされた「+ multiprocessing 」コードに対して実行したことだけでしたが、ここでは「 Session +」オブジェクトについて心配する必要はありません。

上記のように、 `+ multiprocessing.Pool()`コンストラクターの ` processes `オプションパラメーターには注意が必要です。 ` Pool `で作成および管理する ` Process +`オブジェクトの数を指定できます。 デフォルトでは、マシンにあるCPUの数を決定し、各CPUのプロセスを作成します。 これは単純な例ではうまく機能しますが、実稼働環境ではもう少し制御したいかもしれません。

また、「+ threading 」についての最初のセクションで述べたように、「 multiprocessing.Pool 」コードは、マルチスレッドを実行した人にはおなじみの「 Queue 」や「 Semaphore +」などの構築ブロックに基づいて構築されます他の言語のマルチプロセッシングコード。

  • `+ multiprocessing +`バージョンがロックされる理由*

この例の「+ multiprocessing +」バージョンは、設定が比較的簡単で、追加のコードをほとんど必要としないため、素晴らしいです。 また、コンピューターのCPUパワーを最大限に活用します。

ねえ、それはまさに「マルチプロセッシング」を見たときに言ったことです。 大きな違いは、今回が明らかに最適なオプションであることです。 私のマシンでは2.5秒かかります:

$ ./cpu_mp.py
Duration 2.5175397396087646 seconds

他のオプションで見たよりもずっと良いです。

  • `+ multiprocessing +`バージョンの問題*

`+ multiprocessing +`の使用にはいくつかの欠点があります。 この単純な例では実際には表示されませんが、問題を分割して各プロセッサが独立して動作できるようにするのは難しい場合があります。

また、多くのソリューションでは、プロセス間のより多くの通信が必要です。 これにより、非並行プログラムで対処する必要のない複雑さがソリューションに追加される可能性があります。

同時実行性を使用する場合

ここまで多くのことを説明してきたので、いくつかの重要なアイデアを確認してから、プロジェクトで使用する同時実行モジュール(ある場合)を決定するのに役立つ決定ポイントについて説明します。

このプロセスの最初のステップは、同時実行モジュールを使用するかどうかを決定することです。 ここの例では、各ライブラリが非常にシンプルに見えますが、同時実行には常に余分な複雑さが伴い、多くの場合、発見が困難なバグが発生する可能性があります。

既知のパフォーマンスの問題が発生するまで並行性の追加を控え、必要な並行性のタイプを決定します。 Donald Knuthが言ったように、「早すぎる最適化はプログラミングにおけるすべての悪(または少なくともその大部分)の根源です。」

プログラムを最適化する必要があると判断したら、プログラムがCPUにバインドされているかI/Oにバインドされているかを判断することは、次のステップとして最適です。 I/Oにバインドされたプログラムは、CPUにバインドされたプログラムがデータを処理したり、数字をできるだけ高速に処理するのに時間を費やしている間に、ほとんどの時間を何かが起こるのを待つことに注意してください。

あなたが見たように、CPUにバインドされた問題は `+ multiprocessing `を使用することからのみ本当に得られます。 ` threading `と ` asyncio +`はこの種の問題をまったく助けませんでした。

I/Oに関連する問題については、Pythonコミュニティで一般的な経験則があります。「可能な場合は `+ asyncio `を使用し、必要な場合は ` threading `を使用してください。」 ` asyncio `はこのタイプのプログラムに最適な速度を提供しますが、 ` asyncio +`を利用するために移植されていない重要なライブラリが必要になる場合があります。 イベントループへの制御を放棄しないタスクは、他のすべてのタスクをブロックすることに注意してください。

結論

これで、Pythonで利用可能な基本的な並行性のタイプを見てきました。

  • + threading +

  • + asyncio +

  • + multiprocessing +

特定の問題に対してどの並行性メソッドを使用するか、またはどの並行性メソッドを使用するかを決定するための理解があります。 さらに、同時実行を使用しているときに発生する可能性のある問題のいくつかをよりよく理解することができました。

この記事から多くのことを学び、ご自身のプロジェクトで並行性をうまく活用できることを願っています。 以下にリンクされている「Python Concurrency」クイズを受講して、学習内容を確認してください。

  • __クイズに挑戦:*インタラクティブな「Python Concurrency」クイズで知識をテストします。 完了すると、学習の進捗状況を経時的に追跡できるようにスコアを受け取ります。

link:/quizzes/python-concurrency/[クイズに挑戦»]