一覧PG-17-2 · 非同期処理の基礎

非同期処理のエラーとキャンセル

Node.jsの小さな試料で、失敗を受け取る位置、Promise.allの結果順と失敗時の残存処理、取消しの通知を確認します。最後はawaitを挟んだ更新と応答の逆転を再現し、修正前後を比較します。

説明

複数の非同期処理を開始すると、一つが失敗した後も別の処理が続いたり、古い結果が新しい表示を上書きしたりする場合があります。結果を待つ方法だけでなく、失敗後に何を続けるかを決める必要があります。

Node.jsの小さな試料で、失敗を受け取る位置、Promise.allの結果順と失敗時の残存処理、取消しの通知を確認します。最後はawaitを挟んだ更新と応答の逆転を再現し、修正前後を比較します。

1失敗を受け取る位置

async関数内のthrowと、呼出し直後のtry、awaitを囲むtryを比較します。catchで回復値を返す場合と再送出する場合も確認します。

cat failure.mjs失敗を受け取る各経路 node failure.mjs同期catch・await・回復・再送出

呼出しはPromiseを返すのでcall-returnedが出て、同期側のcatchは動きません。await側でbad recordを受け取り、finallyが実行されます。catchが0を返す経路は成功へ回復し、throwする経路は失敗を次へ伝えます。

2完了順と結果の配列順

二つのPromiseをA、Bの順で渡し、Bを先に完了させます。完了させる関数を保持して、順序を明示的に制御しています。

cat all.mjsBを先に完了させるコード node all.mjs完了順と結果順を比較

done Bがdone Aより先ですが、結果の配列は["A","B"]です。Promise.allの成功結果は完了順ではなく、渡した入力の順に並びます。

3一つの失敗が他を取り消すわけではない

一方を失敗させ、他方は未完了のままにします。allの失敗を受け取った後で、残った処理を完了させます。

cat all-failure.mjs失敗後に残る処理 node all-failure.mjs残存処理と最終状態を確認

A failedの時点でもslowはpendingです。その後でBを完了でき、状態はdoneになります。最後のallSettledは両方の最終状態を集め、rejectedとfulfilledを返します。allの失敗だけで別の仕事を取り消したとは扱えません。

4取消しを処理側へ伝える

waitForWorkはAbortSignalの通知を受けるとタイマーを解除し、取消し理由でPromiseを拒否します。通常完了では通知の登録を解除します。まず処理側のコードを確認します。

cat cancel.mjs通知に応じて停止する処理

AbortControllerは取消しの状態と理由を通知します。実際にタイマーを止めるのは、処理側のclearTimeoutです。開始時にすでに取消し済みの場合も、signal.abortedを確認して拒否しています。

5取消し後の状態

処理開始直後に取消し、理由と処理状態を表示します。

node cancel.mjs取消しと後処理を実行

user cancelledが伝わり、signal.abortedはtrue、処理側のstateはcancelledです。終了して不要になったタイマーは解除されています。

6通知を使わない処理

同じようにabortを呼んでも、signalを受け取らない処理が停止するか確認します。

cat ignored-cancel.mjs通知を参照しない処理 node ignored-cancel.mjs取消し状態と完了値

取消し状態はtrueでも、処理の結果はdoneです。AbortControllerが全てのPromiseを強制停止するわけではありません。取消しを受け付ける処理へsignalを渡し、処理側で対応する必要があります。

7awaitを挟んだ更新の取りこぼし

countを読んでからawaitし、古い値へ1を加える処理を二つ開始します。その後、読取りと更新をawaitの後へまとめた形で比較します。

cat race.mjsawaitの前後で更新位置を変更 node race.mjs更新一回分が失われる例と修正後

最初は両方が0を読み、それぞれ1を保存するため結果は1です。後半は各再開処理の読取りと更新の間にawaitがなく、結果は2になります。この修正は同じイベントループ内の例であり、別スレッド間の共有メモリまで原子的にするものではありません。

8古い応答が新しい表示を上書きする

先に開始したoldを後から完了させます。まず到着した結果を無条件に表示へ反映する経路です。

cat stale.mjs開始番号と表示更新のコード node stale.mjs unguarded到着した値を無条件に反映

freshのnew resultが表示された後、old resultへ戻ります。開始順と到着順は同じとは限らず、遅い古い応答が新しい状態を上書きしています。

9最新の要求だけを表示へ反映

同じ完了順で、最新の要求番号に一致する応答だけを反映します。

node stale.mjs guarded最新番号に一致する応答だけ採用

