Promise.race() で時間切れにできますが、遅いほうは裏で走り続けます。本当に止めるには AbortSignal が要ります。両方の書き方と、どちらを使うべきかの判断まで実行して確かめます。
答え
Promise.race() で、処理と時計を競争させます。
example.js
const sleep = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((unused, reject) => setTimeout(() => reject(new Error('時間切れ')), ms)),
]);
}
console.log(await withTimeout(sleep(1, '間に合った'), 50));
try {
await withTimeout(sleep(50, 'おそい'), 1);
} catch (error) {
console.log(error.message);
}
出力書き換えて実行できます
間に合った
時間切れ
javascript
const sleep = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
let finished = 0;
function slow() {
return sleep(30, 'おそい').then((value) => {
finished++;
return value;
});
}
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((unused, reject) => setTimeout(() => reject(new Error('時間切れ')), ms)),
]);
}
try {
await withTimeout(slow(), 5);
} catch (error) {
console.log(error.message);
}
await sleep(60);
console.log('裏で最後まで走った回数:', finished);
時間切れ
裏で最後まで走った回数: 1
Promise.race() は、最初に決着したほうの結果をそのまま返します。
→ Promise — then / catch / finally と async / await
ただし、処理は止まっていない
ここを誤解したまま使っている書き方が多くあります。
example.js
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(signal.reason);
});
});
}
const controller = new AbortController();
setTimeout(() => controller.abort(new Error('やめた')), 5);
try {
await sleep(100, controller.signal);
} catch (error) {
console.log('止まった:', error.message);
}
出力書き換えて実行できます
止まった: やめた
example.js
const controller = new AbortController();
console.log(controller.signal.aborted);
controller.abort();
console.log(controller.signal.aborted, controller.signal.reason.name);
出力書き換えて実行できます
false
true AbortError
Promise.race() がやめたのは待つことだけです。
中の処理は最後まで走ります。
困るのはこういう場面です。
- 時間切れにしたのに、通信の料金だけかかる
- 再試行を重ねるたびに、裏で走るものが増えていく
- 遅れて返ってきた結果で、新しい表示が古い値に上書きされる
本当に止めるなら AbortSignal
中止できる作りになっているものだけが、本当に止まります。
example.js
function sleep(ms, signal) {
return new Promise((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal?.addEventListener('abort', () => {
clearTimeout(id);
reject(signal.reason);
});
});
}
try {
await sleep(100, AbortSignal.timeout(5));
} catch (error) {
console.log(error.name + ': ' + error.message);
}
出力書き換えて実行できます
TimeoutError: The operation was aborted due to timeout
example.js
function classify(error) {
if (error.name === 'TimeoutError') return '時間切れ';
if (error.name === 'AbortError') return '中止した';
return 'そのほかの失敗';
}
const controller = new AbortController();
controller.abort();
console.log(classify(controller.signal.reason));
const timer = AbortSignal.timeout(0);
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(classify(timer.reason));
console.log(classify(new Error('通信できない')));
出力書き換えて実行できます
中止した
時間切れ
そのほかの失敗
AbortController が止めるための取っ手
controller.signal を、止めたい処理に渡す
controller.abort() で、聞いている全員に伝わる
abort() に何も渡さないと、決まった形の例外になります。
example.js
const user = new AbortController();
const signal = AbortSignal.any([user.signal, AbortSignal.timeout(1000)]);
console.log(signal.aborted);
user.abort(new Error('利用者が押した'));
console.log(signal.aborted, signal.reason.message);
出力書き換えて実行できます
false
true 利用者が押した
example.js
const sleep = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((unused, reject) => setTimeout(() => reject(new Error('全体が時間切れ')), ms)),
]);
}
try {
await withTimeout(Promise.all([sleep(1, 'a'), sleep(50, 'b')]), 10);
} catch (error) {
console.log(error.message);
}
console.log(await withTimeout(Promise.all([sleep(1, 'a'), sleep(2, 'b')]), 50));
出力書き換えて実行できます
全体が時間切れ
[ 'a', 'b' ]
時間切れだけなら1行で書ける
example.js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let open = 0;
async function run(ms, limit) {
open++;
try {
return await Promise.race([
sleep(ms).then(() => '成功'),
new Promise((unused, reject) => setTimeout(() => reject(new Error('時間切れ')), limit)),
]);
} finally {
open--;
console.log('後始末した');
}
}
try {
await run(50, 5);
} catch (error) {
console.log(error.message);
}
console.log('開いたまま:', open);
出力書き換えて実行できます
後始末した
時間切れ
開いたまま: 0
example.js
const sleep = (ms, value) => new Promise((resolve) => setTimeout(() => resolve(value), ms));
let latest = 0;
async function search(keyword, delay) {
const id = ++latest;
const result = await sleep(delay, keyword);
if (id !== latest) {
console.log(`${keyword} は古いので捨てる`);
return null;
}
return result;
}
const first = search('古い検索', 30);
const second = search('新しい検索', 1);
console.log(await second);
console.log(await first);
出力書き換えて実行できます
新しい検索
古い検索 は古いので捨てる
null
AbortSignal.timeout(ms) は、その時間で自動的に止まる合図を作ります。
自分で setTimeout を書く必要がありません。
fetch() は signal を受け取れるので、そのまま渡せば通信ごと止まります。このページの実行環境では通信を行わないため、待つ処理で示しています。
どちらを使うか
|
Promise.race() |
AbortSignal |
| 待つのをやめる |
できる |
できる |
| 処理そのものを止める |
できない |
できる |
| 相手の対応 |
要らない |
signal を受け取る作りが要る |
| 書く量 |
少ない |
多い |
signal を渡せる相手なら、AbortSignal を使ってください。
渡せない相手(自分で書いた重い計算など)には Promise.race() しかありませんが、
そのときは裏で走り続けることを承知したうえで使います。
何で終わったのかを見分ける
時間切れ・利用者の中止・ふつうの失敗は、分けて扱ってください。
example.js
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((unused, reject) => setTimeout(() => reject(new Error('時間切れ')), ms)),
]);
}
let attempt = 0;
async function unstable() {
attempt++;
await sleep(attempt < 3 ? 50 : 1);
return `${attempt}回目で成功`;
}
for (let i = 0; i < 3; i++) {
try {
console.log(await withTimeout(unstable(), 10));
break;
} catch (error) {
console.log(`${i + 1}回目: ${error.message}`);
}
}
出力書き換えて実行できます
1回目: 時間切れ
2回目: 時間切れ
3回目で成功
利用者が自分で中止したのに「失敗しました」と出すのは、いちばん嫌われる作りです。
中止は失敗ではありません。
利用者の中止と時間切れを両方待つ
AbortSignal.any() は、どれか1つでも止まったら止まる合図を作ります。
「利用者が押したら止める」と「30秒で諦める」を、1つにまとめられます。
全体に上限を付ける
1件ずつではなく、まとめて上限を付けたいことがあります。
→ 複数の非同期処理を同時に走らせる
後始末は finally に書く
時間切れでも中止でも成功でも、finally は必ず動きます。
「読み込み中」の表示を消す処理は、ここに書いてください。
→ Error と try / catch / finally / throw
遅れて来た結果を捨てる
Promise.race() しか使えない場面では、結果を採用してよいかを自分で見張ります。
後から始めたものが先に終わったら、前のものは捨てます。
これをしないと、入力するたびに表示が行ったり来たりします。
再試行と組み合わせる
1回あたりの上限と、やり直す回数の上限は別に決めてください。
どちらか片方だけだと、いつまでも終わらない場合があります。
→ 失敗したら再試行する
まとめ
- 時間切れは
Promise.race() で作れる
- やめられるのは「待つこと」だけ。処理は裏で走り続ける
- 本当に止めるなら
AbortController と signal
- 時間切れだけなら
AbortSignal.timeout(ms) が短い
signal を渡せない相手なら、遅れて来た結果を捨てる仕組みを自分で持つ
- 中止は失敗ではない。
AbortError と TimeoutError を分けて扱う
- 利用者の中止と時間切れは
AbortSignal.any() でまとめられる
- 「読み込み中」を消す処理は
finally に書く
- 1回あたりの上限と、やり直す回数の上限は別に決める