6時だョ!!全員集合!!

Rails・JavaScrictを中心にアウトプットします。

2022年2月1日 JavaScript (JS Primer) 非同期処理はメインスレッドで実行される

非同期処理はメインスレッドで実行される

メインスレッドは、ブラウザがユーザーのイベントや描画を処理するところです。表示の更新といったUIに関する処理も行っています。

非同期処理は名前から考えるとメインスレッド以外で実行されるように見えますが、 基本的には非同期処理も同期処理と同じようにメインスレッドで実行されます。

今回は非同期処理がどのようにメインスレッドで実行されているかを簡潔に見ていきます。

// 指定した`timeout`ミリ秒経過するまで同期的にブロックする関数
function blockTime(timeout) {
    const startTime = Date.now();
    while (true) {
        const diffTime = Date.now() - startTime;
        if (diffTime >= timeout) {
            return; // 指定時間経過したら関数の実行を終了
        }
    }
}

const startTime = Date.now();
// 10ミリ秒後にコールバック関数を呼び出すようにタイマーに登録する
setTimeout(() => {
    const endTime = Date.now();
    console.log(`非同期処理のコールバックが呼ばれるまで${endTime - startTime}ミリ秒かかりました`);
}, 10);
console.log("ブロックする処理を開始します");
blockTime(1000); // 1秒間処理をブロックする
console.log("ブロックする処理が完了しました");

上記の処理を実行すると1秒ほど経過した後に以下の内容が出力されました。
▶ブロックする処理を開始します
▶ブロックする処理が完了しました
▶非同期処理のコールバックが呼ばれるまで1014ミリ秒かかりました

非同期処理のsetTimeout関数がメインスレッドで行われていないのであれば、blockTime(1000);の最中にsetTimeoutが発火する事になりますが、setTimeoutはblockTime(1000);が終了した後に発火しています。つまり非同期処理もメインスレッドで行われている事になります。

多くの環境では、このときの非同期処理のコールバックが呼ばれるまでは1000ミリ秒以上かかります。 このように非同期処理も同期処理の影響を受けることから、同じスレッドで実行されていることがわかります。

JavaScriptでは一部の例外を除き非同期処理が並行処理(concurrent)として扱われます。 並行処理とは、処理を一定の単位ごとに分けて処理を切り替えながら実行することです。 そのため非同期処理の実行中にとても重たい処理があると、非同期処理の切り替えが遅れるという現象を引き起こします。

このようにJavaScriptの非同期処理も基本的には1つのメインスレッドで処理されています。 これはsetTimeout関数のコールバック関数から外側のスコープのデータへのアクセス方法に制限がないことからもわかります。 もし非同期処理が別スレッドで行われるならば、自由なデータへのアクセスは競合状態(レースコンディション)を引き起こしてしまうためです。

ただし、非同期処理の中にもメインスレッドとは別のスレッドで実行できるAPIが実行環境によっては存在します。 たとえばブラウザではWeb Worker APIを使い、メインスレッド以外でJavaScriptを実行できます。 このWeb Workerにおける非同期処理は並列処理(Parallel)です。 並列処理とは、排他的に複数の処理を同時に実行することです。

Web Workerではメインスレッドとは異なるWorkerスレッドで実行されるため、メインスレッドはWorkerスレッドの同期的にブロックする処理の影響を受けにくくなります。 ただし、Web Workerとメインスレッドでのデータのやり取りにはpostMessageというメソッドを利用する必要があります。 そのため、setTimeout関数のコールバック関数とは異なりデータへのアクセス方法にも制限がつきます。

非同期処理のすべてをひとくくりにはできませんが、基本的な非同期処理(タイマーなど)はメインスレッドで実行されているという性質を知ることは大切です。JavaScriptの大部分の非同期処理は非同期的なタイミングで実行される処理であると理解しておく必要があります。

ポイント

  • JavaScriptでは一部の例外を除き非同期処理が並行処理として扱われます。
  • 並行処理とは、処理を一定の単位ごとに分けて同期・非同期処理を切り替えながら実行することです。
  • 並列処理はメインスレッドとは違うスレッドで同時に処理が実行されることです。ブラウザではWeb Worker APIを使うことで並列処理を扱うことが出来ます。

非同期処理と例外処理

非同期処理は処理の流れが同期処理とは異なることについて紹介しました。 これは非同期処理における例外処理においても大きな影響を与えます。

前回やった同期処理のエラーはキャッチしてくれますが、非同期処理のエラーはキャッチできません。

try {
    throw new Error("同期的なエラー");
} catch (error) {
    console.log("同期的なエラーをキャッチできる");
}
console.log("この行は実行されます");

この場合、非同期処理のエラーをキャッチするためには以下のように非同期処理の中のブロックでtry...catch構文を書く必要があります。

// 非同期処理の外
setTimeout(() => {
    // 非同期処理の中
    try {
        throw new Error("エラー");
    } catch (error) {
        console.log("エラーをキャッチできる");
    }
}, 10);
console.log("この行は実行されます");

このようにコールバック関数内でエラーをキャッチできますが、非同期処理の外からは非同期処理の中で例外が発生したかがわかりません。 非同期処理の外から例外が起きたことを知るためには、非同期処理の中で例外が発生したことを非同期処理の外へ伝える方法が必要です。

この非同期処理で発生した例外の扱い方についてはさまざまなパターンがあります。 この章では主要な非同期処理と例外の扱い方としてエラーファーストコールバック、Promise、Async Functionの3つを見ていきます。 現実のコードではすべてのパターンが使われています。そのため、非同期処理の選択肢を増やす意味でもそれぞれを理解することが重要です。

この後に、非同期処理の中で起こったエラーを外に伝えるエラーファーストコールバックを学んでいきます。

コードを実行して試してみました

try {
    setTimeout(() => {
      console.log(1);
        throw new Error("非同期的なエラー");
      console.log(2);
    }, 10);
} catch (error) {
  console.error(error); // この例では実行されませんがエラー内容であるスタックトレースも出力するのがconsole.error(error);です。
    // 非同期エラーはキャッチできないため、この行は実行されません
} finally {
    console.log("この行はファイナリー");
}
console.log("この行は実行されます");

出力結果は以下のようになりました。

▶この行はファイナリー
▶この行は実行されます
▶1

try構文の中でsetTimeout関数で例外を発生させた場合、先にcatchやfinallyが同期的に実行されてしまうため、上記のような順番で出力されることが分かります。

この例文のコードにはここまで学習してきた内容が集約されているのでまとめます。

try構文の中のsetTimeout関数は第二引数である10ミリ秒後に実行されるため、この間に次の処理に移行しますがこの時点でエラーが発生していないし、そもそもcatchは非同期処理の外に定義されているためfinally以下に処理が移行しています。
10ミリ秒が経過した後にtry構文のsetTimeout内のエラーが発生(ここでconsole.log(1)が実行されます)し、console.log(2)はエラーの後なので実行されません。
そして前述の通り、tryでエラーが発生したため通常ならばcatchに引数が渡り実行されますが、このコードでは非同期処理の外で定義されているために実行されません。

参考

JS primer 非同期処理はメインスレッドで実行される