wwwave's Tech Blog

株式会社ウェイブのエンジニアブログです。 ここではエンジニアの目線から会社の話や技術的な話をしていきたいと思います。

JavaScriptのコード実行について

こんにちは。初めまして、システム開発部メンバーのukです。中国出身です。

最近、ウェイブが運営する電子コミック配信サイトComicFestaでは、jQueryからVue.jsに移行し、開発と運用がスムーズに行えるようにフロントエンドを改善しています。

今回JavaScriptの初心者として、非同期処理についてまとめたいと思います。

はじめに

一般的にプログラムは、逐次処理でソースコードを上から順に実行します。たとえば、以下のソースコードは上から順に実行されます。

let festa = 'https://comic.iowl.jp/';
console.log(festa);
let animey = 'https://anime.iowl.jp/';
console.log(animey);

しかし、非同期の書き方で書くと、このようになっています:

setTimeout(function(){
  console.log('Start');
});

new Promise(function(resolve){
  console.log('Start a loop');
  for (let i = 0; i < 10000; i++);
  resolve();
}).then(function(){
  console.log('function in then is called!');
});

console.log('End');

このソースコードを見ると、実行結果が下記の結果だと想定していましたが、

Start
Start a loop
function in then is called!
End

Google Chromeで動作を確認すると、結果が自分の想定と違っていました。

// Google Chromeでの結果
Start a loop
End
function in then is called!
Start

裏側で何が起こっているのでしょうか……?

JavaScript実行の仕組み

JavaScriptのランタイムは関数を実行するための 実行スタックタスクキュー を持っています。ソースコードを実行する時に、メインスレッドが順番にソースコードを実行して、関数が見つかった時に、まず関数を実行スタックにプッシュして関数の実行が完了すると関数を実行スタックからポップして、ソースコードの最後まで繰り返します。

しかし、Ajax、APIなどを実行する時は、メインスレッドをブロックさせないために、まずPromiseオブジェクトを返し、JavaScriptランタイムが事前に定義したコールバック関数をタスクキューにプッシュします。メインスレッドが実行スタックにプッシュしたタスクを全て実行した後にFIFO方式でタスクキューから次のタスクを実行します。

例えば下記のソースコードだと、

console.log('script start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
console.log('script end');

実際にこのように実行されます:

console.log('script start'); // 1. 'script start'を出力
setTimeout(                  // 2. setTimeoutを呼び出し、実行完了してから実行するコールバック関数を定義
  function() {               // 4. 0秒後にこのコールバック関数をタスクキューに入れる
  console.log('setTimeout'); // 5. 'setTimeout'を出力
}, 0);
console.log('script end');   // 3. 'script end'を出力

ポイントとしては:

  • この場合、 タスクキューsetTimeout のコールバックを制御するために、JavaScriptランタイムに存在し、 setTimeout から受け取ったタスクを保存するキューです。
  • JavaScriptエンジンはイベントループを使って実行スタックを常に監視していますので、実行スタックが空になると、タスクキューからタスクを取り出して実行スタックにプッシュして実行させます。

microtask と macrotask

非同期のタスクをさらに分類すると、microtask と macrotasksの二種類があります。

種類 事例
macrotask setTimeout, setInterval, I/O
microtask Promise

下記のソースコードで microtask と macrotask の違うところを整理します。

console.log('start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2')
}).then(function() {
  console.log('promise3')
})
console.log('end');

Google Chromeで実行してみると結果はこうなります:

"start"
"promise1"
"end"
"promise2"
"promise3"
"setTimeout"

Promise の非同期タスクが setTimeout のコールバックより優先的に実行されましたね。

これは以下のような理由があります。

タスクキューにはmicrotask と macrotask の二種類があり、Promise.then の中の関数は microtask のキューにプッシュされ、setTimeout のコールバックがmacrotask のキューにプッシュされます。

実行スタックが空になると、次に実行するタスクをタスクキューから取り出します。その時、macrotaskキューから、一つだけタスクを取り出して実行します。取り出したmacrotaskの実行が完了すると、次に、microtaskキューを確認し、キューにある全てのmicrotask実行してから、次のmacrotaskの実行に移ります。

それでは、上記ソースコードの実行を整理してみます:

STEP 1

メインスレッドは下記のソースコードを一つのmacrotaskとして順次実行します。

console.log('start');              // STEP 1-1
setTimeout(function() {
  console.log('setTimeout');       // macrotaskキューにプッシュ
}, 0);
new Promise(function(resolve) {    // STEP 1-2
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2')          // microtaskキューにプッシュ
}).then(function() {
  console.log('promise3')          // microtaskキューにプッシュ
})
console.log('end');                // STEP 1-3

まず、下記の結果が出力されます。

"start"
"promise1"
"end"

STEP 2

STEP1 が完了すると、microtaskのキューに Promise の二つの then のコールバック関数が入っているので、全てのmicrotaskを実行します。

console.log('start');
setTimeout(function() {
  console.log('setTimeout');
}, 0);
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2')          // STEP 2-1
}).then(function() {
  console.log('promise3')          // STEP 2-2
})
console.log('end');

STEP 2までの出力結果はこうなります。

"start"
"promise1"
"end"
"promise2"
"promise3"

STEP 3

STEP2 が完了すると、microtaskのキューが空になり、次のmacrotaskを実行する。

console.log('start');
setTimeout(function() {
  console.log('setTimeout');       // STEP3-1
}, 0);
new Promise(function(resolve) {
  console.log('promise1');
  resolve();
}).then(function() {
  console.log('promise2')
}).then(function() {
  console.log('promise3')
})
console.log('end');

最後まで実行された結果はこうなります。

"start"
"promise1"
"end"
"promise2"
"promise3"
"setTimeout"

microtaskとmacrotaskの関係を図で表すと、下記のようになります:

f:id:kanu-wwwave:20190604163346p:plain

所感

今回見たように、コールバックの順番が関数実行する順番と違う場合があります。さらに、今回は触れませんでしたが、Node.jsにはまた別分類のnextTickキューがあります。JavaScriptの非同期処理を書くときは、ちゃんとこのことに気をつけた方がいいと思いました。

最後に

ウェイブは、今エンジニアさんを募集中です!詳しくは採用サイトをご覧ください!

recruit.wwwave.jp

また不定期でもくもく会を実施していますので、ぜひご参加ください!

wwwave.connpass.com

参考