aiohttpとasyncioを使用したPythonの非同期HTTPリクエスト

March 25, 2021
執筆者
Sam Agnew
Twilion

この記事はTwilio Developer EvangelistのSam Agnewが執筆したこちらの記事を日本語化したものです。

非同期コードは、Python開発の主力となりつつあります。このパラダイムは、asyncioが標準ライブラリに加わり、互換性のある機能がサードパーティーのパッケージで数多く提供される今、すぐにはなくならないでしょう。

今回はaiohttpライブラリを活用して非同期HTTPリクエストを作成する方法について、詳しく見てみましょう。非同期HTTPリクエストはノンブロッキングコードのユースケースとして非常に一般的です。

ノンブロッキングコードとは?

「非同期」、「ノンブロッキング」、「同時(並行)」はそれぞれよく聞く表現ですが、それぞれの意味は少しまぎらわしいところがあります。こちらのより詳細なチュートリアルによると、主な特性は次の2つです。

  • 非同期ルーチンでは、最終結果を待つ間「一時停止」し、その間に他のルーチンを実行できる。
  • 非同期コードは、上記メカニズムを通じ同時実行を促す。別の言い方をすると、非同期コードは同時実行の操作感を提供する。

このように非同期コードとは、結果を待つ間に一度処理を中断し、その間に他のコードを実行できるコードです。他のコードの実行を「ブロック」しないため、「ノンブロッキング」コードと言えます。

asyncioライブラリには、Python開発者が非同期の処理を行うためのさまざまなツールがあります。またaiohttpには、HTTPリクエスト専用の機能があります。HTTPリクエストは非同期性にうまく適合している典型的な例です。その理由は、HTTPリクエストには必然的にサーバーからのレスポンスを待つ時間があり、その間に他のコードを実行すると都合が良く、効率もよいからです。

セットアップ

Python環境をあらかじめセットアップしておきます。必要に応じてこのガイドのvirtualenv(仮想環境)に関するセクションを参照してください。すべてが正常に動作するようにします。特に仮想環境は、同じマシン上で複数のプロジェクトを実行している場合、dependency設定を分けるうえで重要です。この投稿で示すコードを実行するには、Pythonのバージョンは3.7以上であることが必要です。

環境のセットアップ後に、サードパーティーのライブラリをいくつかインストールする必要があります。ここではaiohttpを使用して非同期リクエストを作成します。また、requestsライブラリを使用して通常の同期HTTPリクエストを作成し、後で2つを比較します。仮想環境をアクティブにした後に、次のコマンドでインストールしてください。

pip install aiohttp-3.7.4.post0 requests==2.25.1

これで準備が整いました。次のステップに進んでコードを記述しましょう。

aiohttpを使用してHTTPリクエストを作成する

まず、aiohttpを使用してGETリクエストを1つ作成し、キーワードであるasyncawaitの働きについて見てみましょう。例としてPokemon APIを使います。手始めに伝説の151番目のポケモン、ミュウに関するデータを取得してみましょう。

次のPythonコードを実行すると、「mew」という名前がターミナルに出力されます。

import aiohttp
import asyncio


async def main():

    async with aiohttp.ClientSession() as session:

        pokemon_url = 'https://pokeapi.co/api/v2/pokemon/151'
        async with session.get(pokemon_url) as resp:
            pokemon = await resp.json()
            print(pokemon['name'])

asyncio.run(main())

このコードでは、mainというコルーチンを作成しています。mainはasyncioのイベントループを利用して実行されています。ここでは、aiohttpクライアントセッションを1つ開始します。この1つのオブジェクトを、多数の個別のリクエストに使用でき、既定では一度に最大で100台のサーバーに接続できます。このセッションで、Pokemon APIにリクエストを出し、レスポンスを待ちます。

このasyncというキーワードは基本的に、定義するコルーチンをイベントループにより非同期的に実行するようPythonインタープリターに指示するものです。awaitというキーワードにより、制御がイベントループに戻りますが、このとき実行中のコルーチンを一時停止します。そしてイベントループは「await(待機)」中の結果が返されるまで他の処理を実行します。

多数のリクエストを作成する

非同期HTTPリクエストを1つ作成する利点は、レスポンスを待つ間、スレッド全体をブロックする代わりに、イベントループで他のタスクを実行できることです。ただし、この機能が真価を発揮するのは、多数のリクエストを実行しようとしたときです。この利点を、上記と同じリクエストを実行して実際に見てみましょう。ただし今回は、初代ポケモンを150匹すべて出力します。

先ほどのリクエストコードをループに入れ、リクエストするポケモンのデータを更新し、各リクエストでawaitします。

