🚥

「非同期処理」が理解できない原因

2023/11/21に公開

背景

JavaScriptの学習で、初心者のつまづきポイントである「非同期処理」について、自分自身も毎回調べているので、いい加減理解しようと思い、アウトプットがてら記事にまとめようというのが今回の背景にあります。

非同期処理についておさらい

同期処理 → 直列処理

洗濯機を回す→(洗濯が終わるのを待つ)→掃除を始める
一つずつ実行するので、所要時間は合計で2時間かかります。
同期処理

非同期処理 → 並列処理

洗濯機を回す→洗濯機が動いている間に掃除をする
同時進行で行うので、所要時間は1時間で済みます。
非同期処理

非同期処理をコードで説明

現在の時刻を表示するだけの関数。
ただし、taskB関数はsetTimeout関数の中で実行する。ちなみにsetTimeout関数は非同期処理で実行される関数の一つです。

// 関数を定義
function taskA() {
  console.log("タスクAを実行 : " + Date.now());
}
function taskB() {
  console.log("タスクBを実行 : " + Date.now());
}
function taskC() {
  console.log("タスクCを実行 : " + Date.now());
}

// 関数を実行
taskA();
setTimeout(() => taskB(), 1000);
taskC();

もし、setTimeout関数が同期処理をするなら、実行結果は以下のようになるはずです。

// タスクAを実行 : 1700452206972 
// タスクBを実行 : 1700452207972
// タスクCを実行 : 1700452207972

非同期なし
タスクAが実行され、1000ミリ秒(1秒)待って、タスクBが実行され、すぐにタスクCが実行される。

しかし、実際の実行結果は以下のようになります。

// タスクAを実行 : 1700452206972 
// タスクCを実行 : 1700452206972
// タスクBを実行 : 1700452207972

非同期あり
タスクBよりも先にタスクCが実行され、1秒後にタスクBが実行されます。

つまり、同期処理であるタスクAとタスクCが実行され、非同期処理であるタスクBが実行されるわけです。

非同期処理において考えることは2つ

非同期処理のメリットとか、使い方とか以前に、以下の2つを理解しておくと、もうちょっと仲良くなれると思います。

  • 非同期処理をさせる(能動的)
  • 非同期処理している処理に対応する(受動的)

非同期処理を扱うのは主にこの2つの時になると思うのですが、これが真逆のことをやっているから、理解がややこしくなるのだと思います。
それぞれで、考えることが全く違いますので、それぞれ解説していきます。

非同期処理をさせる(能動的)

コードを書く側の都合で非同期処理をさせる場合のことを指します。
これが、よくメリットとして挙げられる「ユーザーを待たせない」と言うやつです。

すべてを同期処理させてしまうと、時間がかかる処理についても、それが終わるまで待たなければ、次の処理に進むことができません。

例えば、以下のような関数を作成しました。

// 現在時刻からtimeoutミリ秒経過したらreturnされる
// つまり指定した`timeout`ミリ秒経過するまで待ってくれる関数
function blockTime(timeout) {
  const startTime = Date.now();
  while (true) {
      const diffTime = Date.now() - startTime;
      if (diffTime >= timeout) {
          return;
      }
  }
}

blickTime(1000)
みたいに実行すると、1000ミリ秒待つ処理が走り、その後次の処理が実行されていきます。
先程のsetTimeout関数の同期版ってことです。

同期イメージ

これだと、blockTime関数が終わるまで、taskB関数、taskC関数は実行できません。
一方、setTimeout関数は、処理をしている間に、別の処理を走らせることができるため、効率が良いということです。

非同期イメージ

つまり、全部同期処理として書いてもいいけど、そうすると時間がかかる処理があったとしても処理が終わるのを待たないといけなくなるので、それよりは裏で実行させてその間に別のことを並行してやっておく方が効率良いよねってっていうのが能動的に非同期処理を使う目的であり、非同期処理のメリットなのです。

冒頭で紹介した洗濯と掃除を同時にやった方が効率良いという話もこれです。

これが、Ajax(Asynchronous JavaScript And XML)を使うメリットと繋がってきます。

https://qiita.com/hisamura333/items/e3ea6ae549eb09b7efb9

非同期処理の結果を待つ場合

続いて、先程のsetTimeoutのように、もともと非同期処理として実行される関数などを使う場合の話になります。
まず、同期処理、非同期処理の実行結果がどのような順番で返ってくるのかを理解する必要があります。

そのため以下のように関数を実行しました。

taskA();
setTimeout(() => taskB(), 1000);
blockTime(5000);
taskC();

素直に考えてみると、taskBは1秒後に実行、taskCは5秒後に実行なので、

