【JavaScript】非同期処理の重なりでif文に反した挙動が起きる例とその対策

 通常if文の中での処理はif文の条件がtrueになった状態で始まります。しかしながら、それが成り立たない場合も存在します。その場合と対策を紹介します。

 実際にそうなる例が次です。

/** ミリ秒単位で指定した時間だけスリープする関数 */
async function sleep(durationMs) {
  await new Promise(resolve => setTimeout(resolve, durationMs));
}

/** メイン関数 */
async function main() {
  /** 非同期処理内で扱うオブジェクト */
  let sharedState = {count: 0};
  // 50ミリ秒後にsharedState.countをインクリメントする
  setTimeout(() => sharedState.count++, 50);
  // setTimeoutの完了を待たずに↓実行する。この時点では count === 0
  if (sharedState.count === 0) {
    // 100ミリ秒待つ
    await sleep(100)
    // sharedState.count === 0 の if の中だけど、sharedState.count は 1 になっている
    console.log(sharedState.count); // 1
  }
}

main();

 sharedState.count === 0 の条件で if の中に入り、sharedState.count の値をその if の中で変更していないにも関わらず sharedState.count は 1 となりました。これは次の時系列で処理がなされたためです。

  1. 数ミリ秒目: if(sharedState.count === 0) の条件が判断される
  2. 約50ミリ秒目: sharedState.count++が実行される
  3. 約100ミリ秒目: console.log(sharedState.count)が実行される

 時刻は雑ですが処理順はこうなります。この現象はif文の中で使われる値をif文が完了する前にif文の外で書き換えたため起きました。例は恣意的に処理順を決めましたがデータの作成更新削除処理が多く、非同期処理の中に非同期処理を書くようなプログラムですとこの不具合を埋め込んでしまうことがあります。

ちなみに普通に同期的処理の裏で一つの非同期処理が走っているだけの時はこんなことは起きません。JavaScriptの非同期処理は並列処理ではなく、計算資源が暇になる時間を減らしているだけだからです。これは次の例からもわかります。

async function asyncExample(arg) {
  console.log(`in asyncExample ${arg} start`);
  for (let i = 0; i < 5000; i++) {
    Array.from({ length: 5000 }, () => Math.random());
  }
  console.log(`in asyncExample ${arg} end`);
}

asyncExample('a');
asyncExample('b');
console.log(`in main`);
// 実行すると次のように出力される
/*
in asyncExample a start
in asyncExample a end
in asyncExample b start
in asyncExample b end
in main
*/


 開始時と終了時にコンソール出力をする重い非同期処理を連続して走らせるコードです。並列処理であれば開始A、開始B、終了A、終了Bとなるのが妥当ですが実際は開始A、終了A、開始B、終了Bの順です。先ほどの例はsetTimeoutを利用してJavaScriptを暇にさせるコードでしたので隙間にオブジェクトのプロパティを変更する処理を差し込まれました。sleep関数が差し込まれる余地のない全く計算資源にやさしくない次のコードであればif文に反するような挙動は起きません(これはこれでsleep関数なのに計算資源が全く眠っていないのはどうかと思いますが)。

/** ミリ秒単位で指定した時間だけスリープする関数 */
function syncSleep(durationMs) {
  const start = new Date().getTime();
  // 現在時刻の取得と比較を行い続ける
  while (new Date().getTime() < start + durationMs) {
    // 何もしない
  }
}

 話題を戻して、冒頭で挙げた問題の対策を紹介します。対策をするには処理順の整理が確実です。先ほどの例であれば次のようにできます。

/** ミリ秒単位で指定した時間だけスリープする関数 */
async function sleep(durationMs) {
  await new Promise(resolve => setTimeout(resolve, durationMs));
}

/** メイン関数 */
async function main() {
  /** 非同期処理内で扱うオブジェクト */
  let sharedState = {count: 0};
  // 50ミリ秒後にsharedState.countをインクリメントする
  setTimeout(() => sharedState.count++, 50);
  // setTimeoutの完了を待たずに↓実行する。この時点では count === 0

/* ifの外に出したパターン */
  // 100ミリ秒待つ
  await sleep(100); 
  if (sharedState.count === 0) {
    // 今度は正しく sharedState.count === 0
    console.log(sharedState.count); // 1
  }
}

main();
/** ミリ秒単位で指定した時間だけスリープする関数 */
async function sleep(durationMs) {
  await new Promise(resolve => setTimeout(resolve, durationMs));
}

/** メイン関数 */
async function main() {
  /** 非同期処理内で扱うオブジェクト */
  let sharedState = {count: 0};
  // 50ミリ秒後にsharedState.countをインクリメントする
  setTimeout(() => sharedState.count++, 50);
  // setTimeoutの完了を待たずに↓実行する。この時点では count === 0

  if (sharedState.count === 0) {
    // 100ミリ秒待つ
    await sleep(100); 
/* 再度条件のチェックをかけるパターン */
    if (sharedState.count === 0) {
      // sharedState.count === 0 の if の中だけど、sharedState.count は 1 になっている
      console.log(sharedState.count); // 1
    }
  }
}

main();

 こんな感じで期待通りに動作するようにできます。

 このあたりの実行順のことを詳しく知りたいのであればシングルスレッド、イベントループ、マクロタスク、マイクロタスクあたりで調べるといいです。

>株式会社シーポイントラボ

株式会社シーポイントラボ

TEL:053-543-9889
営業時間:9:00~18:00(月〜金)
住所:〒432-8003
   静岡県浜松市中央区和地山3-1-7
   浜松イノベーションキューブ 315
※ご来社の際はインターホンで「316」をお呼びください

CTR IMG