JavaScriptの非同期処理に関してあれこれ書いてみる

前提

まず整理しておく。

同期とは、タスクを順々に処理していくこと。
非同期とは、あるタスクの完了を待たずに次の処理に進めること。

一方でJavaScriptは一度に一つのことしかできない。
これはシングルスレッドという実行モデルを採用しているためだ。

「え?一度に一つ?」

そうなると非同期処理が行えないのではないかと思うかもしれないが、 イベントループという仕組みによって、APIの応答やファイルの読み込みといった「待ち」が発生する処理の間に、他の処理を進めることができるようになっている。

なので同時に動いているというよりは待ち時間を無駄にしない設計だと理解するのが正しい。

この設計思想があるため、JSでは「重い処理を待たずに先に進み、終わったら通知をもらう」というのが基本戦略になっている。


非同期処理の歴史

JSの非同期処理は、書き方が3段階で進化してきた。

// 1. コールバック地獄(いにしえのJS)
getData(url, function(data) {
  parse(data, function(parsed) {
    save(parsed, function(result) {
      console.log(result);
    });
  });
});

// 2. Promise チェーン(ES2015)
getData(url)
  .then(data => parse(data))
  .then(parsed => save(parsed))
  .then(result => console.log(result))
  .catch(err => console.error(err));

// 3. async/await(ES2017)
async function main() {
  const data = await getData(url);
  const parsed = await parse(data);
  const result = await save(parsed);
  console.log(result);
}

混乱しやすいポイント:async内部の直列処理

同期・非同期をあまり理解できていない時の混乱ポイントとして、非同期処理の内部では直列的な処理を複数配置することが多いという点がある。

以下を見るとawait毎に処理が中断する。 上から下へAを終えてからBを行うような手順に見えるわけで、同期的な挙動に見えてしまう。

async function main() {
  console.log('1. getData開始');
  const data = await getData(url);    // ← ここでmainは"中断"
  // getData()が解決するまで、この下は実行されない
  
  console.log('2. parse開始');
  const parsed = await parse(data);   // ← またここで"中断"
  
  console.log('3. save開始');
  const result = await save(parsed);  // ← またここで"中断"
  
  console.log('4. 完了', result);
}

main();

console.log('5. mainの外');

今回のポイントは asyncキーワードで宣言されたmain関数とその外側との関係だ。

この処理は 1. getData開始 が出力された後、次に 5. mainの外 が出力される。 async内部ではawait毎に直列的に物事を解決しながら、外側の処理はどんどん進んでいるのだ。 この点が非同期である。

(上記のコードではasync関数の内部におけるI/O系処理はawaitを使わないことにより非同期化するが、ことがややこしくなるので今回の解説には含めない)


async/awaitの正体はPromise

ちなみに、async/awaitは実際にはPromiseのシンタックスシュガーだ。 async関数自体がPromiseオブジェクトを返却する。

async function main() {
  const data = await getData(url);
  return data;
}

// main()の戻り値はPromise
main().then(data => {
  console.log('mainが終わった:', data);
});

console.log('hello');

つまり呼び出す側から見れば、async関数そのものが一つの非同期処理ということになる。 呼び出した瞬間にPromiseが返ってきて、中の処理は裏で進行する。 だからmain()の外側とは非同期の関係になるわけだ。 故に上記のコードでは、main()の中の処理が完了する前にhelloが出力される。


ついでに:Promise.allによる並列実行

async内部のawait対象はPromise.allで並列にもできる。

// 直列: 合計3秒(各1秒かかると仮定)
async function foo() {
  const a = await fetchA();  // 1秒待つ
  const b = await fetchB();  // さらに1秒待つ
  const c = await fetchC();  // さらに1秒待つ
}

// 並列: 合計1秒(各1秒かかると仮定)
async function foo() {
  const [a, b, c] = await Promise.all([
    fetchA(),
    fetchB(),
    fetchC(),
  ]);
}

ただしこれは各fetchが互いに依存していない場合に限る。 先の例のようにparse(data)dataを必要とするケースでは、直列にするのが正しい。 依存関係があるものを無理に並列にはできないのだから。

一方で、並列実行なら以下のようなものを使ったら擬似的にPromise.allなんじゃね?と思うかもしれない。

async function fetchA () {}
async function fetchB () {}
async function fetchC () {}

fetchA();
fetchB();
fetchC();

これは俗にfire and forget(撃ちっぱなし)と呼ばれるパターンで3つとも非同期で走ってはいるが完了を待つ術がない。 Promise.allでは終了地点が明確で、並行した処理の結果は確実に取得できるのだ。

const [a, b, c] = await Promise.all([
  fetchA(),  // 即座に開始
  fetchB(),  // 即座に開始
  fetchC(),  // 即座に開始
]);

// ここに来た時点で、A/B/C 全部終わっている
console.log(a, b, c);  // ← すなわち全て正しく取得できている

JavaScriptの非同期の注意どころは大体こんな感じだ!(たぶん)🍻