非同期処理とは？Pythonでの具体的なコード例を紹介

[NTT コムウェア Advent Calendar 2023](https://qiita.com/advent-calendar/2023/nttcomware) 22日目の記事です

# はじめに
NTTコムウェアの寺島です。
今年の記事は、数年前まで案件にてNode.jsを触っていたことや、最近PythonのASGIアプリケーションに関わることもあり、改めて非同期処理の仕組みに関して理解しようと思い、記事執筆に至りました。

# 非同期処理/ノンブロッキングとは何か？
## 同期と非同期
- 同期：プログラムが各タスクを順番に実行し、前のタスクが完了するまで次のタスクを開始しないこと。
- 非同期：プログラムがタスクの完了を待たずに、次のタスクを進めることができること。
## ブロッキングとノンブロッキング
- ブロッキング：タスクが完了するまで、他のタスクやプログラムの実行が停止すること。
- ノンブロッキング：タスクの完了を待たずに次の処理を進めること。

# 非同期処理の基本概念
## イベントループの役割
- イベントループは一連のイベント（またはタスク）をループで処理していく仕組みです。非同期処理では、完了時間が不確定または待ち時間が伴うタスクをイベントとしてキューに追加します。そして、イベントループはそのキューから一つずつイベントを取り出し、即座に完了できるなら処理し、できない場合はそのイベントの完了を待つ代わりに次のイベントを取り出して処理します。
![イベントループイメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2303038/26fcaba6-60b7-1ec7-1f25-857a50408a1d.png)
- イベントループの利点は、重いI/O処理があっても、他のイベントをキューから取り出し逐次処理することで全体の実行をブロックせず、複数のタスクを効率的に進行できることです。ここでいう"同時"とは、タスクが並行的に実行され、それぞれが独自の進行を持つことを意味します。これは並列処理とは異なり、並列処理は複数のタスクが物理的に同時に実行されます。

## 具体的なイメージ
例えば、コンビニの店員がレジで同期的に処理を行った場合は、どうなるでしょうか？
- レジに列ができ、あるお客さまがレジでお弁当の温めをお願いしました。店員はお弁当を電子レンジで温め始め、温めが終わるまで電子レンジの前で待ち、温まったお弁当をお客さまに手渡しました。

次にコンビニの店員が非同期処理で対応した場合は、どうなるでしょうか？
- 同じシチュエーションで、店員は電子レンジの前で待つことなく、次のお客さまの対応を行い、温めが終われば、初めのお客さまに温めたお弁当を渡します。システムに当てはめると、このお弁当を温めるという処理はDBやNW通信などのI/O処理になります。非同期処理によって待ち時間が発生する間に別の処理を行うことで効率的な処理が可能です。

## Pythonアプリでの同期と非同期
次にPythonのWebアプリにおける同期処理と非同期処理の比較を行います。ここでは、ASGI対応のFastAPIを例に挙げます。

まず同期処理のコードになります（通常はこのようには書かない）。次のコードではGETリクエストを受け取り、別のWebアプリとの通信を行います。ここで後々の動きをわかりやすくするために、通信先の別のWebアプリは10秒後にレスポンスを返すように設定します。アプリの起動worker数は1とします。

```python
from fastapi import FastAPI
import requests

app = FastAPI()

@app.get("/")
async def root():
    res = requests.get("http://127.0.0.1:3000")
    return res.text
```
Webアプリにリクエストを送った後、3秒後に再度リクエストを送る場合、2回目のリクエストにレスポンスが返るまでにかかる時間は何秒でしょうか？
答えは約17秒になります。1回目の通信が同期処理であるため、その通信処理が完了するまで次の処理を受け付けないためです。以下が実際のリクエストとレスポンスになります。Execution Timeは通信先の実行時間結果、response_timeはcurlからのレスポンス時間になります。

実行例：1回目の送信
```bash
curl http://127.0.0.1:8000 -w "\nresponse_time:%{time_total}\n" 2> /dev/null -s

"Execution Time:10001ms"
response_time:10.012294
```

実行例：2回目の送信
```bash
curl http://localhost:8000 -w "\nresponse_time:%{time_total}\n" 2> /dev/null -s

"Execution Time:10000ms"
response_time:17.258400
```

次に、非同期のコードに変更します。ここではPythonの非同期通信ライブラリであるaiohttpを使用します。こちらのコードを簡単に説明すると「async with aiohttp.ClientSession() as session:」は非同期にHTTPセッションを作成し、「async with session.get("http://127.0.0.1:3000") as response:」はそのセッションを使って非同期にGETリクエストを送っています。

```python
from fastapi import FastAPI
import aiohttp

app = FastAPI()

async def get():
    async with aiohttp.ClientSession() as session:
        async with session.get("http://127.0.0.1:3000") as response:
            return await response.text()


@app.get("/")
async def root():
    res = await get()
    return res
```

同様にWebアプリに初めてリクエストを送った後、3秒後に再度リクエストを送る場合、2回目のリクエストにレスポンスが返るまでにかかる時間は何秒でしょうか？
答えは約10秒になります。1回目の通信が非同期処理であるため、外部への通信結果を待たずに次の処理を開始できるためです。以下が実際のリクエストとレスポンスになります。

実行例：1回目の送信
```bash
curl http://127.0.0.1:8000 -w "\nresponse_time:%{time_total}\n" 2> /dev/null -s

"Execution Time:10000ms"
response_time:10.015267
```

実行例：2回目の送信
```bash
curl http://localhost:8000 -w "\nresponse_time:%{time_total}\n" 2> /dev/null -s

"Execution Time:10000ms"
response_time:10.011548
```


# おわりに
ここまで、非同期処理の概念とそのPythonコード例を用いて、同期処理と非同期処理の違いを比較してみました。非同期処理を取り入れることによって、待ち時間が発生する間にも別の処理を行うことができ、全体の処理効率が改善します。しかし、非同期処理にも若干のデメリットとしてタスク管理（複数の非同期タスクがあり、その完了順序が重要な場合等）の複雑化やエラーデバッグに苦労することがあり注意が必要です。
この記事がみなさまの何か助けになれば幸いです。

※記載されている会社名、製品名、サービス名は、各社の商標または登録商標です