Help us understand the problem. What is going on with this article?

python3 の async/awaitを理解する

More than 1 year has passed since last update.

TL;DR

await coroutineawait futureの違いが理解できたら公式ドキュメントを読もう。

はじめに

python3.5から導入されたasync/awaitだが入門記事を読んでもいまいちピンとこない。結局のところ公式ドキュメントを読むのが一番なのだが、頭から読むには分量が多すぎる。
そこで公式ドキュメントを読む前にこれを抑えておけば勘所がつかめるというものを書いてみた。

yieldyield fromについては知っていると理解が早いのだが、この記事ではそれらの知識は前提としないように書いた。

またasync/awaitはマルチスレッドと密接に関係していると思っている人もいるかもしれないが、pythonのasync/awaitは勝手にスレッドを生成したりは一切しない。この記事にあるコードも特に断りがない限りシングルスレッドで動作する。

async defはコルーチン関数定義

まずはasyncを使ってみる

hello.py
import asyncio

async def hello_world():
    print("Hello World!")

hello_world()

$ python3 hello.py
hello.py:6: RuntimeWarning: coroutine 'hello_world' was never awaited
  hello_world()

実行すると「'hello_world'コルーチンがawaitされてないよ」という警告がでる。
C#のasync/awaitに馴染んでる人だと「?」となるのではないだろうか。C#の場合はasyncがついていてもただの関数に過ぎないため、同じノリで考えると"Hello World!"は出力されそうだ。

しかしpythonのasync defは単なる関数定義ではなくコルーチン関数定義であり、実行するとコルーチンオブジェクトが返る。
コルーチンオブジェクトはイベントループ内でのみ実行が可能であり、上記のようなコードを書くと警告が出てしまう。

実際にhello_worldコルーチンをイベントループで実行してみたのが以下のコード

hello.py
import asyncio

async def hello_world():
    print("Hello World!")

loop = asyncio.get_event_loop()
loop.run_until_complete(hello_world())
$ python3 hello.py
Hello World!

awaitを使ってみる

import asyncio

async def hello_world():
    print("Hello World!")

async def call_hello_world():
    await hello_world()

loop = asyncio.get_event_loop()
loop.run_until_complete(call_hello_world())
$ python3 hello.py
Hello World!

awaitを使ってhello_world()が生成したコルーチンオブジェクトが実行されているように見える。

次の例はどうか

import asyncio
import time

async def hello_world(n):
    time.sleep(1)
    print("{}: Hello World!".format(n))

async def call_hello_world1():
    print("call_hello_world1()")
    await hello_world(1)

async def call_hello_world2():
    print("call_hello_world2()")
    await hello_world(2)

loop = asyncio.get_event_loop()
loop.create_task(call_hello_world1())
loop.run_until_complete(call_hello_world2())
$ python3 hello.py
call_hello_world1()
# 1秒待つ
1: Hello World!
call_hello_world2()
# 1秒待つ
2: Hello World!

以下のように出力されるのでは?と思った人もいるだろう

call_hello_world1()
call_hello_world2()
# 1秒待つ
1: Hello World!
2: Hello World!

上記のように出力してほしければ以下のようなコードになる。

import asyncio

async def hello_world(n):
    await asyncio.sleep(1)
    print("{}: Hello World!".format(n))

async def call_hello_world1():
    print("call_hello_world1()")
    await hello_world(1)

async def call_hello_world2():
    print("call_hello_world2()")
    await hello_world(2)

loop = asyncio.get_event_loop()
loop.create_task(call_hello_world1())
loop.run_until_complete(call_hello_world2())

先ほどとの違いはtime.sleep(1)await asyncio.sleep(1)に変わっていることだ。

自分はこのコードに遭遇したとき「await hello_world()hello_world()が関数の様に実行されるのに対して同じくawaitを使っているawait asyncio.sleep(1)は制御がイベントループに戻っている???」
と大変混乱した。ここらへんの挙動の違いを以下で説明したい。

await futureawait coroutine

ところで18.5.3.1. コルーチンを読んでいると気になる記述に出くわす。

コルーチンができること:

  • result = await future or result = yield from future -- suspends the coroutine until the future is done, then returns the future's result, or raises an exception, which will be propagated. (If the future is cancelled, it will raise a CancelledError exception.) Note that tasks are futures, and everything said about futures also applies to tasks.
  • result = await coroutine or result = yield from coroutine -- wait for another coroutine to produce a result (or raise an exception, which will be propagated). The coroutine expression must be a call to another coroutine.

どうやらpythonのawaitfuturecoroutineの2つが取れるらしいが、挙動が微妙に違う。
await futureの方はsuspends、つまり制御を手放すのに対してawait coroutineの方は単にcoroutineの終了を待つだけに見える。

ためしに以下のようなコードを書いてみる。

await_sample.py
import asyncio
import time

async def task_one():
    print("task_one: before sleep")
    await asyncio.sleep(0.1)
    print("task_one: after sleep")
    return 1

async def time_sleep():
    time.sleep(5)
    print("time_sleep")

async def task_two():
    print("task_two: before task_one")
    await time_sleep()
    print("task_two: after task_one")
    return 2

async def test(loop):
    t1 = loop.create_task(task_one())
    t2 = loop.create_task(task_two())

    print(repr(await t1))
    print(repr(await t2))

def main():
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(test(loop))
    finally:
        loop.close()

main()
$ python3 await_sample.py
task_one: before sleep
task_two: before task_one
# ここで5秒待つ
time_sleep
task_two: after task_one
task_one: after sleep
1
2

挙動としては、一見以下のように見える(が、間違っている)

  1. task_one()が実行され、await asyncio.sleep(0.1)で制御を手放す
  2. task_two()が開始されるがawait time_sleep()では制御を手放さずそのまま寝て返ってくる

asyncio.sleep()コルーチンである。また、time_sleep()もコルーチンのはずである。片方は制御を手放し、片方は制御を手放さない。これはどういうことだろうか?

ポイントはasyncio.sleep()の実装にある。

cpython/Lib/asyncio/tasks.py
async def sleep(delay, result=None, *, loop=None):
    """Coroutine that completes after a given time (in seconds)."""
    if delay <= 0:
        await __sleep0()
        return result

    if loop is None:
        loop = events.get_event_loop()
    future = loop.create_future()
    h = loop.call_later(delay,
                        futures._set_result_unless_cancelled,
                        future, result)
    try:
        return await future
    finally:
        h.cancel()

return await futureがあるのが見て取れる。
冒頭の記述を思い出して解釈するとサンプルコードの挙動は以下のようになる。

  1. task_one()が実行され、await asyncio.sleep(0.1)によりコルーチンであるasyncio.sleep()内部が実行される
  2. asyncio.sleep()内部のawait futureで制御を手放す
  3. task_two()が開始されawait time_sleep()により、コルーチンであるtime_sleep()内部が実行される
  4. time_sleep()内には制御を手放す構文はないためtime.sleep(5)ごとそのまま実行され呼び出し元に返る

ソケットを触るときのTips

asyncioでソケットを操作するときは必ずソケットをノンブロッキング(sock.setblocking(False))にすること。さもないとloop.sock_recv()等を呼び出したときにブロッキングされてしまい他のタスクが動作しなくなってしまう。

https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.sock_recv

itage
ITAGEは「IT」のAGENCYになることを夢、目標として進化、変化していきます。「It’s It Agency」
http://www.itage.co.jp
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした