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 に記載されている。
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
で出力しています。
上記が実行結果です。10秒ごとに GC が実行されているため、
そしてGC 実行後のヒープ領域は常に最小になっています。
こちらが、js-flags
を追加しなかった場合です。
追加しなかった場合、レンダラプロセスには global.gc()
関数は存在しないので、
GC は実行されずに、ヒープ領域の使用量も不定(レンダラプロセス側の v8 エンジン任せ) となっています。
最後に
この記事を書いていて思ったのですが、メモリ解放対象のオブジェクトがなくとも、
GC時のルートオブジェクトからマーク済みオブジェクトを探索していく処理が、
ゲームに影響を与えるレベルの処理だと上述の方法は意味ないですね…
v8 エンジンが何を持ってGCを走らせてるのか知りたいなと思いました。