カテゴリー
【javascript応用編】中断もできてasync/awaitも使える再帰setTimeoutループ処理のPromise版の自作する
※ 当ページには【広告/PR】を含む場合があります。
2022/10/07

以前の別記事で、setTimeoutを再帰的に利用した一定時間間隔でループする処理について紹介したことがあります。
今回は標準のsetTimeout関数単体では用途的に不十分だった、
課題の再確認〜標準のsetTimeout関数は「非同期割り込み」できない
まず
javascripで一定時間間隔で定期的な処理をテクニックとして、以下の方法を紹介していました。
const ms = 1000; //待機時間(ms単位)
let timerId = setTimeout(function tick() {
//☆再帰的処理の中身(定期実行したい処理を記述)
timerId = setTimeout(tick, ms);
}, ms);
javascriptの標準APIの一つである
一見、コールバック関数をasync指定にしておけば、awaitが使えて、その間はタイマーも停まり、元の待機時間が更に遅延してくれるように思えます。
const ms = 1000;
//👇コールバックをasync指定
let timerId = setTimeout(async function tick() {
//...
timerId = setTimeout(tick, ms);
}, ms);
実際のところ、これでもたまたま問題なく処理されるときもあれば、意図しない挙動で制御不能になるときもあり、それがこの問題を分かりにくく、理解の難しいものにしています。
ここでのタイマーはグローバルスコープで
timerId
他方で、setTimeoutのコールバックから非同期処理であるPromise処理を呼び出したら、そこからは新たに別のサブ(子)プロセスが走るため、awaitが処理待ちするのは、サブプロセス上の別の時間になっています。

