Web Audio APIのOfflineAudioContextを使うたびに増えていく、スレッド数とメモリ使用量の調査

経緯

音声を操作するWeb Audio APIという、WebブラウザAPIがあります。
そのAPIの中の、OfflineAudioContextというオブジェクトを、MacのElectron製アプリで利用していたのですが、
アプリを動作させながらアクティビティモニタを確認していたら、何かがおかしい。おかしい。


何故か、
音声を再生するごとに、
アプリが使用しているスレッドの数が増えていったのです。




↓(何回か再生)

ということがあり、これは困ったぞ、と、いろいろ調べてみたのでありました。

Electron 1.8.8 で検証

Electron バージョン 1.8.8。最初に問題を発見した環境。少し古い。
Electronに組み込まれているChromiumのバージョンは

>process.versions.chrome
"59.0.3071.115"


最小の検証コード(一部)
ボタンを押したら音声を再生する。
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron188/renderer.js

var fs = require('fs');
var audioCtx = new window.AudioContext();

function toArrayBuffer(buf) {
    var _aBuffer = new ArrayBuffer(buf.length);
    var view = new Uint8Array(_aBuffer);
    for (let i = 0; i < buf.length; ++i) {
        view[i] = buf[i];
    }
    return _aBuffer;
}

var btn = document.getElementById('play');
btn.addEventListener('click', function(elm, ev) {
    console.log('click play');

    // read
    fs.readFile('./voice.wav', function(err, bufWav) {
        if (err) {
            console.log('failed to read wav. path %s', './voice.wav');
            return false;
        }
        console.log('read wav');

        // decode audio
        var aBuffer = toArrayBuffer(bufWav);
        audioCtx.decodeAudioData(aBuffer).then(function(decodedData) {
            console.log('decode wav');

            // process audio
            var offlineCtx = new OfflineAudioContext(decodedData.numberOfChannels, decodedData.length, decodedData.sampleRate);

            var inSourceNode = offlineCtx.createBufferSource();
            inSourceNode.buffer = decodedData;

            var gainNode = offlineCtx.createGain();
            gainNode.gain.value = 0.4;
            inSourceNode.connect(gainNode);
            gainNode.connect(offlineCtx.destination);

            inSourceNode.start(0);

            // offlineCtx rendering
            offlineCtx.startRendering().then(function(renderedBuffer) {
                console.log('process wav');

                // play
                var audioNode = audioCtx.createBufferSource();
                audioNode.buffer = renderedBuffer;
                audioNode.connect(audioCtx.destination);
                audioNode.start(0);
                console.log('play processed wav');
            });
        });
    });

}, false);
発生した現象

前述した通り、OfflineAudioContextで音声を再生する毎に、動作しているスレッド数が増えていく。
メモリの消費量も徐々に増えていく。




↓(何回か再生)


アクティビティモニタで確認してみると、
"offline audio renderer"という、いかにもOfflineAudioContextに関係ありそうなスレッドが
OfflineAudioContextが不要になった後でも残り続けているようです。
いろいろ試してみましたが、Javascript側からこの問題を解消することはできませんでした。

2550 Thread_13873430: offline audio renderer
  2550 thread_start  (in libsystem_pthread.dylib) + 13  [0x7fffb3b3e08d]
    2550 _pthread_start  (in libsystem_pthread.dylib) + 286  [0x7fffb3b3e887]
      2550 _pthread_body  (in libsystem_pthread.dylib) + 180  [0x7fffb3b3e93b]
        2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x2640e7  [0x10ed4e0e7]
          2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x290709  [0x10ed7a709]
            2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x26f183  [0x10ed59183]
              2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x25a15c  [0x10ed4415c]
                2550 ???  (in Electron Framework)  load address 0x10eaea000 + 0x25a6bf  [0x10ed446bf]
                  2550 CFRunLoopRunSpecific  (in CoreFoundation) + 420  [0x7fff9e185114]
                    2550 __CFRunLoopRun  (in CoreFoundation) + 1361  [0x7fff9e1858c1]
                      2550 __CFRunLoopServiceMachPort  (in CoreFoundation) + 212  [0x7fff9e186434]
                        2550 mach_msg  (in libsystem_kernel.dylib) + 55  [0x7fffb3a4b797]
                          2550 mach_msg_trap  (in libsystem_kernel.dylib) + 10  [0x7fffb3a4c34a]

Electron 2.1.0 で検証

Electronのバージョンを上げて試してみる。
Electronを2.1.0に上げてみました。

>process.versions.chrome
"61.0.3163.100"


Electron 1.8.8で試した時のように、
音声を再生する毎にスレッド数が増えて残り続けることはなくなりました。
やった!!





