async awaitはなぜ必要なのか?


「非同期処理」が重要なのはなぜか

Apache Http Serverは接続ごとにプロセスを生成するような仕組みです。このため、接続してくるクライアントがものすごく多くなると、メモリ領域を圧迫したり、レスポンス性能が壊滅的に低くなります。

この問題は、C10K問題(Client 10,000)と呼ばれ、これを解決するには、小さいリソースで多数のクライアントに対応できる仕組みが必要でした。

その具体的な解決策として出てきたのが、NginxやNode.jsです。今回はJavaScriptにおけるasync awaitに着目しますので、Node.jsについて焦点を当てます。

Node.jsでは、多数の処理要求をシングルスレッドで(少ないリソースで)高速に捌けるよう、ノンブロッキングIOを実現するアーキテクチャが採用されています。このあたりの詳しい説明は「Node.jsでのイベントループの仕組みとタイマーについて」で学びましょう。

要求を捌いてくれるスレッドは1つだけなわけですから、仮に全ての処理が同期的に行われてしまうと、レスポンス性能が非常に低くなります。クライアントからの処理を1つずつ順番に完了させていくような感じになるわけで、DBアクセスやファイルアクセスのようなIO処理で生じる待ち時間はボーッと待つだけです。当然ながら、その待ち時間で他の処理を進めた方が好ましいでしょう。

つまり、ある処理でIO等の待ちが発生する場合、他の処理に速やかにバトンを渡してあげる必要があるのです。これを実現する手段こそが非同期処理です。

私たちの書くコードで、待ちが発生するような重い処理を呼び出す場合、「この重い処理が終わったら、このコールバック関数を呼んでね」という感じでコールバック関数を指定しますよね。これは同期的な流れをいったん断ち切り、他の処理にバトンを渡しているということです。

以上が、非同期処理というものが必要な理由です。

async awaitが必要な理由は何か

非同期処理をうまく使うことで他の処理にバトンを渡す、という大前提の方針のなかで私たちはコードを書いていくことになります。

とはいえ、他の処理にバトンを渡して、すぐに次の処理(次の行のコード)を実行して良いかというと、多くの場合そんなことはないはずです。

例えば、DBを参照する処理であれば、その次にDBから取得したデータを使って何らかの処理をするでしょう。DBを参照する処理が完了するまで、次に行ってはいけないのです。

ここで必要なのは、「他の処理にバトンを渡しながらも、自分のコードでは次に進まずに止まる」ということです。

これを、より直感的なコーディングスタイルで実現できるのが、async awaitです。

async await(及びその前段のPromise)については、「JavaScript Promiseの本」で学びましょう。

私がasync awaitを知った時は、「非同期処理を活用すべき環境のなかで、無理やり同期処理を実現していて、何が嬉しいのか分からないな・・」と感じたものです。これは、awaitで待っている間、他の処理もブロックされている、という誤解から生じた考えでした。

Node.jsのノンブロッキングIOという環境の中で、他の処理にバトンを渡しつつも、自分のコードの処理では次に進まずに、重い処理が終わるまでジッと待つ。このような器用なことを実現するために、async awaitがあるわけです。

こういった大枠を頭の片隅に置いて、以下の説明をご覧いただければと思います。

Promiseだけだと なぜ困るのか

async awaitはPromiseを使いやすくするためのものですが、Promiseそのものは非同期処理の状態を管理する役目しかありません。

Promiseにthen()でコールバックを登録したら、さっさと処理は先に進んでしまいます。まさにノンブロッキングIO的な動きです。

例えば、以下のようにAWS SDKでPromiseを使う場合を考えてみましょう。

s3.putObject(params).promise()
  .then(data => {
    console.log(data)
    console.log("putObjcet successfully.")
})

console.log("end.")

// 実行結果は以下のとおりです。
//   end.
//   (dataの中身が出力されます)
//   putObjcet successfully.

s3.putObject(params).promise()で返却されたPromiseでresolve()が呼ばれた後、thenの中身が実行される、という順序関係だけが保証されます。

thenメソッドでやっていることは、あくまでもコールバックの登録であり、コールバックそのものを実行しているわけでは無いのです。

ですので、このままだと

console.log("putObjcet successfully.")の後にconsole.log("end.")が呼ばれる、ということは保証されません。

そういった順序関係を保証したいのであれば、以下のようにすべきです。

s3.putObject(params).promise()
  .then(data => {
    console.log(data)
    console.log("putObjcet successfully.")
}).then(() => {
    console.log("end.")
})

// 実行結果は以下のとおりです。
//   (dataの中身が出力されます)
//   putObjcet successfully.
//   end.

ですが、これだと読みづらいのです。やりたいことは単純なのに、何だか込み入ったことをやっているような感じになってしまいます。

そこでasync awaitの出番です。

async awaitを使うと どうなるか

async awaitを使うと、以下のようになります。なお、以下のコードはasync関数の中に実装されていると思ってください。

const data = await s3.putObject(params).promise()

console.log(data)
console.log("putObjcet successfully.")
console.log("end.")

// 実行結果は以下のとおりです。
//   (dataの中身が出力されます)
//   putObjcet successfully.
//   end.

このように、putObjectの実行結果を待ち、次に進んでいます。やりたいことは単純であり、コードもそれと同じくらい単純で読みやすいはずです。

「こんなの、Javaとかではasyncとかawait等と書かなくても、普通にできることでは?」と思うかもしれません。

ですが前述の通り、Node.jsの世界では「非同期処理をうまく使うことで他の処理にバトンを渡す」という大前提の方針のなかで、私たちはコードを書くのです。Javaではできない「シングルスレッドによって大量の処理を捌く」という恩恵を得ながら、その中でJavaのような直感的なコーディングをできるようにするためのテクニックが、async awaitなのです。

そういった位置付けを把握すると、(私のような)モヤモヤは失くなるのではと思います。

AWS LambdaでのNode.js

AWS Lambdaでは言語としてJavaScript(Node.js)を選ぶことができます。

Lambdaは当然ながら基本的には1つの処理要求を受けて、単発で動作するものですから、上記で述べたような「多数のクライアントからの処理要求を捌く」といった状況にはありません。

クライアントが1つしかいないような状況ですから、純粋に自分の処理が同期的に進むようにコードを書けば良いでしょう。

DBアクセスのような時間のかかるIO処理を呼び出すときには、誰にバトンを渡すわけでもないですが、async awaitを使って自分の処理が同期的に進むようにコードを書く、ということですね。

実現したいことが、そういった単純なことであれば、awaitを使って同期的に実装してもOKです。

しかし、いくつかの独立した非同期処理を呼び出して並列で処理したい、ということであれば、awaitでは実現できません。awaitでは対象の処理が完了するのを待って、次に進むからです。シーケンシャルなのです。そういった場合は、awaitではなく、Promise.all()などを上手に使う必要が出てきます。

つまり、AWS Lambdaでは「多数のクライアントからの処理要求を捌く」といった状況には無いのですが、それ以外のケースでは「シングルスレッドによって多数の並列処理を高速に実行する」という恩恵を得ることはできるのです。

こういった点を踏まえてAWS Lambdaに接していくと、良い感じのコードを書けるのではと思います。