この記事は非同期処理の知識が0の私が
こちらの記事やその他の情報をまとめたものになります。
私自身細かいコードを書くわけではないので、この記事ではざっくりとした理解を目的とし、他の記事をスラスラ読めるようになることができればいいというポジションです。
asyincio とは
並列処理には「マルチスレッド」、「マルチプロセス」、「ノンブロッキング」の3種類ありますが、asyncioは「ノンブロッキング」に相当します。
詳細な説明はこちら
に委ねます。
「ノンブロッキング」とは、1スレッドで複数のリクエストを処理するもので、スレッドが増えてメモリ不足にならないというメリットがあります。
デメリットや使い所、書き方の基本はこの記事を読むことでわかるようになります。
asyncioのサンプルコード
まずは次の例を見てみましょう。async, awaitなどが見慣れないですが、二つの非同期関数があり、それぞれにスリープの処理が入っているだけです。
import asyncio
async def func1():
print('func1() started')
await asyncio.sleep(1)
print('func1() finished')
async def func2():
print('func2() started')
await asyncio.sleep(1)
print('func2() finished')
async def main():
task1 = asyncio.create_task(func1())
task2 = asyncio.create_task(func2())
await task1
await task2
asyncio.run(main()) # こちらpython3.7以降の書き方らしいです
func1() started
func2() started
func1() finished
func2() finished
func1の処理の最中にfunc2の処理が割り込まれていますね。
なぜこの挙動になるのか少しずつ紐解いていきましょう。
知らなければならない前提知識が多数あるので、遠回りですが頑張りましょう。
前提知識1:コルーチン(co-routine)
まずはコルーチンというものを知ってもらいます。
サブルーチンがエントリーからリターンまでを一つの処理単位とするのに対し、コルーチンはいったん処理を中断した後、続きから処理を再開できます。
接頭辞 co は協調を意味します。「他の処理と協調します」ということでしょうね。
pythonにおける最小のコルーチンは次のようになります。
async def f(n):
return n
asyncを関数定義の先頭に置くことで、「これがコルーチンだ!」というのをpyhonに教えることになります。
しっかりと、asyncを書いていない関数内で、非同期処理をしようとするとエラーになります。
例えば次のコードはエラーになります。
import asyncio
def f():
await asyncio.sleep(1)
asyncio.run(f())
エラー文として'await' outside async functionと言われてしまいます。
asyncとawaitはセットだということを覚えておきましょう。
また、コルーチン関数は普通の関数と挙動が異なります。
import asyncio
async def f():
return 100
print(f)
<function f at 0x0000025479C403A0>
普通の関数であれば、100が返ってきますが、この例ではコルーチンオブジェクトという謎のオブジェクトが返ってきます。
このコルーチンオブジェクトを実行するには、asyncioのイベントループに放り込む必要があります。
前提知識2:イベントループ
調べるとイベントループはかなり複雑なことをしているようで、理解しきれなかったのですが、どうやら初学者レベルでは「複数のコルーチンをいい感じに管理・処理してくれる、複雑な処理のループ」くらいの理解で十分みたいです。
ただ、イベントループの機能をすべて理解しなくとも、1つくらいは知っておきましょう。
import asyncio
async def f():
print("fが実行されたよ")
return 100
coro = f() # コルーチンオブジェクトを作成
loop = asyncio.get_event_loop() # イベントループを作成
print(loop)
task = loop.create_task(coro) # イベントループにコルーチンを登録
print(task)
<ProactorEventLoop running=False closed=False debug=False>
<Task pending name='Task-1' coro=<f() running at c:/Users/username/Desktop/program.py:3>>
このように、コルーチンオブジェクトが登録され、それらを管理するというのがイベントループの機能の一端です。だいたいイメージできたかと思います。(そういうことにします笑)
(補足:コルーチンオブジェクトはコルーチン関数を呼び出すと返ってくるオブジェクトであり、コルーチン関数ではないです。)
print(task)で<Task pending name='Task-1'...と出力されていますが、こちらはタスクオブジェクトといい、コルーチンオブジェクトの実行状態を管理するオブジェクトです。
一旦、「ふ~ん」くらいのままでOKです。タスクオブジェクトについては、後でまたでてきます。
このtaskを実際に走らせるにはloop.run_until_complete()というメソッドを使います。
import asyncio
async def f():
print("fが実行されたよ")
return 100
coro = f() # コルーチンオブジェクトを作成
loop = asyncio.get_event_loop() # イベントループを作成
task = loop.create_task(coro) # イベントループにコルーチンを登録
loop.run_until_complete(task) # タスクが終了するまでイベントループを実行
# result = loop.run_until_complete(task) # このようにすればresultに100が格納される
fが実行されたよ
こんな、長ったらしいのを書いてようやく実行されましたね。
はっきり言ってこんなに書くのめんどくさいですよね。ということで、python3.7から使えるようになったasyncio.run()が便利です。
次のように短くなります。
coro = coro_func() # コルーチンオブジェクトを作成
asyncio.run(coro) # イベントループを作成し、コルーチンオブジェクトが終了するまで実行
これなら書く気になりますね。
asyncio.run()は次の処理を行うようです。
- あたらしくイベントループを作成します。
- 指定されたコルーチンオブジェクトをイベントループに登録し、タスクオブジェクトを作成します。
- タスクオブジェクトが完了するまでイベントループを実行し続けます。
便利ですね。これでイベントループのざっくりした理解を終えます。
最後にもう一度いうと、イベントループは「複数のコルーチンをいい感じに管理・処理してくれる、複雑な処理のループ」です。
前提知識3:Future
Futureは処理の結果を格納するためのオブジェクトです。
JavaScriptでいうPromiseに相当するらしいです。
Futureには「結果」と「ステータス」という2つの情報を持っています。
次のコードを見てください。
>>> future = loop.create_future()
>>> future.set_result(100)
>>> future
<Future finished result=100>
future がfinishedというステータスと100という結果を持っている事がわかりますね。
ステータスには「pending」「finished」「cancelled」の3種類があり、 初期状態は「pending」で、終了状態は「finished」か「cancelled」のいずれかです。
なお、通常ではこの例のようにわざとFutureオブジェクトが作られることはありません。このオブジェクトはただ単に「状態と結果を保存するための箱」です。この「箱」をうまく使ってイベントループは処理の状態を把握します。
前提知識4:Future/Coroutine/Taskの違い
個人的に気になったので、これまでのまとめも兼ねて、ここで整理しておきます。
公式ドキュメント1,公式ドキュメント2を見るとざっくり理解できます。
タスクは、イベントループ内のコルーチンオブジェクトの実行を担当する。タスクはFutureのサブクラスである。
と公式ドキュメントに書いてありました。
あくまで私の理解ですが、TaskはFutureという処理の状態を記述できる用紙を片手に、実行命令をCoroutineに出したり、処理結果や処理状況をFutureを通してCoroutineから受け取り、上司であるイベントループにFutureを通して報告するのが役割だと思います(かなり不正確ですがこの記事ではイメージがつかめればOKです)。
相関図は次の通りです。これだけの情報だと「タスクいらない子」になるので、実際のTaskはもっと細かいことをやっているのだと思います。
await
やっとずっとモヤモヤしていたawaitについて解説できますね。
awaitにはルールがあります。
await文に指定できるオブジェクトは、 CoroutineとFuture、Taskの3つです(これらをAwaitableオブジェクトと言います)。
ただ、Futureを直接使うことは無いので
await taskオブジェクト
await Coroutineオブジェクト
のようにして使うみたいです。
「CoroutiineやTaskを実行させる行では、書類Futureのやり取りが必要になるため、そのような行ではwaitを先頭につけるといる決まりになっている」といってしまったほうがわかりやすいかもしれませんね。
asyncio.sleep(1)とtime.sleep(1)の違いからわかる全体像
ここで衝撃の事実をお伝えするのですが、asyncioは一つのタスクしか実行できません。
「あれ?並列処理じゃないの?」と思いますが、asyncioは「CPUを使わない待ち」が生じてる間に次に優先順位の高いタスクにCPUのリソースを割り当てることしかできません。
(決して、CPUのリソースを要する処理を2つ同時に実行できるわけではありません)
結局のところ、asyncio.sleep(1)とtime.sleep(1)の違いは「この処理はCPUを使わない待ちだよー」というのを、イベントループに伝える能力の有無にあります。
ここで、冒頭のサンプルコードとの違いに注意しながら次のコードを見てください。
import asyncio
import time
async def func1():
print('func1() started')
await asyncio.sleep(1) # 上に「CPUを使わない待ち」ということを伝え,CPUの制御をイベントループに渡す
print('func1() finished')
async def func2():
print('func2() started')
time.sleep(2) # 上に「CPUを使わない待ち」ということを伝えられず終わるまで待つしか無い
print('func2() finished')
async def main():
task1 = asyncio.create_task(func1())
task2 = asyncio.create_task(func2())
await task1 # タスクもawaitがないと、イベントループに「CPUを使わない待ち」ということを伝えられない
await task2
asyncio.run(main())
func1() started
func2() started
func2() finished
func1() finished
冒頭のサンプルコードとは異なり、func1()とfunc2()の終了タイミングが逆転していますね。これは、func2()の「待ち」の間にfunc1()に制御が戻らなかったことを意味しています。
「func2() started」が表示された直後までの流れのざっくりとしたイメージ図は次のようになります。
time.sleep()はコルーチンではなくサブルーチンなので、イベントループに「CPUを使わない待ち」ということを伝えられず、素直に終わるまで待つしか無いです。
また、別の捉え方をすると、asyncio.sleep()で他人に順番を譲ることはできても、自発的に割り込むことはできないということになります。
なお、「CPUを使わない待ち」が実際に生じる場面は、「webへの問い合わせを行った際に、結果が帰ってくるまで待つ」などが挙げられます。確かに、web関連のライブラリでよくasyncioを見ますね。
以上、ざっくりとした説明でしたが、ご指摘等あればコメントいただけると幸いです。
勉強しながら記事を書いたので正確ではない部分もありますが、用語を解説しながら説明したので初心者に優しい文章にはなっているはずです。(しかし文章力。。)
ここまで理解すれば、他の記事の情報もスムーズに理解できると思います。(私自身が)

