こんにちは。暗号通貨のボッターとして活動している黒枝と申します。
Advent Calendarの7日目と8日目の枠をお借りしています。
この記事ではPythonやNodejsで利用することのできるasync/await
を用いた非同期処理について、その背景や実際のところ何を実現したいのかについて考えてみたいと思います。
この記事の目標は次のような状態を、根っこの部分を知ることで抜け出すことです。
「あれ、なんでこれ動かないんだろう…あ、await取ったら動いた。よくわからないけどまあいいか」
シングルスレッドで扱う非同期処理は安定したウェブアプリケーションを書くために非常に有効ですが、理解が浅いとアプリケーションの動きを阻害してしまいます。
本稿では、この機能をイベントループという仕組みに触れながら、前編と、後編の二回に分けて解説を試みています。
コンセプトの掘り下げを本題のため、どのようにパラレルな呼び出しをするかや、どうやって複数の非同期処理を待つのかといった実用面はカバーしていません。また、コードの中では個人的に理解の深いNode.jsのコードを合わせて取り上げています。Nodejsはasync/await
を利用した仕組みを単一の機能というよりも言語の中心として据えていますが、働きはPythonのものとほぼ同じです。
PythonとNode.jsでの非同期処理の比較
例として用いるため、まずは簡単にPythonとNode.jsの非同期処理を比較しておきたいと思います。
Pythonではこんな感じ。
import aiohttp
impor asyncio
async def getData(url):
async with aiohttp.ClientSession() as session:
res = await session.get(url)
return await res.json()
# Execution
async def main():
data = await getData("https://...")
...
同じようなコードをNodejsで書くとこうなります。ちなみに最近のNodejsは末尾にセミコロンをつけないのが流行りです。
async function getData(url){
return await https.get(url)
}
// Execution
async function main() {
const data = await getData('https://...')
...
}
Pythonで用いたwithの部分が異なるだけで、似たように書けますね。実際内部でやっていることもほとんど同じという認識で、共通した思想で設計された機能かと思います。
asyncとawait
前章で紹介した簡単な処理は、実行すると単純にデータを取得するまで待ってくれて、エラーを起こさずに値を扱えます。たとえば提供されているAPIを叩いて暗号通貨の値段を取ってきたりします。
この素直に利用法は、一見データの取得を待つために使っているように見えます。そして実際このスコープでは同期的に待ってくれています。
async/await
を使い始めたばかりだと、この目に見える動作の部分に引っ張られて、これを「非同期処理を待ってくれる機能」だと考えてしまいがちなのではないかと思います。
しかし実際のasync/await
の機能は、イベントループという管理機能に対して「いったんこっちのことは良いから他の処理をすすめてくださいね」とメッセージを出す機能だと考えられます。
大事なことなので繰り返しますが、awaitは外部からRESTでデータを取得するといった処理を待つためだけに使う機能ではありません。
次章では、このイベントループという仕組みについて見ていきましょう。
イベントループとは何か
PythonとNode.jsは非同期処理をイベントループという仕組みで処理しています。
このイベントループが何をしているかというと、単純にタスクを監視して、準備が整っていないならどんどんと次のタスクを呼び出しているだけです。こうして処理を滞らせることなく全ての機能を進めていきます。
このとき、私達がイベントループに制御を返したいと思ったときに使うのがawaitなのです。
先程の何らかの外部からの呼び出しを同期的に待つ動きだけを見ていても、このタスクを監視する、というイベントループの動作はイメージしづらいのではないかと思います。ここがasync/await
でつまづきやすいポイントではないでしょうか。
そこで、次章ではイベントループと非同期処理が本領を発揮している様子を確認するために、複数のループが回っている状態を見ていきたいと思います。
ループと非同期処理
シンプルな例にするために、今回はNodejsで書いてみたいと思います。Pythonとは異なり、ループの呼び出し時にcreate_taskなどは必要ないため、すっきりしています。
const sleep = (seconds) => new Promise(r => setTimeout(r, seconds * 1000))
async function hello() {
while(true) {
console.log('Hello')
await sleep(2)
}
}
async function bye() {
while(true) {
console.log('Bye')
await sleep(5)
}
}
hello()
bye()
非常に簡単な処理ですが、2秒毎にHello、5秒毎にByeと返すだけの2つのループです。結果は次のようになります。
何も不思議なことはなかったかと思いますが、この仕組を理解しておくことは大切です。
2つの関数はasyncを伴って定義された非同期関数です。内部では無限ループを実行しており、その内側で非同期関数であるsleep(この実装の細かい部分は気にしないで下さい)を呼び出しています。
これが、まさにイベントループに制御を返している、ということです。
要約すると、asyncで定義された空間で、awaitを利用して何らかの非同期関数を呼び出したときに、いったん制御がイベントループに戻ります。イベントループは他の処理も監視していますから、そういった別の処理を先に実行し、また元の処理に戻ってきます。そしてawaitされていた処理(たとえばbye関数のsleep(5)
)が終わっていたなら、また処理を進めます(この場合はbye関数のループを再開します)。
言葉に戻すと少しややこしく感じますが、起こることは先程のコードの例で見たとおりです。awaitを宣言することで、制御をイベントループという制御機構に返すだけです。
ただし、helloとbye関数の呼び出しにはawaitをつけていません。もしここでhelloにawaitをつけて呼び出すと何が起こるでしょうか?
この場合は、helloが終了するまでイベントループが待ち続けます。しかしhelloはwhileを使った無限ループですので、この空間はここで永遠に止まったままになります。すなわち、bye関数が呼び出されることはなく、ターミナルにはHelloの文字だけが2秒毎に表示されるだけになってしまいます。
たとえば、このように実行します。
(async () => {
await hello()
await bye()
})()
誤ってawaitを使ってしまったため、bye関数が呼び出されることはありません。
また、当たり前のことに感じるかもしれませんが、awaitが働くのは非同期関数の手前で宣言した場合のみである、ということも忘れないようにしましょう。
今日のまとめ
本日はasync/await
とイベントループの基本的な動作を確認しました。明日の後編では、冒頭で紹介させていただいているPybottersでの利用例と、もう少しややこしいループ処理の動作を順番に確認してみたいと思います。
明日もよろしくお願いいたします。