アルファテックブログ

JavaScriptの非同期処理で順番が崩れる理由

カバー

はじめに

JavaScriptで画面開発をしていると、API通信などの非同期処理を扱うことが頻繁にあります。 私自身も非同期処理を扱った際、ソースに書いた順番どおりに処理が進まず、意図しない順番で動いてしまうケースに遭遇しました。

例えば、次のコードを見てください。

let userData;
console.log("1. データ取得を開始");
// 非同期処理の例(API通信)
fetch("https://api.sample.com/user")
.then((data) => {
userData = data;
console.log("2. データ取得完了");
});
// 取得したデータを使おうとする
console.log("3. データの中身:", userData);

※ 実際のfetch()ではjson()によるレスポンスデータのJSON変換が必要ですが、この記事では省略しています。

直感的には1 → 2 → 3の順に進みそうですが、実際には1 → 3 → 2の順で出力されます。また、3の時点ではfetch()は完了していないため、userDataはundefinedとなってしまいます。

この記事では、上記のコードのように、JavaScript(以降JS)において、非同期処理を含む処理の実行順が直感とずれる理由と、その対応方法を紹介します。

期待した順番にならない理由

上記の例では、次のようにソースに書いた順番どおりに処理されませんでした。

  • 期待:1 → 2 → 3
  • 実際:1 → 3 → 2

ここでは、この順番のずれがなぜ起きるのかを整理します。

同期処理と非同期処理の違い

順番がずれる理由は、JSの同期処理と非同期処理で性質が異なるためです。

  • 同期処理:呼び出し元が処理完了まで待つ処理(次の行に進まない)
    例:console.log()、alert()など
  • 非同期処理:呼び出し元が処理完了を待たない処理(次の行に進み、完了後に「続き」が実行される)
    例:API通信(fetch())、タイマー(setTimeout())、イベント処理など

はじめに提示したコードで使ったfetch()は、ネットワーク越しにデータを取得するための仕組みです。しかし、通信には時間がかかるため、JSエンジンはその完了を待たずに次の行へ進みます。

そのため、fetch()を呼んだ直後のコード(同期処理であるconsole.log("3..."))が先に実行され、その後に通信が完了してコールバック(then()の中身)が実行されます。
これが1 → 3 → 2になる理由です。

実行の仕組み:スタックとキュー

では実際に、後回しになった処理がどのように実行されるのか、ブラウザー環境を前提として整理します。

Agent(エージェント)という実行単位

仕様上、JSのコードを実行する主体はAgentと呼ばれます。Agentはコード実行のために次の仕組みを持ちます。

  • スタック:関数の呼び出し履歴が積み上げられる場所。関数Aの中で関数Bが呼ばれると、Aの上にBが積まれる。後から積まれたものほど上に置かれ(後入れ先出し/LIFO)、実行されるのは常に一番上の処理のみ。その下の処理は、上の処理が完了して取り除かれるまで待機する。
  • キュー:実行待ちのタスク(コールバックなど)を一時的に保持しておく場所。先入れ先出し(FIFO)で管理される。ブラウザーの仕様上、キューは更に2種類に大別される。
    • タスクキュー:setTimeout()やイベントハンドラーなどが利用する。優先度は低い。
    • マイクロタスクキュー:Promise1の後続処理(.then()や.catch()など)が利用する。fetch()もPromiseを返すため、そのthen()もここに含まれる。優先度は高く、スタックが空になるとタスクキューより先に実行される。

ここからは、特に開発時につまずきやすいfetch()などの非同期処理を例に、なぜ記述した順番どおりに実行されないのか、その仕組みと解決策を解説します。

以下の図は、非同期処理を開始してから、後続処理(then()/awaitの次の行以降)が実行されるまでの流れを表しています。

graph LR
    %% 定義
    Stack[スタック
(実行中の処理)] Host[ホスト環境
(ブラウザー)] Queue[マイクロタスクキュー
(待ち行列)] %% 処理の流れ Stack -- 1. fetchの依頼 --> Host Host -- 2. 完了時にキューへ追加 --> Queue Queue -- 3. 取り出して実行
(スタックが空いたら) --> Stack

