【javascript応用編】中断もできてasync/awaitも使える再帰setTimeoutループ処理のPromise版の自作する


2022/10/07
TypescriptのInterfaceでasync関数を定義する・async関数でクラス変数(this)を使う
TypeScriptのInterfaceでStaticなクラスメソッドを適用させる方法
蛸壺の技術ブログ|中断もできてasync/awaitも使える再帰setTimeoutループ処理のPromise版の自作する

以前の別記事で、setTimeoutを再帰的に利用した一定時間間隔でループする処理について紹介したことがあります。

合同会社タコスキングダム|蛸壺の技術ブログ
【Angular活用講座】Rxjs:repeatオペレーターで一定時間間隔の処理を行わせてみる

Rxjsで再帰的なループ処理を実行するためのrepeatオペレーターのデザインパターンを紹介します。

今回は標準のsetTimeout関数単体では用途的に不十分だった、
「タイマーの停止」「await/async可能な非同期処理」に対応したPromiseベースの一定時間毎に定期実行されるループ処理の処理の実装方法について考えてみます。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】Javascript&Typescriptプログラミング入門のためのオススメ書籍&教材特集

課題の再確認〜標準のsetTimeout関数は「非同期割り込み」できない

まず以前のブログ記事で紹介していた「再帰したsetTimeout関数」について、今回の問題点となるsetTimeout関数の内部でPromiseの呼び出しがどのように振る舞うか、少し復習します。

javascripで一定時間間隔で定期的な処理をテクニックとして、以下の方法を紹介していました。

            
            const ms = 1000; //待機時間(ms単位)
let timerId = setTimeout(function tick() {

    //☆再帰的処理の中身(定期実行したい処理を記述)

    timerId = setTimeout(tick, ms);
}, ms);
        

javascriptの標準APIの一つである
setTimeout関数にはいくつかのオーバーロードタイプが存在しますが、基本形は第一引数がコールバック関数、第二引数が待機時間時間になります。

一見、コールバック関数をasync指定にしておけば、awaitが使えて、その間はタイマーも停まり、元の待機時間が更に遅延してくれるように思えます。

            
            const ms = 1000;
//👇コールバックをasync指定
let timerId = setTimeout(async function tick() {
    //...
    timerId = setTimeout(tick, ms);
}, ms);
        
実際のところ、これでもたまたま問題なく処理されるときもあれば、意図しない挙動で制御不能になるときもあり、それがこの問題を分かりにくく、理解の難しいものにしています。

ここでのタイマーはグローバルスコープで
timerIdをインスタンス化しているので、setTimeoutが待機してくれるのはメインプロセスで走っているグローバル時間になります。

他方で、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] 経過後に開始
...以下省略
        
見てのように、サブプロセス(コールバックの内部処理)が非同期処理中の間に、メインプロセス側(外部)からタイマーを停止させたつもりになっていても、実際には「タイマーが正常に止まってくれる保証がない」、ということが問題になってきます。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】Javascript&Typescriptプログラミング入門のためのオススメ書籍&教材特集

非同期割り込みにも使える再帰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メソッドを使って別のPromiseインスタンスへ繋ぐことで、解決(resolve)した値を繋いだPromise側へ送り出すことができます。

基本的にこの
thenメソッドを使って、Promiseから他のPromiseへいくらでも接続できるので、これをメソッドチェーンと呼んでいます。

また、一つのPromiseインスタンスは、内部の処理が完了することで、引数の
resolveを通して、その返り値にまた新しいPromiseインスタンスを返します。

なお、内部の処理が正常に完了せずに、エラーが発生した場合、引数の
rejectを通して、エラーが起こった理由の情報を保持したPromiseインスタンスが返ります。

...ということで、話を元に戻して、
subscriber関数を良くみてもらうと、「Promiseを返す関数を返すPromiseを返す関数」になっています。

なかなか日本語にすると複雑ですが、「メソッドチェーン」を理解していたらさほど難しくはなく、
「処理を続ける場合はメソッドチェーンで別のPromiseへ繋ぎ、処理を終わる場合は新たなチェーンに繋がない」、ということをやっています。

とにかく慣れないうちは、Promiseのメソッドチェーンを再帰的に呼び出すのは、非常に混乱しやすい使い方なのは間違いありません。


合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】Javascript&Typescriptプログラミング入門のためのオススメ書籍&教材特集

まとめ

以上、機能を拡張するためにsetTimeout関数ベースにして、内部で非同期処理も利用できる再帰的ループをPromise対応にしてみました。

ここまで見てきたようにPromise版の再帰的なループ処理は非常に分かりにくく、実装するには少々複雑な構造にように感じます。

もともとのsetTimeoutを使った再帰ループ処理は、単純な構造ゆえに内部処理を理解しやすい、というメリットは失われ、もはや別モノの処理です。

話のネタとしては興味深いものでしたが、今回のようにタイマーを用いて複雑な処理を行う必要があるのなら、Rxjsのような時間的なイベント処理を制御するものに特化した外部ライブラリを用いるのが良いと思います。

以前の記事で取り上げた
『Rxjsを使ったWhileループ処理的なストリームの簡潔な設計方法』を紹介していますので、興味があればそちらとも比較してみてください。

合同会社タコスキングダム|蛸壺の技術ブログ
【Angular活用講座】Rxjs:repeatオペレーターで一定時間間隔の処理を行わせてみる

Rxjsで再帰的なループ処理を実行するためのrepeatオペレーターのデザインパターンを紹介します。

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログ【効果的学習法レポート】Javascript&Typescriptプログラミング入門のためのオススメ書籍&教材特集