クイック エンジニアリングブログ

株式会社クイック Web事業企画開発本部のエンジニアリングチームが運営する技術ブログです。

【意外と知らない?】JavaScriptの非同期処理の強いクセ

こんにちは!Czです✌️

みなさんJavaScriptの非同期処理を使っていますか〜?
そうそう、Promiseとかasyncのやつですね。

じゃあせっかくなのでちょっと理解度を確認してみましょうか。
(全問正解した人はバッチリ理解しているのでこの記事読まなくていいかも!)

問題は全部で3問あります。それぞれのconsole.logの出力順を答えてみてください。

理解度確認テスト

問1

exec();

function exec() {
  console.log(1);
  new Promise((resolve) => {
    console.log(2);
    resolve();
  });
  console.log(3);
}

問2

exec();

async function exec() {
  console.log(1);
  await func1();
  console.log(3);
}

function func1() {
  return new Promise((resolve) => {
    console.log(2);
    resolve();
  });
}

問3

exec();

async function exec() {
  console.log(1);
  func1();
  console.log(3);
}

function func1() {
  return new Promise((resolve) => {
    console.log(2);
    resolve();
  });
}

回答できましたかね。

正解はー?

 ↓
 ↓
 ↓
 ↓
 ↓
 ↓
 ↓
 ↓
 ↓

問1 1,2,3
問2 1,2,3
問3 1,2,3

どうですか?全問正解できました?
できた人、おめでとうございます👏
ブラウザバックでおかえりください。あっ、待って!(↓読んで)

\\『真のユーザーファーストでマーケットを創造する』仲間を募集中です!! //

919.jp

...

惜しくも全問正解できなかった人。
大丈夫!この後の内容を読めばたった5分でJavaScriptの非同期処理を理解することができるようになります!

そもそも非同期(asynchronous)とは

IT用語辞典 e-Wordsでは

複数の主体がタイミングを合わせずに通信や処理を行う方式。対義語は「同期」(synchronized/synchronous)。

引用:https://e-words.jp/w/%E9%9D%9E%E5%90%8C%E6%9C%9F.html

とあります。

フロントエンドの実装ではよく外部APIの呼び出しや、重い処理を後回しにするために使ったりします。

みなさんも速度改善のために非同期処理を使ったことありますよね。

でも先ほどの問題の通り、JavaScriptは直感的ではない強いクセがあります。 重い処理を後回しにした「つもり」にならないようにしたいですね。

非同期のタイミング

先ほどの「問1」をもう一度見てみましょう。

exec();

function exec() {
  console.log(1);
  new Promise((resolve) => {
    console.log(2);
    resolve();
  });
  console.log(3);
}

ここで間違えた方はもしかしたら「1, 3, 2」と答えてしまったのではないでしょうか。

以下の図のような流れをイメージしたかもしれません。

通常の非同期処理であれば上記のイメージで正しいです。
しかしJavaScriptの場合、実際の処理の流れはこのようになります。

あれ?全ての処理が「メイン」で実行されているぞ。

そうなんです。「Promiseの中のコールバックはメイン(メインスレッド)で同期的に実行される」んです。

また、処理の途中でキューに何かしらの処理が追加され、キューから取り出した後、メインで実行されます

これがJavaScriptの非同期処理のクセであり、正しく使うには知っておくべきことなのです。

JavaScriptの非同期の仕組み

イベントループ

JavaScriptの非同期の仕組みはJAVAやCなどのような「マルチスレッド」ではなく「イベントループ」によって実現しています。

上記の図の流れを説明すると、

  1. メインスレッドから非同期処理として投げられたmessageをキューに積み
  2. イベントループという仕組みでmessageを取り出し
  3. メインスレッドに返却し、メインスレッドで実行する。

となります。

つまり、JavaScriptの非同期処理は「実行タイミングをずらしているだけ」となります。
(非同期の定義としてIT用語辞典 e-Wordsにあるように「複数の主体がタイミングを合わせずに通信や処理を行う方式」なので非同期なのは間違っていない)

裏で動いていると思ってたら〜実は表でした〜(チキショー)

ってことですね。

イベントループはメッセージのスケジューリングを行い、実際に実行するのはメインスレッドなのでJavaScriptはシングルスレッドということになります。