この場合、通常のsetTimeout関数のコールバック関数の中でasync/await構文を使おうとすると、予想のつかない滅茶苦茶な処理になってしまう恐れがあります。
この点を確認するため、以下のようなソースコード・
standard_loop.js
(() => {
let _time = Date.now();
let _count = 0;
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitRandom = async (count) => {
//👇1 ~ 4秒間のランダムな間隔で待機する
const ms = 1000 + Math.random()*3000;
await sleep(ms);
console.log(`[サブプロセス#${count}] 内部処理で ${~~ms}[ms] 遅延が発生`);
};
//👇メインループ処理の間隔は1秒空ける
const loop_ms = 1000;
let timerId = setTimeout(async function tick() {
console.log(`[メインプロセス#${++_count}] 処理を ${~~(Date.now() - _time)}[ms] 経過後に開始`);
_time = Date.now();
await waitRandom(_count);
timerId = setTimeout(tick, loop_ms);
}, loop_ms);
//👇プログラム開始10秒後にタイマーを終了する
setTimeout(() => {
console.log(`[メインプロセス] 外部処理でタイマーを終了!`);
clearTimeout(timerId);
}, 10000);
})();
このコードを利用して、何度か動かしてみます。
とある実行結果で、タイマーが上手く止まるときの出力は以下のようになっています。
[メインプロセス#1] 処理を 1003[ms] 経過後に開始
[サブプロセス#1] 内部処理で 2168[ms] 遅延が発生
[メインプロセス#2] 処理を 3170[ms] 経過後に開始
[サブプロセス#2] 内部処理で 2565[ms] 遅延が発生
[メインプロセス#3] 処理を 3569[ms] 経過後に開始
[サブプロセス#3] 内部処理で 1404[ms] 遅延が発生
[メインプロセス] 外部処理でタイマーを終了!
Done in 16.24s.
しかし、また別の実行結果では、以下のようにタイマー終了後もコールバックの処理が止まっていないため、プログラムが暴走してしまいます。
[メインプロセス#1] 処理を 1003[ms] 経過後に開始
[サブプロセス#1] 内部処理で 3197[ms] 遅延が発生
[メインプロセス#2] 処理を 4201[ms] 経過後に開始
[サブプロセス#2] 内部処理で 3095[ms] 遅延が発生
[メインプロセス#3] 処理を 4100[ms] 経過後に開始
[メインプロセス] 外部処理でタイマーを終了!
[サブプロセス#3] 内部処理で 2156[ms] 遅延が発生
[メインプロセス#4] 処理を 3158[ms] 経過後に開始
...以下省略
見てのように、サブプロセス(コールバックの内部処理)が非同期処理中の間に、メインプロセス側(外部)からタイマーを停止させたつもりになっていても、実際には
非同期割り込みにも使える再帰setTimeoutタイマーのPromise対応させる
では、先程掲げていた問題の解決策として、いかなるときでもタイマーを正常に終了させるためには、
これには、Promiseの機能を上手く利用する必要が出てきます。
そうなると、標準のsetTimeout関数だけでは機能が不十分ですので、setTimeoutメソッドもPromiseを使って大幅な改造を施さないといけません。
例えば、先程のスタンダードなsetTimeoutの再帰的処理をPromiseへ書き換えると、以下のようなソースコード・
promise_loop.js
(() => {
//👇外部からタイマーを停止させるためのフラグ
let stopFlg = false;
let _time = Date.now();
const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitRandom = async (count) => {
//👇1 ~ 4秒間のランダムな間隔で待機する
const ms = 1000 + Math.random()*3000;
await sleep(ms);
console.log(`[サブプロセス#${count}] 内部処理で ${~~ms}[ms] 遅延が発生`);
};
//👇ループ処理の完了を受け取るまでのPromiseを返す関数
function subscriber(i = 0) {
const loop = (count) => {
return new Promise(async (resolve) => {
//👇メインループ処理の間隔は1秒空ける
const loop_ms = 1000;
await sleep(loop_ms);
console.log(`[メインプロセス#${count}] 処理を ${~~(Date.now() - _time)}[ms] 経過後に開始`);
//👇ループ処理の中身(非同期処理も可能)
await waitRandom(count);
_time = Date.now();
//👇次回のチェーンループ処理の呼び出しで利用する
resolve(count+1);
})
}
return new Promise((resolve) => {
//👇チェーンループ処理(再帰的に呼び出しに相当)
loop(i).then((count) => {
//👇外部からのループ処理を停止させる条件
if (!stopFlg) {
//👇別のPromiseを使ってチェーンループに処理が続く
subscriber(count).then((res) => resolve(res));
} else {
//👇Promise自体のresolveで解決させるとループ処理が終了
resolve('[サブプロセス] 内部処理でタイマーも終了');
}
})
})
}
//👇ループ処理開始
subscriber(1).then((msg) => {
//👇ループ処理の終了メッセージを受け取る
console.log(msg);
});
//👇プログラム開始10秒後にタイマーを終了する
setTimeout(() => {
console.log(`[メインプロセス] 外部処理でタイマーを終了!`);
stopFlg = true;
}, 10000);
})();
これを実行すると、如何なる場合であれ、確実に内部の非同期処理もろとも終了させることが可能になっていると思います。
[メインプロセス#1] 処理を 1002[ms] 経過後に開始
[サブプロセス#1] 内部処理で 2934[ms] 遅延が発生
[メインプロセス#2] 処理を 3937[ms] 経過後に開始
[サブプロセス#2] 内部処理で 2538[ms] 遅延が発生
[メインプロセス#3] 処理を 3542[ms] 経過後に開始
[サブプロセス#3] 内部処理で 1091[ms] 遅延が発生
[メインプロセス] 外部処理でタイマーを終了!
[メインプロセス#4] 処理を 2093[ms] 経過後に開始
[サブプロセス#4] 内部処理で 3653[ms] 遅延が発生
[サブプロセス] 内部処理でタイマーも終了
とはいえ、標準のsetTimeoutを再帰的に利用した繰り返しループ処理よりも、その仕組みは非常に難関に思えます。
今回のPromise版ループ処理のキモは、ソースコード中の
subscriber
Promise特有の実装パターンに慣れるまでは、なかなか理解しにくいものですが、その根底にある考え方に
これは任意のPromiseインスタンスは、
then
基本的にこの
then
メソッドチェーン
また、一つのPromiseインスタンスは、内部の処理が完了することで、引数の
resolve
なお、内部の処理が正常に完了せずに、エラーが発生した場合、引数の
reject
...ということで、話を元に戻して、
subscriber
なかなか日本語にすると複雑ですが、「メソッドチェーン」を理解していたらさほど難しくはなく、
とにかく慣れないうちは、Promiseのメソッドチェーンを再帰的に呼び出すのは、非常に混乱しやすい使い方なのは間違いありません。
まとめ
以上、機能を拡張するためにsetTimeout関数ベースにして、内部で非同期処理も利用できる再帰的ループをPromise対応にしてみました。
ここまで見てきたようにPromise版の再帰的なループ処理は非常に分かりにくく、実装するには少々複雑な構造にように感じます。
もともとのsetTimeoutを使った再帰ループ処理は、単純な構造ゆえに内部処理を理解しやすい、というメリットは失われ、もはや別モノの処理です。
話のネタとしては興味深いものでしたが、今回のようにタイマーを用いて複雑な処理を行う必要があるのなら、Rxjsのような時間的なイベント処理を制御するものに特化した外部ライブラリを用いるのが良いと思います。
以前の記事で取り上げた
記事を書いた人
ナンデモ系エンジニア
主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。
カテゴリー