しかし、ごっつりメモリを消費するようにもなってしまいました。
ギエエエエエエエ


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron210/renderer.js

Electron 2.1.0 に終了処理を追加

これには解決方法があります。
使用済みのOfflineAudioContext、AudioContextの終了処理をすれば良いのです。
disconnectとcloseを使えば良い。

(参考) Web Audio APIの闇
https://qiita.com/zprodev/items/7fcd8335d7e8e613a01f

var fs = require('fs');

function toArrayBuffer(buf) {
    var _aBuffer = new ArrayBuffer(buf.length);
    var view = new Uint8Array(_aBuffer);
    for (let i = 0; i < buf.length; ++i) {
        view[i] = buf[i];
    }
    return _aBuffer;
}

var btn = document.getElementById('play');
btn.addEventListener('click', function(elm, ev) {
    console.log('click play');

    // read
    fs.readFile('./voice.wav', function(err, bufWav) {
        if (err) {
            console.log('failed to read wav. path %s', './voice.wav');
            return false;
        }
        console.log('read wav');

        // decode audio
        var audioCtx = new window.AudioContext();
        var aBuffer = toArrayBuffer(bufWav);
        audioCtx.decodeAudioData(aBuffer).then(function(decodedData) {
            console.log('decode wav');

            // process audio
            var offlineCtx = new OfflineAudioContext(decodedData.numberOfChannels, decodedData.length, decodedData.sampleRate);

            var inSourceNode = offlineCtx.createBufferSource();
            inSourceNode.buffer = decodedData;

            var gainNode = offlineCtx.createGain();
            gainNode.gain.value = 0.4;
            inSourceNode.connect(gainNode);
            gainNode.connect(offlineCtx.destination);

            inSourceNode.start(0);

            // offlineCtx rendering
            offlineCtx.startRendering().then(function(renderedBuffer) {
                console.log('process wav');

                // play
                var audioNode = audioCtx.createBufferSource();
                audioNode.buffer = renderedBuffer;
                audioNode.connect(audioCtx.destination);
                audioNode.onended = function(evEnd) {
                  console.log('play processed wav ended');

                  inSourceNode.disconnect();
                  gainNode.disconnect();
                  audioNode.disconnect();
                  audioCtx.close();
                  global.gc();
                  console.log('close audio node');
                };
                audioNode.start(0);
                console.log('play processed wav');
            });
        });
    });

}, false);





メモリの消費量はだいぶ減りました。
音声再生しても、スレッド数も増えません。
しかし、まだまだ音声再生するごとに、メモリを消費してしまいます。


ChromiumのDevelop ToolのHeap Snapshotで、
音声再生前、音声再生後のスナップショットを取り、
どのようなゴミが残ってしまうのかと確認すると、

"Pending activities / 1 entries",
↓
"Pending activities / 2 entries",
↓
"Pending activities / 3 entries",


OfflineAudioContextで音声を再生する毎に、
"Pending activities"というゴミが増え続けていくことがわかりました。
こいつがメモリを掴んでいるのでしょう。多分。きっと。


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron210close/renderer.js

Electron 3.1.1 で検証

さらにElectronのバージョンを上げる。3.1.1。
ちなみに最新のElectronのバージョンは4であり、まだバージョンを上げる余地がある。
4は新しすぎるので、個人的にはまだちょっと不安ありますけれど。

>process.versions.chrome
"66.0.3359.181"






良くなった。
Heap Snapshotで確認してみたが、
"Pending activities"の数が、音声再生後に増えるようなこともない。
勝ったな!!(なにが?)


まだ少しずつはメモリの消費が増えている。
他にも問題があるのかな?
issueリストの中に該当するものがあるかもしれない。
https://bugs.chromium.org/p/chromium/issues/list?can=2&q=component:Blink%3EWebAudio&sort=-modified&colspec=ID%20Pri%20M%20Stars%20ReleaseBlock%20Component%20Status%20Owner%20Summary%20OS%20Modified


まぁでも、これぐらいなら良いでしょう。
(そ、そうかな?)


検証コード
https://github.com/taku-o/offlineaudiocontext-memoryleak/blob/master/electron311/renderer.js

おわり

以上、今回、見つけた出来事でした。


Webなら、画面をリロードすれば、画面遷移すれば、ブラウザを閉じれば、解決する程度の問題ですが、
Electronの場合は、基本的にSingle Page Applicationで、しかも、アプリは起動され続けるので、
そこそこの問題になってしまうのです。


ひとまず、MacのElectronで、Web Audio APIのOfflineAudioContextを使うなら、バージョン3以降だな!