シングルスレッドとマルチスレッドの違い

緑のエリアをメインスレッドとしてみたとき、シングルスレッドは処理が順次実行されています。

一方マルチスレッドは別スレッド(オレンジのエリア)にメッセージが渡った後、メインスレッドと同時に実行することができています。

つまり、シングルスレッドに比べ、マルチスレッドの方が一定期間内に処理できる量が多いということになります。

これが「シングルスレッド」と「マルチスレッド」の違いですね。

並列と並行

マルチスレッドとシングルスレッドに似たような意味で使われる言葉として「並列」「並行」というものがあります。
微妙にニュアンスが違うため、混同しないように確認しましょう。

並列
複数のCPUを使い処理を同時に実行

並行
一つのCPUを使い細かい単位で処理を切り替え(コンテキストスイッチし)ながら同時に実行しているように見せる

つまり、使えるCPUの数によって「マルチスレッド(並列)」や「マルチスレッド(並行)」ということがあるということですね。

並列と並行について現実世界で例えてみます。

  • AさんがBacklogのチケット1とチケット2を持っており、チケット2をBさんに渡して2人で同時に対応することを「並列」
  • Aさんが1人で隙間時間で2つのチケットを切り替えながら対応することを「並行」
  • チケット1が終わってからチケット2に着手することを「同期」

という感じです。

Promiseのコールバックは同期的に実行される

ここまででJavaScriptは「イベントループ」という仕組みで「シングルスレッド」で非同期処理が実行されることがわかりました。

ここでもう一度実際の処理の流れを確認してみます。

キューへの追加はPromiseのコンストラクタで渡されているコールバックの後に呼ばれています。

なんとなくここは非同期で実行されそうな気がしますが、実は非同期ではなく同期的に実行されます。
これがJavaScriptの非同期処理のクセです。

ただ同期的に実行されるのには理由がありそうです。
ちゃんと調べられていないのですが、おそらく外部APIのコールを同期で行うことでAPIからのレスポンスが遅れないようにするためと思われます。

重い処理を非同期にしたい場合

みなさんが知りたいところはここですよね。😉

Promiseのコンストラクタでそのまま実行すると同期になってしまいます。
これを非同期で実行する場合はsetTimeout()という関数を使います。

これは「イベントループを使って処理の実行タイミングを遅らせる」関数です。
つまり今まで説明してきた非同期処理を「実現するための関数」となります。

では重い処理を非同期にしてみましょう。

まずは修正前。

exec();

function exec() {
  console.log(1);
  new Promise((resolve) => {
    console.log(2);
    // 重い処理
    for (var i = 0; i < 100000; i++) {
      // something.
    }
    console.log(3);
    resolve();
  })
  .then(() => {
    console.log(4);
  });
  console.log(5);
 
  return 'completed';
}

Promiseのコールバックの中で重い処理を実行しています。
この処理を後ろにずらしたいですね。

ではこれを以下のように修正してみます。

exec();

function exec() {
  console.log(1);
  new Promise((resolve) => {
    console.log(2);
    setTimeout(() => {
      // 重い処理
      for (var i = 0; i < 100000; i++) {
        // something.
      }
      console.log(3);
      resolve();
    });
  })
  .then(() => {
    console.log(4);
  });
  console.log(5);

  return 'completed';
}

動作を確認するために、ChromeのDevToolsを開いてConsoleに上記コードを貼り付けて実行してみましょう。
以下のような結果になりました。

修正後の結果を見てみましょう。
setTimeoutで囲んだ処理は「completed」の後に実行されていますね。
このようにsetTimeoutを使うことで重い処理のタイミングを遅らせることができます。

ちなみに修正前の方を見るとthenの中は「completed」の後に実行されています。
つまりthenの前の処理のresolve()は非同期で実行されていることになります。
(どうやらresolve()以降から非同期になる挙動みたいです。でも普段はあまり気にせず結果を処理する場所とだけ考えておく方が色々都合が良いかなと思います)

外部API呼び出しは並列?

JavaScript単体ではシングルスレッドのため並行処理になりますね。
しかし外部APIを呼び出した際の処理はサーバーで行うため、ブラウザとサーバーで並列で処理が実行できます。