JSの非同期処理は、上図のように スタックホスト環境キュー が連携して動作します。この一連の仕組みを イベントループ と呼び、具体的には以下の手順で処理が進みます。

  1. 処理を依頼:まず、JSエンジン(スタック)がfetch()などの非同期処理をホスト環境(ブラウザー)へ依頼します。依頼後、完了を待たずに後続の処理へ進みます。
  2. 完了時にキューへ追加:ホスト環境側で通信処理などが完了すると、実行すべきコールバック関数が マイクロタスクキュー に追加されます。
  3. 取り出して実行:現在の処理がすべて完了してスタックが空になると、イベントループがマイクロタスクキューからコールバックを取り出して、スタックへ移動させて実行します。

これが、コードの記述順と実際の実行順がずれる理由です。

この仕組みを冒頭のサンプルコードに当てはめると、実際にはコメントに記載された順番で処理が進んでいることがわかります。

let userData;
console.log("1. データ取得を開始");
// 図の【1. 処理を依頼】
// JSがブラウザーへ通信を依頼し、結果を待たずに即座に18行目の処理へ進みます
fetch("https://api.sample.com/user")
// 図の【2. 完了時にキューへ追加】
// 通信が終わると、このコールバック関数がキューに登録されます
// 図の【3. 取り出して実行】
// スタックが空になった後(下の3.のログ出力後)、イベントループによって実行されます
.then((data) => {
userData = data;
console.log("2. データ取得完了");
});
// 図の【1】の直後に実行される処理
console.log("3. データの中身:", userData); // ← userDataはundefined

Agentは一度に1つの処理しか進められません。つまり、JSの非同期処理は、複数の処理を同時に実行する並列(parallel)の仕組みではありません。1つのスレッド上で、キューに積まれた処理を1つずつ取り出して実行することで、複数の処理が同時に進んでいるように見せる並行(concurrent)の仕組みです。

対応方法

期待した順番で処理を行うための対応方法を3つ紹介します。

ここで紹介する方法はいずれも、PromiseというJSの仕組みをベースにしています。Promiseとは、将来完了する処理の結果を表すオブジェクトであり、保留(pending)成功(fulfilled)失敗(rejected) のいずれかの状態を持ちます。fetch()もPromiseを返すため、これから紹介する方法で完了後の処理を制御できます。

ここでは、より実践的な例として、1つ目のfetch()で取得したユーザー情報をもとに2つ目のfetch()を呼び出すケースを考えます。次のコードは、はじめに紹介した例と同じ理由で期待どおりに動きません。

let user;
console.log("1. ユーザー取得を開始");
fetch("https://api.sample.com/user")
.then((data) => {
user = data;
console.log("2. ユーザー取得完了:", user.id);
});
// 1つ目のthen()が実行される前に到達するため、userはundefinedのまま
fetch(`https://api.sample.com/users/${user.id}/posts`)
.then((posts) => {
console.log("3. 投稿取得完了:", posts);
});

1つ目のthen()が実行される前に2つ目のfetch()を開始するため、userはundefinedのままであり、user.idへのアクセスでTypeErrorが発生します。

以下の方法1〜3では、このコードを正しく動くように書き直します。

方法1:入れ子でつなぐ

「終わったら次を呼ぶ」を入れ子にして手作業でつなぐ方法です。

以下の例では、1つ目のfetch()のthen()の中で2つ目のfetch()を呼び出すことで、1つ目の完了後に2つ目が実行されるようにしています。

console.log("1. ユーザー取得を開始");
fetch("https://api.sample.com/user")
.then((user) => {
console.log("2. ユーザー取得完了:", user.id);
// 1つ目の結果を使って2つ目を呼び出す
fetch(`https://api.sample.com/users/${user.id}/posts`)
.then((posts) => {
console.log("3. 投稿取得完了:", posts);
});
});

メリット:

  • 入れ子にするだけで処理の順番を制御できる。

