JavaScriptの非同期処理を理解する その1 〜コールバック編〜

こんにちは!小田島です。コロナ第二波が来ましたが、もともと出不精気味なので個人的にはあまり影響はありません。むしろ外出しない理由ができて堂々と引きこもっていられます。

これまで、さくらのナレッジではNode.jsDenoの話をしてきました。今回は、これらを使う上で欠かせない非同期処理について説明します。よろしくお願いします!

対象者

本記事は、こんな人が対象です。

  • JavaScriptの非同期処理はコールバックとかPromiseとかasync/awaitとかあるけど、どう違うの?どう使い分ければいいの?
  • Node.jsのコールバックって罠が多くて使いにくい
  • Promiseの仕組みがよくわからずになんとなく使っていた
  • async/awaitって中でどういうことをやってるのかわからないけど便利だよね
  • 非同期処理?async/awaitさえ覚えておけばいいんじゃない?
  • async/awaitって要はマルチスレッドみたいなもんでしょ?

最後は怒涛のasync/awaitラッシュです。

逆に、async/awaitをTypeScriptコンパイラで変換した結果を見て「なるほど!そういうことか!」と理解できる方にこの記事は不要です。そっとこのページを閉じて手品動画でも見ていてください。

3つの非同期処理

先ほど書いてしまいましたが、JavaScriptの非同期処理にはコールバックPromiseasync/awaitの3つがあります。今回は、その中のコールバックについて説明します。

コールバック

Node.jsの標準ライブラリーで採用されている方法です。処理が終わったら指定した関数が呼ばれる、原始的な方法です。モダンな開発ではあまり使うことはありませんが、非同期処理に関してはコールバックを理解することが全ての始まりと言っても過言ではありません。

例えばfs.readFile()は指定したファイルの中身を読み取る関数ですが、ファイルへのアクセスは(CPUにとっては)非常に時間がかかる処理で、しかもファイルにアクセスしている間はCPUが暇になるので非同期処理を活用できる典型的な例です。

function foo() {
  fs.readFile('/etc/passwd', (err, data) => {
    if (err) throw err;
    console.log(data);
  });
  console.log('foo');
}

この例では、Node.jsがOSに「/etc/passwdの中身を読み取り、読み取り終わったらコールバック関数を呼ぶ」ように指示しています。

ここで重要なのは、処理自体はあくまでシングルプロセス・シングルスレッドだという点です。ファイルの読み取りはCPUの管轄外なのでCPUとは独立して処理できますが、CPUを使う処理は明確に実行順序が付けられています。

具体的には、fs.readFile()を呼び出すとファイルの読み取り処理が非同期で行われますが、CPUの処理は次のconsole.log('foo');に移ります。そのため、ファイルの読み取りがどんなに速かったとしてもconsole.log('foo');の前にコールバック関数が呼ばれることはありません。これはJavaScriptにおける非同期処理の基本です。

イメージとしては下図のような感じです。読み取りが終わったらコールバック関数がイベントキューに入れられ、foo()の処理が終わったらコールバック関数が呼ばれます。逆に言えばfoo()が終わらない限りコールバック関数は呼ばれません

コールバック処理の流れ。コールバック関数は現在実行中の関数が終わらないと実行されない。

複数の非同期処理

では、次のように非同期処理が複数ある場合はどうでしょう。

function foo() {
  fs.readFile('/etc/passwd', (err, data) => {
    if (err) throw err;
    console.log(data);
  });
  fs.readFile('/etc/group', (err, data) => {
    if (err) throw err;
    console.log(data);
  });
  console.log('foo');
}

OSに/etc/password/etc/groupの順で読み取り指示を行い、2つのコールバック関数よりも先にconsole.log('foo');が実行されます。ここまでは先ほどの通りですが、2つのコールバック関数のどちらが先に呼ばれるかは決まっておらず、先に読み取り終わった方が実行されます

並列に非同期処理を行う場合。先に終わった方からコールバック関数が実行される。