まぁ言われてみれば当たり前のことですが、ブラウザでの処理速度を上げるため(または負荷を下げるため)にサーバーサイドに処理を委譲(お任せ)するということもできるので参考までに覚えておくと良いでしょう。

Promiseとasync/await

最近はasync/awaitの方が主流ですね。

今更ですがasync/awaitはPromiseのシンタックスシュガーなので置き換えることができます。
async/awaitに置き換えると可読性が向上するのでこの機会におさらいしましょう。

修正前

exec();

function exec() {
  console.log(1);
  something().then((res) => {
    console.log(res);
    console.log(3);
  });
  console.log(4);
}

function something() {
  return new Promise((resolve) => {
    console.log(2);
    resolve('test');
  })
}

修正後

exec();
console.log(4); // exec()がasyncなので4の出力は外に出す

async function exec() { // asyncを付与
  console.log(1);
  const res = await something(); // awaitで待つ
  console.log(res);
  console.log(3);
 }

async function something() { // asyncを付与
  console.log(2);
  return 'test'; // resolve('test')と書かずにそのままreturnする
}

出力結果はいずれも以下の通りです。

async/awaitを使うことで入れ子がなくなり見やすくなりましたね。
しかし、「console.log(4)」の記述場所が変わったりしているのでどこから非同期なのか気をつける必要がありそうです。

そこで上記のコードを同期と非同期に色分けしてみました。

Promiseではthen以降が非同期、async/awaitではawaitで結果を受け取ってから非同期となります。
どちらもsomething()メソッドの中身が同期で処理されているので混乱してしまいますが、どのタイミングで非同期になるかしっかり把握しておきましょう。

重たい処理をasync/awaitに変えてみよう

先ほどはPromiseで書きましたが、でもやっぱりasync/awaitがいいですよね。

exec();
console.log(4); // exec()がasyncなので4の出力は外に出す

async function exec() { // asyncを付与
  console.log(1);
  const res = await something(); // awaitで待つ
  console.log(res);
  console.log(3);
 }

function something() { // asyncはつけずにPromise -> setTimeoutを使う
  return new Promise((resolve) => {
    setTimeout(() => {
      // ここで重い処理
      console.log(2);
      resolve('test');
    });
  });
}

残念ながらsetTimeoutを使う場合はresolve()をコールする必要があるため、Promise()で記述する必要があります。

とはいえ、使う側ではawaitが使えるので使い勝手としては変わらなさそうですね。

全部async/awaitで記述したい気持ちはありますが、この記述はMDNにも載っているので「そういうもんなんだな」と思ってください。
async function - JavaScript | MDN

ラムダ式(無名関数)でasync

わざわざasyncのためだけに関数を定義したくない時もあると思います。
そういう時は以下のように書くこともできます。

exec();
console.log(4);

async function exec() {
  console.log(1);
  const res = await (async () => {
    return 'test';
  })();
  // const res = await (async () => 'test')(); // こうすれば更に短く書ける!
  console.log(res);
  console.log(3);
}

スッキリしました。
ここまでできればJavaScriptの非同期処理はバッチリです👌

まとめ

JavaScriptの非同期処理の挙動については以下の通り

  • JavaScriptの非同期はシングルスレッド
  • イベントループという仕組みで順次メインスレッドで処理を実行している
  • Promiseのコンストラクタで渡されているコールバック内は同期的に実行される
  • 重い処理を非同期にしたい場合はsetTimeout()を使う
  • 外部APIは並列で実行することができる(別プロセスになるため)

いや〜なかなかクセがありますね。理解できましたか?
ここで改めて「理解度確認テスト」を実施してみてください。全問正解できるといいですね👍

Appendix

イベントループ
https://developer.mozilla.org/ja/docs/Web/JavaScript/EventLoop

非同期関数
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function

余談

長男が今年小学校を卒業し、いよいよ中学生になります。
この記事を書いている今、最後のランドセル姿を玄関で見送った後なので感慨深いです。

卒業式泣かないようにしなきゃなって思いながら、もう涙腺ヤバい。歳かな🥺
(この記事を公開する頃には卒業式が終わってるのか。。。)

イキリすぎないように(多少はOK)中学校生活も楽しんでくれるといいな。


\\『真のユーザーファーストでマーケットを創造する』仲間を募集中です!! //

919.jp