🤞

可変長Promiseチェーンで発生するメモリリークの防止策

2024/12/10に公開

背景

業務上、Promiseチェーンを多く繋いで、大きなデータを処理する必要があった。
これまでメモリリークについて意識することはなかったが、今回の業務における実装では、Promiseチェーンの長さや処理するデータの大きさから、メモリリークが発生する可能性があった。
そのため、Promiseチェーンにおいてどのような書き方をするとメモリリークが発生するのか調査した。

調査

以下のコードは大きなデータを作成し、次の処理へそのデータを渡すような可変長のPromiseチェーンを生成し、処理するコードである。
このコードをブラウザの開発者ツールのConsoleへ直接貼り付けて実行し、動作を確認した。

const tasks = [];
for (let i = 0; i < 1000; i++) {
     tasks.push(
         Promise.resolve()
         .then(() => new Promise(resolve => setTimeout(() => resolve()), 0))
         .then(() => {
             let a = new Array(9000000).fill('data');
             return a;
         })
     );   
}

tasks.reduce((prev, cur, index) => {
    return prev.then(() => cur).then(a => {
        console.log(index);
        return Promise.resolve();
    });
});

結果

私の環境下だと、115個程度の処理の時にメモリリークが発生した

考察

原因を探るために、以下のような次の処理へデータを渡さないパターンを実行して、動作を確認してみた。
このパターンだと、メモリリークは発生しなかった
このことから、次の処理へデータを渡さない場合はガベージコレクションの対象となり、次の処理へデータを渡す場合はガベージコレクションの対象とならないことが分かる。
もう少し正確に記載すると、次の処理へデータを渡した場合はPromiseが解決されるまでガベージコレクションの対象にはならないということである。

const tasks = [];
for (let i = 0; i < 1000; i++) {
     tasks.push(
         Promise.resolve()
         .then(() => new Promise(resolve => setTimeout(() => resolve()), 0))
         .then(() => {
             let a = new Array(9000000).fill('data');
             return;
         })
     );   
}

tasks.reduce((prev, cur, index) => {
    return prev.then(() => cur).then(() => {
        console.log(index);
        return Promise.resolve();
    });
});

上記から得た情報をもとに、次は以下のコードの動作確認を行った。
以下のコードは次の処理へデータを渡すまでは同じであるが、次の処理で明示的にデータの参照を切るようにしたものである。
このコードの結果としては、メモリリークが発生した
得たデータの参照を明示的に切るようにしても意味はないようであった。

const tasks = [];
for (let i = 0; i < 1000; i++) {
     tasks.push(
         Promise.resolve()
         .then(() => new Promise(resolve => setTimeout(() => resolve()), 0))
         .then(() => {
             let a = new Array(9000000).fill('data');
             return a;
         })
     );   
}

tasks.reduce((prev, cur, index) => {
    return prev.then(() => cur).then(a => {
        a = null;
        console.log(index);
        return Promise.resolve();
    });
});

データに対して参照を切る操作を行っても改善しないならば、大元のデータの参照を切るのはどうかと考えた。
つまり、tasks[index]nullを直接代入して大元から参照を切るという方法である。
以下のコードは上記を実装したものである。
このコードを実行した結果、コードは最後の処理まで実行することができた。
すなわち、メモリリークは発生しなかった
これは、tasksの中で該当のPromiseの参照を保持し続けているためにGC対象とならなかったのだが、tasks[index]nullを直接代入したことにより、該当のPromiseやデータの参照が完全になくなったことでGC対象になったのだと推測できる。

const tasks = [];
for (let i = 0; i < 1000; i++) {
     tasks.push(
         Promise.resolve()
         .then(() => new Promise(resolve => setTimeout(() => resolve()), 0))
         .then(() => {
             let a = new Array(9000000).fill('data');
             return a;
         })
     );   
}

tasks.reduce((prev, cur, index) => {
    return prev.then(() => cur).then(a => {
        tasks[index] = null;
        console.log(index);
        return Promise.resolve();
    });
});

結論

以下のようなコードにすることでメモリリークを防止することができる。

const tasks = [];
for (let i = 0; i < 1000; i++) {
     tasks.push(
         Promise.resolve()
         .then(() => new Promise(resolve => setTimeout(() => resolve()), 0))
         .then(() => {
             let a = new Array(9000000).fill('data');
             return a;
         })
     );   
}

tasks.reduce((prev, cur, index) => {
    return prev.then(() => cur).then(a => {
        tasks[index] = null;
        console.log(index);
        return Promise.resolve();
    });
});

まとめ

可変長のPromiseチェーンにおいて、メモリリークが発生する実装方法を調査し、メモリリークを発生させない対処法を考察した。
配列を用いた可変長のPromiseチェーンにおいては、処理が完了したPromiseの処理にnullを代入することで参照を切り、ガベージコレクションの対象とすることができる。
それにより、メモリリークの発生を防ぐことが可能となる。

Discussion