まるまるこふこふ

数々の次元が崩壊し、全ての生命が塵と化すのを見てきた。私ほどの闇の心の持ち主でも、そこには何の喜びも無かった。

Electron で手動GCを行う

さいです。
JavaScript と Electron でPC向けの弾幕シューティングゲームを作っています。
C++ と違って JavaScript はGarbage Collection (以降GCと呼称)がある言語なので、
ゲームプレイ中に不定期にGCが発生して、ゲームのパフォーマンスが悪化することがあります。

Object pooling を使って、GCの回数は減らす努力はしているのですが、
それでも(恐らく)GCの発生によって、古いPC等では露骨に画面が止まることが
あるので、Electron上で GCのタイミングを調整したいなぁと思いました。

Electron は Chromium ベースのプラットフォームであり、
Chromium は v8 エンジンを使用しています。
v8 エンジンのGCは Mark-Sweep 方式でのGCです。

v8 エンジンが自分で判断して行うGCを止める方法は
調べたのですが、無さそうです。

よって仮説として、メモリが潤沢にある環境であれば、オブジェクトが必要なくなってもすぐにオブジェクトへの参照を切らずにしておき、リザルト画面などのタイミングで不要なオブジェクトへの参照を切って、それからすぐに手動でGCを発生させることで、ゲームプレイに支障が出る場面でのGCをなくせそうです。

というわけでやっとこの記事の本題に入りますが、Electron 上で
手動GCを行う方法を書きたいと思います。

–expose-gc フラグを立てる

const electron = require('electron');
const app = electron.app;
app.commandLine.appendSwitch('js-flags', '--expose-gc');

app.on('ready', () => {
  // Your code here
})

app.commandLine.appendSwitch('js-flags', '--expose-gc'); を実行することで、Electron のレンダラプロセス上で global.gc() 関数を実行することができるようになります。

メインプロセス上のコードで app に対して、ready イベントが発火するまえに、app.commandLine.appendSwitch() を実行することで、
js-flags に引数を渡すことで、Electron の v8 エンジンに対して--expose-gc フラグを渡すことができます。

この辺は、公式の docs に記載されている。

github.com

global.gc() の実行

上記の方法で、v8 エンジンに --expose-gc フラグを渡すことで、
Electron のレンダラプロセス上で、global.gc() が実行できるようになります。

// 手動GCの実行
if (global.gc) {
    global.gc();
}

サンプルコード

サンプルコードを書いて、GCが行われているか確認してみます。

メインプロセス

// electron エントリポイント
'use strict';
const electron = require('electron');
const app = electron.app;
const dialog = electron.dialog;
const BrowserWindow = electron.BrowserWindow;
const globalShortcut = electron.globalShortcut;

app.commandLine.appendSwitch('js-flags', '--expose-gc');


let mainWindow;

function createWindow () {
        mainWindow = new BrowserWindow({
            "width":          640,
            "height":         480,
        });

    mainWindow.loadURL(`file://${__dirname}/index.html`);
    // Open the DevTools.
    mainWindow.webContents.openDevTools();

    mainWindow.on('closed', function () {
        mainWindow = null;
    });
}

app.on('ready', createWindow);

app.on('will-quit', () => {
    // Unregister all shortcuts.
    globalShortcut.unregisterAll();
});

app.on('window-all-closed', function () {
    if (process.platform !== 'darwin') {
        app.quit();
    }
});

app.on('activate', function () {
    if (mainWindow === null) {
        createWindow();
    }
});

レンダラプロセス

'use strict';

var Game = function() {
    this.frame_count = 0;

    this.bullets = [];
};
Game.prototype.run = function() {
    requestAnimationFrame(this.run.bind(this));

    this.frame_count++;

    // create
    for (var i = 0; i < 100000; i++) {
        this.bullets.push(new Bullet());
    }
    // run
    for (var j = 0; j < this.bullets.length; j++) {
        this.bullets[j].run();
    }

    // reset
    this.bullets = [];

    if (this.frame_count % 600 === 0) {
        if (global.gc) {
            console.log("done gc");
            global.gc();
        }

        console.log(process.memoryUsage().heapUsed);
    }
};

var id = 0;
var Bullet = function() {
    this.id = id++;
    this.frame_count = 0;
};
Bullet.prototype.run = function (){
    this.frame_count++;
};

window.onload = function() {
    var game = new Game();
    game.run();
};

レンダラプロセスは、1フレーム毎に、Bullet オブジェクトを10万個生成しては、その場で破棄しています。
また10秒に1度、GC を実行して、それからヒープ領域の使用量を console.log で出力しています。

f:id:sairoutine:20170513220817p:plain

上記が実行結果です。10秒ごとに GC が実行されているため、
そしてGC 実行後のヒープ領域は常に最小になっています。

f:id:sairoutine:20170513220823p:plain

こちらが、js-flags を追加しなかった場合です。
追加しなかった場合、レンダラプロセスには global.gc() 関数は存在しないので、
GC は実行されずに、ヒープ領域の使用量も不定(レンダラプロセス側の v8 エンジン任せ) となっています。

最後に

この記事を書いていて思ったのですが、メモリ解放対象のオブジェクトがなくとも、
GC時のルートオブジェクトからマーク済みオブジェクトを探索していく処理が、
ゲームに影響を与えるレベルの処理だと上述の方法は意味ないですね…

v8 エンジンが何を持ってGCを走らせてるのか知りたいなと思いました。