import aiohttp
import asyncio
import time

start_time = time.time()


async def main():

    async with aiohttp.ClientSession() as session:

        for number in range(1, 151):
            pokemon_url = f'https://pokeapi.co/api/v2/pokemon/{number}'
            async with session.get(pokemon_url) as resp:
                pokemon = await resp.json()
                print(pokemon['name'])

asyncio.run(main())
print("--- %s seconds ---" % (time.time() - start_time))

今回はプロセス全体にかかる時間の測定もします。このコードをPythonシェルで実行すると、ターミナルには次のように出力されます。

aiohttp - 150 pokemons

150回のリクエストに8秒というのはかなり良い結果に見えますが、比較対象がありません。ここで、requestsライブラリを使用して同じ処理を同期方式で実行してみましょう。

同期リクエストとの速さの比較

requestsは「人間のための」HTTPライブラリとして設計され、非常に洗練されたシンプルなAPIを備えています。そのため開発のスピードよりも、使いやすさやコード追跡のしやすさが重視されるプロジェクトには、強くお勧めします。

上記と同様に初代150匹のポケモンを出力する目的でrequestsライブラリを次のように使用します。

import requests
import time

start_time = time.time()

for number in range(1, 151):
    url = f'https://pokeapi.co/api/v2/pokemon/{number}'
    resp = requests.get(url)
    pokemon = resp.json()
    print(pokemon['name'])

print("--- %s seconds ---" % (time.time() - start_time))

同じ内容が出力されますが、実行時間は異なります。

request - 150 pokemons

29秒近くかかりました。前のコードと比較してかなり遅いと言えます。連続するそれぞれのリクエストで、前の手順が終了するまでプロセスの開始を待機しなければなりません。このコードは、150回のリクエスト終了を順次待機するため、非常に時間がかかります。

asyncioを活用してパフォーマンスを向上させる

8秒と29秒を比較すればパフォーマンスが大幅に向上していることが分かりますが、asyncioのツールを使用することでさらに改善できます。最初の例では個別のHTTPリクエストの後にawaitを使用していますが、これが最適とは言えません。すべてをコルーチンで実行するためrequestsの例よりは速いですが、こうしたすべてのリクエストを、asyncioタスクとして「同時に」実行することもできます。そして最後にasyncio.ensure_futureとasyncio.gatherを使用して結果を確認します。

実際にリクエストを出すコードがそのコルーチン関数に割り込む場合は、各リクエストのfuturesで構成されるタスクリストを作成できます。その後、このリストをアンパックして1回のgatherコールにし、すべてのタスクを同時に実行します。このコールをawaitしてasyncio.gatherに渡すとき、代入したすべてのfuturesのイテラブルが戻ります。出力ではリストでの順序が維持されます。この方法なら待機は1度きりです。

実装するとどうなるか、次のコードを実行してみましょう。

import aiohttp
import asyncio
import time

start_time = time.time()


async def get_pokemon(session, url):
    async with session.get(url) as resp:
        pokemon = await resp.json()
        return pokemon['name']


async def main():

    async with aiohttp.ClientSession() as session:

        tasks = []
        for number in range(1, 151):
            url = f'https://pokeapi.co/api/v2/pokemon/{number}'
            tasks.append(asyncio.ensure_future(get_pokemon(session, url)))

        original_pokemon = await asyncio.gather(*tasks)
        for pokemon in original_pokemon:
            print(pokemon)

asyncio.run(main())
print("--- %s seconds ---" % (time.time() - start_time))

この方法では、150回のHTTPリクエストにかかる時間がわずか1.53秒まで短縮されます。最初のasync/awaitの例と比較しても大きな改善です。この例は完全にノンブロッキングであるため、150回のリクエストをすべて実行する合計時間は、最も時間がかかるリクエスト1回の実行時間とほぼ同じになります。実際の数値は、ご利用のインターネット接続により変わります。

asyncio - async

まとめ

ご覧のとおり、aiohttpのようなライブラリを使用してHTTPリクエストの方法を見直すことにより、コードのパフォーマンスを大幅に向上させ、多数のリクエストの処理時間を大幅に短縮できます。既定では、requestsのような同期ライブラリよりも少し長いコードになりますが、これはパフォーマンスを最優先に考える開発者の目的にかなうものです。

このチュートリアルでは、aiohttpとasyncioを使用して何ができるかについてごく初歩的な説明をしました。これを手始めに、非同期Pythonの世界を少しでも身近に感じていただけるようになれば幸いです。

何を構築されるか、とても楽しみです。体験談の共有やご質問など、どうぞお気軽にお問い合わせください。