デメリット:

  • 依存する非同期処理が増えるほど入れ子が深くなり(コールバック地獄)、可読性が低下する。

方法2:Promiseチェーン

Promiseのthen()が返すPromiseを利用して、処理を順番に記述する方法です。

以下の例では、then()の中でPromiseを返すことで、入れ子にせずに処理の順番を制御しています。

console.log("1. ユーザー取得を開始");
fetch("https://api.sample.com/user")
.then((user) => {
console.log("2. ユーザー取得完了:", user.id);
// Promiseを返すことで、次のthen()へチェーンできる
return fetch(`https://api.sample.com/users/${user.id}/posts`);
})
.then((posts) => {
console.log("3. 投稿取得完了:", posts);
});

メリット:

  • 入れ子を避けてフラットに書けるため、処理の流れが追いやすくなる。

デメリット:

  • then()が多くなるとチェーンが長くなり、可読性が下がる場合がある。

方法3:async/await

async/awaitは、Promiseを使った非同期処理を同期処理のように書くための構文です。

  • asyncを付けた関数は必ずPromiseを返すようになり、関数内でawaitを使用できるようになる。
  • awaitの次の行以降の処理は、Promiseが解決された後に実行される処理として予約される。この処理はキューに追加され、イベントループによって実行される。
async function main() {
console.log("1. ユーザー取得を開始");
// awaitを使うと、完了(マイクロタスクの処理)まで待機する
const user = await fetch("https://api.sample.com/user");
console.log("2. ユーザー取得完了:", user.id);
const posts = await fetch(`https://api.sample.com/users/${user.id}/posts`);
console.log("3. 投稿取得完了:", posts);
}
// 実行
main();

メリット:

  • 方法1・2に比べて簡潔で直感的に書くことができるため、可読性が向上する。

デメリット:

  • 同期処理のように書けるため、複数の非同期処理を連続してawaitしてしまい、本来は同時に開始できる処理が逐次実行になっていることに気づきにくい。
// 【悪い例】依存関係がないのに1つずつ完了を待ってしまう
const news = await fetchNews(); // ← この完了を待ってから……
const weather = await fetchWeather(); // ← これが開始される(無駄な待ち時間)
// 【良い例】複数の非同期処理に依存関係がない場合、Promise.all()を使えば、同時に開始して両方の完了を待てる
const [news, weather] = await Promise.all([fetchNews(), fetchWeather()]);

まとめ

この記事では、非同期処理によってコードの「記述順」と「実行順」がずれるメカニズムと、その制御方法について解説しました。

要点は以下のとおりです。

  • 順番がずれる理由

    非同期処理では、開発者が「完了後に実行したい処理」をコールバックとして登録する。非同期呼び出しが完了すると、実行環境がそのコールバックを実行待ちキューに追加する。このコールバックは、現在の同期処理が完了したタイミングでイベントループによって取り出されて実行される。その結果、コードの「記述順」と「実行順」にずれが生じる。

  • 制御する方法

    • 入れ子でつなぐ(基本だがネストが深くなりやすい)
    • Promiseのthen()でつなぐ(チェーンで記述できる)
    • async/awaitで待つ(同期処理のように書けるため可読性が高い)

システム開発において、APIなどの外部リソースを利用する際は「それがいつ実行され、いつ完了し、その結果をどうハンドリングするか」を意識することが不可欠です。「非同期だから遅れる」という曖昧な認識ではなく、イベントループの特性を正確に理解しておくことが、非同期処理に関するバグを防ぐ第一歩となります。

参考

Footnotes

  1. Promiseとは、将来完了する非同期処理の結果(保留・成功・失敗)を表すオブジェクトです。

記事の執筆にあたっては情報の正確性に努めておりますが、掲載されている文章やソースコード、設定ファイル等の内容について、完全な正確性や安全性を保証するものではありません。活用される際は、必ず公式ドキュメント等をご自身で確認のうえご判断ください。


TOP
アルファロゴ 株式会社アルファシステムズは、ITサービス事業を展開しています。このブログでは、技術的な取り組みを紹介しています。X(旧Twitter)で更新通知をしています。