// タスクAを実行 : 1700452206972 
// タスクBを実行 : 1700452207972
// blockTime関数で5秒ブロック
// タスクCを実行 : 1700452212972

こんな感じになりそうです。

しかし実際は、

// タスクAを実行 : 1700452206972 
// blockTime関数で5秒ブロック
// タスクCを実行 : 1700452211972
// タスクBを実行 : 1700452211972

こうなります。
タスクAが実行されたあと、5秒後にタスクCが実行され、その後すぐに非同期処理であるtaskBが実行されています。つまり図解するとこんな感じ。

非同期_時系列

時系列的にはtaskBの方が速く終わっているはずなのですが、時間のかかるtaskCよりも後に実行されました。
ポイントは、非同期処理の結果は同期処理の結果を待ってから実行されるという点です。

続いてこんな処理を実行してみます。

taskA(); //同期処理
setTimeout(() => taskA(), 3000); //非同期処理
setTimeout(() => taskB(), 2000); //非同期処理
setTimeout(() => taskC(), 1000); //非同期処理

最初に同期処理としてtaskAが実行されます。
続いて、非同期処理として、taskA,taskB,taskCを実行しています。

実行結果は以下のようになります。

// タスクAを実行 : 1700452206972  ←同期処理の結果
// タスクCを実行 : 1700452207972  ←非同期処理の結果(1秒後)
// タスクBを実行 : 1700452208972  ←非同期処理の結果(2秒後)
// タスクAを実行 : 1700452209972  ←非同期処理の結果(3秒後)

つまり、非同期処理は記述順ではなく、結果が返ってきた順に実行されます。

以上まとめると、

ここを押さえてください。

そうすると、こんな困ったことがおきます。
先程の例で、

setTimeout(() => taskA(), 3000);
setTimeout(() => taskB(), 2000);
setTimeout(() => taskC(), 1000);

このときに、どうしてもtaskA→taskB→taskCの順に結果を返して欲しいとします。

実際、taskAの結果を使ってtaskBを行いたいので、taskAの結果を先に返すことを保証して欲しいということはよくあります。

順番を保証したい場合、以下のように記述すれば順番どおりに実行できます。

setTimeout(() => {
  taskA()
  setTimeout(() => {
    taskB()
    setTimeout(() => {
      taskC()
    }, 1000);
  }, 2000);
}, 3000);

実行結果

// タスクAを実行 : 1700452206972 
// タスクBを実行 : 1700452208972
// タスクCを実行 : 1700452209972

最初のsetTimeoutの中でtaskAを実行し、そこで次のsetTimeout関数を実行し、その中で次のsetTimeout関数を...
というふうに入れ子にしていけば、順番を制御することができます。

しかし、このような複数の入れ子構造になってしまうため、可読性は失われてしまいます。

Promise、async/awaitの登場

そこで、Promiseやasync/awaitといった構文ができました。
先程の入れ子処理を以下のように書くことができます。

new Promise((resolve) => {
  setTimeout(() => {
    taskA()
    resolve()
  },3000)
})
.then(() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      taskB()
      resolve()
    },2000)
  })
})
.then (() => {
  return new Promise((resolve) => {
    setTimeout(() => {
      taskC()
      resolve()
    },1000)
  })
})
  1. 1番目のPromiseが実行
  2. 3秒後にtaskA()が実行
  3. resolve()が実行(この時点では何もない)
  4. then()が呼ばれthen()の中身が実行...

の繰り返しで実行が進んでいきます。
結果的にtaskA→taskB→taskCの順番で実行させることができます。

async/await

このPromiseをさらにわかりやすく、同期処理っぽく書くことができるのがasync/awaitです。

上記のコードをasync/awaitを使って書き換えてみます。

const task = (task, time) => {
  return new Promise((resolve) => {
    setTimeout(() => {
      task()
      resolve()
    }, time)
  })
}

async function main() {
  await task(taskA, 3000)
  await task(taskB, 2000)
  await task(taskC, 1000)
}

main()

実行結果

// タスクAを実行 : 1700452206972 
// タスクBを実行 : 1700452208972
// タスクCを実行 : 1700452209972

全く同じ実行結果が得られます。
taskを関数として定義している理由は、awaitPromiseオブジェクトを返す関数にしか使えないからです。

まとめ

非同期処理は以下の2パターンで分けて考えましょう。

  • 非同期処理をさせる時(能動的)
  • 非同期処理している処理に対応する時(受動的)

今回はPromiseや、async/awaitの細かい事はすっ飛ばしました。
もし、この記事で非同期処理について少しでも理解が進んだのであれば、さらに調べて頂ければと思います。

GitHubで編集を提案

Discussion