18
11

More than 3 years have passed since last update.

Python の非同期処理/コルーチンを、Node.js との比較を通じて学ぶ

Last updated at Posted at 2020-08-29

Python は asyncio モジュールを import してコルーチンを定義することにより、非同期処理を実装していくというスタイルの言語です。
コルーチンとは一旦処理を中断したあと再開できるような構造のことで、一旦処理が行われたら完了するまで止まらない構造であるサブルーチンと対比されるものです。

今回は、私が以前から馴染んでいた Node.js の Promiseasync/await と比べて Python の非同期処理の書き方がどのように異なるのか、という点にスポットを当てて考えてみようと思います。

なお、Python のソースコードは公式ドキュメントの コルーチンと Task におおむね準拠しています。

コルーチン

Python では async def 文でコルーチンを定義します。

Python
import asyncio

async def main():
    print('hello')
    await asyncio.sleep(1)
    print('world')

asyncio.run(main())

これは "hello" と出力した 1 秒後に "world" と出力する例です。
コルーチンの中では await が使えます。 await asyncio.sleep(1) で 1 秒処理の解決を待ってから次の処理を始めるという、非同期処理の典型的な例です。

Node.js ではどうなるでしょうか?まず Node.js には Python の asyncio.sleep() のような組み込みの非同期関数はないので、

function sleep(sec) {
  return new Promise(resolve => {
    setTimeout(resolve, timeout=sec*1000);
  })
}

と自作します(以下、Node.js のソースコードではこの sleep 関数の定義は省略します)。そうすると以下のように書けます。

Node.js
async function main() {
  console.log('hello');
  await sleep(1);
  console.log('world');
}

main();

両者を比べてみると、Python のコルーチンによる非同期処理は、最上位のエントリポイントである main() を単に呼び出すのではなく、asyncio.run(main()) というように、asyncio.run() の引数に入れて実行する点に気を付けねばならないという違いがあります。

直列処理

await を並べると直列処理になります。

Python
import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    print(f"started at {time.strftime('%X')}")

    await say_after(1, 'hello')
    await say_after(2, 'world')

    print(f"finished at {time.strftime('%X')}")

asyncio.run(main())

これは、1秒待機した後 "hello" と出力し、さらに2秒待機した後 "world" と出力する例です。
Node.js で書くとだいたい以下のようになるでしょう。

Node.js
async function say_after(delay, what) {
  await sleep(delay);
  console.log(what);
}

async function main() {
  console.log(`started at ${new Date().toTimeString()}`);

  await say_after(1, 'hello');
  await say_after(2, 'world');

  console.log(`finished at ${new Date().toTimeString()}`);
}

main();

見た目は全然変わらないので、わかりやすいですね。

並行処理

Python では Tasks によってコルーチンを並行して走らせることができます。

Python
import asyncio
import time

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    task1 = asyncio.create_task(
        say_after(1, 'hello'))

    task2 = asyncio.create_task(
        say_after(2, 'world'))

    print(f"started at {time.strftime('%X')}")

    await task1
    await task2

    print(f"finished at {time.strftime('%X')}")

asyncio.run(main())

この例では、前回の例と違って、「2秒待機した後 "world" と出力する」操作が「1秒待機した後 "hello" と出力する」操作と同時に行われるため、前回よりも 1 秒早く終了します。
Python においては、asyncio.create_task() でコルーチンを Task としてラップし、その実行をスケジュールすることができます。

Node.js で書くとだいたい以下のようになるでしょう。

Node.js
async function say_after(delay, what) {
  await sleep(delay);
  console.log(what);
}

async function main() {
  const task1 = say_after(1, 'hello');
  const task2 = say_after(2, 'world');

  console.log(`started at ${new Date().toTimeString()}`);

  await Promise.all([task1, task2]);

  console.log(`finished at ${new Date().toTimeString()}`);
}

main();

Node.js の場合、Promise.all() が使えます。


もう一つ例を見ましょう。

Python
import asyncio

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

async def main():
    await asyncio.gather(
        factorial("A", 2),
        factorial("B", 3),
        factorial("C", 4),
    )

asyncio.run(main())

これは、毎回数を掛けるのを1秒遅らせて階乗を計算する関数です。今回は asyncio.gather() というのが出てきましたが、これも引数のコルーチンを Task としてスケジュールするものです。

Node.js で書くとだいたい以下のようになるでしょう。

Node.js
async function factorial(name, number) {
  let f = 1;
  for (let i=2;i<=number;++i) {
    console.log(`Task ${name}: Compute factorial(${i})...`);
    await sleep(1);
    f *= i;
  }
  console.log(`Task ${name}: factorial(${number}) = ${f}`);
}

async function main() {
  await Promise.all([
    factorial("A", 2),
    factorial("B", 3),
    factorial("C", 4)
  ]);
}

main();

タイムアウト

Python の非同期処理では、asyncio.wait_for() を用いて、一定の時間で処理が完了しなかった場合タイムアウトとして扱うことができます。

Python
import asyncio

async def eternity():
    await asyncio.sleep(3600)
    print('yay!')

async def main():
    try:
        await asyncio.wait_for(eternity(), timeout=1.0)
    except asyncio.TimeoutError:
        print('timeout!')

asyncio.run(main())

例えば上記の例では、eternity() という 3600 秒スリープする関数は、実際には 3600 秒待たれることなく 1 秒後にタイムアウトになります。

Node.js では(私の知る限り)これを簡潔に実装する方法が無いように思います。

確かに Promise.race() を使えば、

await Promise.race(eternity(), timeout(1.0))
  .catch(err => {
    console.log('timeout!');
  })

と擬似的に書くことはできます(timeout(sec)sec 秒後に reject を返す関数とします)。
しかし、この実装では 1秒後に "timeout!" と表示された後も eternity() の方の待機が続いてしまうのです。

参照: Use Promise.race to Timeout Promises

Caveat: Cleanup
The timeout does not cause the other promise to clean up or cancel. For example, if a database-write promise were to be Promise.race ‘d against a timeout, and if the timeout completed first, then the database write operation would still be running and may (eventually) succeed, but the rest of your application will think it failed. Make sure to introduce logic to cancel the operation in case your application initiates a timeout.
Handling cleanup and canceling logic is a tricky subject, as the feature is not built into JavaScript.

そう考えると、Python では非同期処理の中断が簡潔に書けるのはなかなか便利ですね。

18
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
11