これは以前にいまさら聞けないNode.jsで説明した非同期で家事をする例に該当します。洗濯機→炊飯器の順にスイッチを押しても、先に炊飯が終われば料理を先にします。

非同期的に家事を行う例

非同期処理に順序をつける

では非同期処理に順序を付けたい場合、つまり/etc/passwdの読み取りが終わってから/etc/groupを読み取りたい場合はどうすればいいでしょうか。

難しく考えず、/etc/passwordのコールバックの中で/etc/groupを読む処理を入れればOKです。

function foo() {
  // 最初に /etc/passwd を読み取る
  fs.readFile('/etc/passwd', (err, data) => {
    if (err) throw err;
    console.log(data);

    // 次に /etc/group を読み取る
    fs.readFile('/etc/group', (err, data) => {
      if (err) throw err;
      console.log(data);
    });
  });
  console.log('foo');
}

ただ、このように非同期処理を重ねていくとネストがどんどん深くなってコードが見づらくなり、流れも追いにくくなります。これがコールバック地獄です。

複数の非同期処理の待ち合わせ

それでは、こんな非同期処理を行いたい場合はどうしましょう。

  • 2つのファイルの読み込み処理を並列で実行して、どれか1つでも処理が終わったらコールバックを呼びたい
  • 2つのファイルの読み込み処理を並列で実行して、両方の処理が終わったらコールバックを呼びたい

前者は、例えばデータベースからデータを取得するとき、複数のレプリケーション先にクエリーを投げて一番早く返ってきたものを使いたいというときに便利です。

これらはどちらもコールバック方式ではサポートされていません。ちょっとした魔術を使えばできないことはありませんが、多分あまり美しいコードにはなりません。

コールバック関数からの例外送出

コールバック関数の説明の最後として、コールバック関数内の例外処理について見てみましょう。

例外処理といえばこういうやつです。

function foo() {
  try {
    throw new Error("░▒▓▇▅▂\(‘ω’)/▂▅▇▓▒░ うおあああああああああああ!!!!");
  }
  catch(err) {
    console.log(err);
  }
}

さて、これまで出てきたサンプルコードにもthrowがありましたが、これをキャッチするにはどうすればいいでしょうか。

 

「え、全体をtryで囲めばいいんじゃないの?」

 

なるほど。こういうことですね?

const fs = require('fs');

function foo() {
  try {
    fs.readFile('/no/such/file', (err, data) => {
      if (err) throw err;
      console.log(data);
    });
    console.log('foo');
  }
  catch(err) {
    console.log('caught', err);
  }
}
foo();

では実行してみましょう。

t-odashima@MacBook-Pro ~ % node foo.js
foo
/Users/t-odashima/tmp/foo.js:6
      if (err) throw err;
               ^

Error: ENOENT: no such file or directory, open '/no/such/file'

「ほらちゃんと例外が表示されて・・・ん?

 

出力をよく見ればわかりますが、これは正しく例外処理を行えていません。理由は2つ。

  • 'caught'というメッセージが表示されていない。catchの中でerrの中身を表示しているなら表示されているはず。
  • 'foo'が表示されている。例外処理を行えているなら表示されないはず。

なぜこのような結果になるのでしょうか。

上で説明した通り、コールバック関数はfoo()が終了しないと呼ばれません。つまりコールバック関数が呼ばれた頃にはfoo()の処理は終わっているので、foo()内のtry-catchでは何も処理できないのです。

早い話が、コールバック方式で例外処理を使おうとすると地獄を見ます

まとめ

第1回目では、JavaScriptのコールバックとして以下を説明しました。

  • Node.jsの標準ライブラリーでは非同期処理にコールバックを使っている
  • 複数の非同期処理を順に実行する場合、ネストがどんどん深くなる
  • 複数の非同期処理を並列に実行して完了を待ち合わせるのは難しい
  • コールバック関数は呼び出し元の関数が終了してから実行されるため、コールバック内から例外を投げても呼び出し元の関数では捕捉できない

次回のPromise編では、下3つの問題をどのように解決できるのかを見ていきましょう。お楽しみに!