図解でわかる asyncio 入門

# 前置き
業務で大量のWeb APIを並行で投げる必要があり、良い方法がないか探したところ、asyncio が良いソリューションだったため勉強した。勉強したことをまとめて、アウトプットするということで情報をここで公開する。これから asyncio を学習する人の役に立つとうれしい。

## 想定読者
- 並列・並行処理の概念はわかっているが、asyncio の処理がいまいち理解できてないので理解したい人

## 検証環境
- Python 3.9.4
- Jupyter Notebook 上で検証

# asyncio とは
asyncio ([→公式ドキュメント](https://docs.python.org/ja/3/library/asyncio.html)) とはざっくり言ってしまうと、**`async/await` 構文を使ってシングルスレッドで並行処理を行うためのライブラリ**。
Python には並列処理・並行処理を行うためのライブラリがいくつかあるが、**シングルスレッドで動く**のがポイント。下記図のように、あるライブラリではマルチプロセスで動作し並列処理を行い、また別のライブラリではマルチスレッドで動作して並行処理を行う。
![図1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280227/561b2011-3f0b-687c-19b7-ce8517ff48ef.png)
上記図で示している通り、Pythonでは**マルチスレッドとシングルスレッドでは**扱えるCPUに差がないため、**処理速度向上の点であまり差がない**。強いて違いを挙げるとすると、マルチスレッドによる並行処理では実行するスレッドを[GIL](https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%AD%E3%83%BC%E3%83%90%E3%83%AB%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%97%E3%83%AA%E3%82%BF%E3%83%AD%E3%83%83%E3%82%AF)が管理するため、実装者は実行の切り替わりをコントロールできないが、asyncioを利用したシングルスレッドによる並行処理は`await`式によってある程度切り替わりをコントロールできる点。加えて、スレッドを立ち上げで管理する分、マルチスレッドのほうが処理コストが大きい可能性がある。マルチスレッドで扱えるCPUが増えないのはPythonの[GIL](https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%AD%E3%83%BC%E3%83%90%E3%83%AB%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%97%E3%83%AA%E3%82%BF%E3%83%AD%E3%83%83%E3%82%AF)によるものだが、ここでは説明を割愛する。

この記事ではシングルスレッドで並行処理を行う asyncio を解説する。

# asyncioによる並行処理の概念
ざっくり言うと、下記図のように**複数のコルーチン**(後続で説明)**をそれぞれブロック化**して、イベントループなる実行機構がブロックを全部消費するまでIteratorのように**1つ1つブロックを順番を管理しながら処理していく**のがasyncioによる並行処理のイメージになる。
![図2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280227/66a05c36-a71e-b609-2a18-2e95b8476924.png)
このコルーチンを作るのが `async`で、ブロックを区切っていくのが `await` になる。
asyncio は `async` と `await` の挙動を理解できればほぼ理解できたと言っても過言ではない。

# `async`と`await`
## `async`
`async` とは**コルーチンを作る文**である。**コルーチンとは、中断・再開ができる処理の一連(ルーチン)**である。補足としてルーチンには、メインルーチン、サブルーチン、コルーチンがあり、関係を図示すると以下の図のようになる。
![図3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280227/ca90f0af-f5e5-0e74-ce9e-97cc40ff9108.png)
Pythonにおけるコルーチンは関数のように定義できるが、通常のように関数呼び出しでは実行できない。

```python
async def coroutine():
    print('コルーチンです。コンニチハ！')
    return

coroutine()
```

```
<coroutine object coroutine at 0x0000028BA8FFD0C0>
```

コルーチンを実行するには実行機構（イベントループ）が必要になる。実行するための高レベルAPIとして `asyncio.run()` があるので、基本それを使うことになる。

```python
asyncio.run(coroutine())
```

```
コルーチンです。コンニチハ！
```

**注意**: Jupyter Notebookではイベントループがすでに立ち上がっており、各セルはイベントループ内で実行されるため、 `asyncio.run()` が使えず、代わりに `await coroutine()`のように `await` を使う。入れ子のようにイベントループ内でイベントループを作ることができないためである。

## `await`
`await` は `await` の対象となる **Awaitable オブジェクトの実行結果が返ってくるまで**、`await`が宣言された**実行中のコルーチンを一時停止させ、計算リソースを手放す式**である。ざっくり `await` のイメージを説明すると、コルーチンの内部を `await` の前と後で分割してブロック化する感じになる。例えば以下のようなコルーチンがあった場合、①で `await` が実行されるとコルーチンである `say_after` が①の処理が終わるまで一時停止し、計算リソースを手放す。空いた計算リソースはイベントループが管理しているReady（実行開始可能）なコルーチンに割り当てられる。

```python
async def say_after(delay, what):
    print(f'before_sleep: {what}')
    await asyncio.sleep(delay) # --①
    print(what)
```

`await`は実行中のコルーチンを一時停止させる式なので、コルーチンの中でしか宣言できない。また式なので、実行結果を `=` で変数に代入することができる。
Awaitableオブジェクトはコルーチン、`Task`(後述)、`Future`の3種があるが、基本的にコルーチンと`Task`だけ覚えておけばよい。`Future`は`Task`の低レベルAPIであるためである。

実際に `await` 挙動を確認するため、以下のコードの実行を例に実行処理順を解説する。

```python
async def say_after(delay, what):
    print(f'before_sleep: {what}')
    await asyncio.sleep(delay)
    print(what)

async def execute():
    print(f'started at {time.strftime("%X")}')
    await say_after(1, 'hello')
    await say_after(2, 'world')
    print(f'ended at {time.strftime("%X")}')

asyncio.run(execute())
```

```
started at 17:56:57
before_sleep: hello
hello
before_sleep: world
world
ended at 17:57:00
```
ポイントは並行処理されていない点にある (1+2秒で3秒掛かってる)。`async/await`構文を使っただけでは並行処理されない。`await`の挙動により実際の実行処理は以下の図の通りになる。
![図4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280227/7228a841-61be-1a81-e538-21482713c7f9.png)
並行処理がされなかったのは、`sleep`中にReadyなコルーチンがなかったためである。基本的にコルーチンは`await`されて初めてReadyになるため、`say_after(2, 'world')` は並行して実行されなかった。

asyncioで並行処理を行うためには、`await`をせずにコルーチンをReadyな状態にする機能が必要になる。それが次の章で説明する`Task`である。

# `Task`と`Task`による並行処理
`Task`とは**コルーチンをReadyの状態にし、実行状態と結果を管理するもの**である。`Task`は`asyncio.create_task(coroutine())`で作成できる。Ready状態にするだけなので、`Task`を作っただけではコルーチンは実行されない。どこかのコルーチンが一時停止状態になって計算リソースが開放されて初めて処理が実行される。

実際に`Task`の挙動を確認するため、以下のコードの実行を例に実行処理順を解説する。

```python
async def say_after(delay, what):
    print(f'before_sleep: {what}')
    await asyncio.sleep(delay)
    print(what)

async def execute2():
    print(f'started at {time.strftime("%X")}')
    task = asyncio.create_task(say_after(2, 'world'))
    await say_after(1, 'hello')
    await task
    print(f'ended at {time.strftime("%X")}')

asyncio.run(execute2())
```
```
started at 18:51:38
before_sleep: hello
before_sleep: world
hello
world
ended at 18:51:40
```
ポイントは`say_after()`が並行処理されている点である(先ほどとは違い2秒しかかかってない)。`Task`の挙動により実際の実行処理は以下の図の通りになる。
![図5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280227/882d98f2-1e14-84f9-7654-958ffe772cd2.png)
このように`Task`を作成すると、コルーチンをReadyの状態にし、計算リソースが開放されると処理が割り当てられるようになる。ただし、コルーチンのReady状態になる順番通りに計算リソースが割り当てられるわけではなく、イベントループの処理ロジックによって割り当てされるため実行順序には注意されたい（経験上、おおよそReady状態になった順番通りにリソースが割り当てられる）。

asyncioによる並行処理は基本的にこの`Task`を利用することになる。並行化したいものはとりあえず`Task`を作成し、それを`await`すればよい。

```python
async def factorial(name, number):
    f = 1
    for i in range(2, number + 1):
        print(f"Task {name}: Compute factorial({number}), currently i={i}...")
        await asyncio.sleep(1)
        f *= i
    print(f"Task {name}: factorial({number}) = {f}")
    return f

async def execute():
    task_a = asyncio.create_task(factorial('A', 2))
    task_b = asyncio.create_task(factorial('B', 3))
    task_c = asyncio.create_task(factorial('C', 4))

    L = []
    L.append(await task_a)
    L.append(await task_b)
    L.append(await task_c)

    print(L)

asyncio.run(execute())
```
```
Task A: Compute factorial(2), currently i=2...
Task B: Compute factorial(3), currently i=2...
Task C: Compute factorial(4), currently i=2...
Task A: factorial(2) = 2
Task B: Compute factorial(3), currently i=3...
Task C: Compute factorial(4), currently i=3...
Task B: factorial(3) = 6
Task C: Compute factorial(4), currently i=4...
Task C: factorial(4) = 24
[2, 6, 24]
```
ただし、`Task`を作って`await`をするやり方だと、`Task`が多くなったときにボイラープレートなコードが増えてしまう(`create_task(...), create_task(...)`, `await task1, await task2 ...`)。これを解決するやり方としてasyncioは高レベルAPIとして`asyncio.gather()`というAPIを提供している。
`asyncio.gather()`を使うと上記コードは以下のように書き換えられる。

```python
async def execute2():
    # Schedule three calls *concurrently*:
    L = await asyncio.gather(
        factorial("A", 2),
        factorial("B", 3),
        factorial("C", 4),
    )
    print(L)
```
多くの`Task`を扱う場合はこちらを使うとよい。

## asyncioを利用したWebAPIリクエストの並行化
もし多くのWebAPIを並行でリクエストしたい場合は、リクエストAPIがコルーチンで実装された`aiohttp`(→[公式ドキュメント](https://docs.aiohttp.org/en/stable/))ライブラリを活用するとよい。Pythonでデファクトとして利用される `requests` はコルーチンで実装されてないため、並行化ができないためである。実装例は以下の参考文献を参照されたい。

- [aiohttpとasyncioを使用したPythonの非同期HTTPリクエスト](https://www.twilio.com/blog/asynchronous-http-requests-in-python-with-aiohttp-jp)



# まとめ
- asyncioは`async`と`await`を理解すればさほど難しくない
- 大抵のことは公式ドキュメントに載っている


（え？いまさら asyncio とか遅くない？という批評は重々承知してます・・・）
（もし間違っている部分があったら直すのでコメントいただけると幸いです）
# 参考文献
- [asyncio --- 非同期 I/O](https://docs.python.org/ja/3/library/asyncio.html)
- [Pythonの非同期asyncioについてのメモ](https://savo.hatenablog.com/entry/2019/05/02/170351)
- [Pythonのthreadingとmultiprocessingを完全理解](https://qiita.com/kaitolucifer/items/e4ace07bd8e112388c75#1-5-gil)
- [Pythonの並行処理を理解したい [マルチスレッド編]](https://zenn.dev/ryo_kawamata/articles/python-concurrent-thread)
- [aiohttpとasyncioを使用したPythonの非同期HTTPリクエスト](https://www.twilio.com/blog/asynchronous-http-requests-in-python-with-aiohttp-jp)
- [GIL](https://ja.wikipedia.org/wiki/%E3%82%B0%E3%83%AD%E3%83%BC%E3%83%90%E3%83%AB%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%97%E3%83%AA%E3%82%BF%E3%83%AD%E3%83%83%E3%82%AF)