oldの応答が届いてもvisibleはnew resultのままです。この方法は古い処理そのものを止めるのではなく、古い結果が表示を上書きするのを防ぎます。取消しと結果の採用条件は別々に設計できます。

まとめ

失敗を成功へ回復するのか、次へ伝えるのかをcatchの戻り値とthrowで明示します。

Promise.allの失敗は他の処理の取消しを意味しません。取消しは通知を受けた処理側の対応が必要です。

awaitをまたぐ古い状態の利用や応答順の逆転は、一つの実行スレッドでも起きます。更新範囲と結果の採用条件を確認します。

操作履歴と解説を振り返れます。

シェル / Node.js

1
cat failure.mjs node failure.mjs
async function fail() { throw new Error("bad record"); }
let pending;
try {
  pending = fail();
  console.log("call-returned");
} catch {
  console.log("sync-catch");
}
try {
  await pending;
} catch (error) {
  console.log("await-catch", error.message);
} finally {
  console.log("finally");
}
const recovered = await fail().catch(() => 0);
console.log("recovered", recovered);
try {
  await fail().catch(error => { throw error; });
} catch (error) {
  console.log("rethrown", error.message);
}
call-returned
await-catch bad record
finally
recovered 0
rethrown bad record
2
cat all.mjs node all.mjs
let finishA, finishB;
const a = new Promise(resolve => { finishA = resolve; });
const b = new Promise(resolve => { finishB = resolve; });
a.then(value => console.log("done", value));
b.then(value => console.log("done", value));
const joined = Promise.all([a, b]);
finishB("B");
await Promise.resolve();
finishA("A");
console.log("results", JSON.stringify(await joined));
done B
done A
results ["A","B"]
3
cat all-failure.mjs node all-failure.mjs
let finishSlow;
let state = "pending";
const slow = new Promise(resolve => { finishSlow = resolve; })
  .then(value => { state = "done"; return value; });
const failed = Promise.reject(new Error("A failed"));
try {
  await Promise.all([failed, slow]);
} catch (error) {
  console.log(error.message, "slow:", state);
}
finishSlow("B");
console.log("later", await slow, state);
const results = await Promise.allSettled([failed, slow]);
console.log(results.map(r => r.status).join(","));
A failed slow: pending
later B done
rejected,fulfilled
4
cat cancel.mjs
const controller = new AbortController();
let state = "pending";
function waitForWork(signal) {
  return new Promise((resolve, reject) => {
    if (signal.aborted) { reject(signal.reason); return; }
    const timer = setTimeout(() => {
      signal.removeEventListener("abort", stop);
      state = "done";
      resolve("done");
    }, 10000);
    function stop() {
      clearTimeout(timer);
      state = "cancelled";
      reject(signal.reason);
    }
    signal.addEventListener("abort", stop, { once: true });
  });
}
const work = waitForWork(controller.signal);
controller.abort(new Error("user cancelled"));
try { await work; }
catch (error) { console.log(error.message); }
console.log(controller.signal.aborted, state);
5
node cancel.mjs
user cancelled
true cancelled
6
cat ignored-cancel.mjs node ignored-cancel.mjs
const controller = new AbortController();
let finish;
const work = new Promise(resolve => { finish = resolve; });
controller.abort("stop");
finish("done");
console.log(controller.signal.aborted, await work);
true done
7
cat race.mjs node race.mjs
let count = 0;
async function increment() {
  const old = count;
  await Promise.resolve();
  count = old + 1;
}
await Promise.all([increment(), increment()]);
console.log("lost-update", count);
count = 0;
async function incrementTogether() {
  await Promise.resolve();
  count = count + 1;
}
await Promise.all([incrementTogether(), incrementTogether()]);
console.log("together", count);
lost-update 1
together 2
8
cat stale.mjs node stale.mjs unguarded
const guarded = process.argv[2] === "guarded";
let latest = 0, visible = "";
function request(label) {
  const id = ++latest;
  let finish;
  const data = new Promise(resolve => { finish = resolve; });
  const done = data.then(value => {
    if (!guarded || id === latest) visible = value;
    console.log(label, "response:", value, "visible:", visible);
  });
  return { finish, done };
}
const old = request("old");
const fresh = request("fresh");
fresh.finish("new result");
await fresh.done;
old.finish("old result");
await old.done;
console.log("final", visible);
fresh response: new result visible: new result
old response: old result visible: old result
final old result
9
node stale.mjs guarded
fresh response: new result visible: new result
old response: old result visible: new result
final new result

演習完了