Works by

Ren's blog

@rennnosuke_rk 技術ブログです

【JavaScript】同時並行モデル

JavaScriptが動作する仕組み

JavaScriptで書かれたスクリプトがブラウザから読み込まれた時、ブラウザに組み込まれたレンダリングエンジンによってその内容が解釈され、実行されます。スクリプト上では変数が定義されたり、オブジェクトが生成されたり、関数呼び出しが行われたりします。このスクリプトを解釈するレンダリングエンジンは、どのような規則に則って処理を実行していくのでしょうか。

同時並行モデル

JavaScriptでは、同時並行モデルと呼ばれる実行モデルに従います。同時並行モデルは関数呼び出しを管理する「スタック」、処理の単位である「メッセージ」、そして関数やメッセージから生成・参照されるオブジェクトの格納場所である「ヒープ」からなります。MDNでは、このモデルを以下のような図で表現していました。*1


f:id:rennnosukesann:20180326230945p:plain:w400

このモデルはJavaScriptランタイムをかなり簡潔に表現したものですが、大まかなJavaScriptの動きを把握したり、仕事などで議論する際には役に立ちそうです。

スタック

上記モデルにおけるスタックの役割は、呼び出された関数の順序と、各関数内における処理で必要な情報を保持することです。スクリプト上で関数が呼ばれるたびに、フレームと呼ばれる、ローカル変数と関数が必要とする引数を含むセットがスタックに積まれます。 呼び出された関数内でさらに関数が呼ばれる度、フレームがスタックの上から積まれていき、関数が終了あるいはreturnすることでフレームがポップされます。

function g(b){ 
  return b + 1; // ③
}

function f(a){ 
  return g(a) + 1; // ②
}

// ①
var result = f(3); // result : 5

実際のスクリプトとモデルの動きを照らし合わせてみます。最初、①で関数fが呼ばれ、スタックに一番目のフレームが積まれます。②の時点で関数fは処理を終了しますが、その前に関数gを呼び出します。このとき二番目のフレームもスタックに積まれます。関数gは③で引数bに+1をした値を返戻して終了するので、この時点で二番目のフレームがポップされます。再び②に戻り、関数fは関数gの返戻値にさらに1を加え返戻し、処理を終了します。ここで最後のフレームがポップされます。

関数スタックの概念は関数を扱う様々な言語の中で取り入れられていますが、JavaScriptも例外ではないようです。

キュー

キューの役割は、処理されるメッセージをキューイングすることです。JavaScriptの場合、スクリプト中の動的な処理だけでなく、ブラウザイベント等の非同期な処理も扱える必要があります。キューはそのような処理をメッセージと言う単位で格納しておき、順番に実行していきます。

メッセージの実行は、スタックが空になったタイミングで行われます。メッセージが実行されるとスタックフレームが開始され、関数呼び出しによってスタックにフレームがプッシュ/ポップされながら処理が進んでいきます。スタックが空になると処理は終了し、メッセージは破棄され、また次のメッセージの実行が開始されます。

ヒープ

ヒープは、メッセージ中の処理、すなわち関数内の処理で扱われるデータの置き場所となります。JavaScriptではデータを格納するために必要なヒープを動的に確保する一方で、不要になったデータは破棄し、ヒープの領域を確保しようとします。

// Dateオブジェクトの生成
// Dateオブジェクトを格納するのに必要なヒープ領域を確保する
var date = new Date();

動的な領域の確保で必ず議論になるのが、不要になったデータを破棄する処理、すなわちガーベジコレクションについてです。ガーベジコレクションは誰からも利用されなくなったオブジェクトを探し出し、その名の通り「ごみ」として扱います。ごみとなったオブジェクトは実質破棄された状態となり、破棄されたオブジェクトが確保していた領域は開放されます。

このガーベジコレクションを実現するための手法はたくさんありますが、基本的なガーベジコレクションアルゴリズムとして挙げられるのは、決まって以下の二つです。

  • Reference Counter
  • Mark & Sweep

Reference Counter

オブジェクトに対する参照の数をガーベジコレクタがカウントし、参照が0となったオブジェクトを破棄の対象とするアルゴリズムです。

// objが参照するオブジェクトへの参照カウンタ:1
var obj = {
  a : "foo",
  b : {
    c: "bar"
  }
};

// objが参照するオブジェクトへの参照カウンタ:2
var obj2 = obj;

var obj_a = obj.a;

var obj_b = obj.b;

var obj_b_c = obj.b.c;


// ① objが参照するオブジェクトへの参照カウンタ:0
obj = null;
obj2 = null;

// ②
obj_a = null;
obj_b = null;

// ③
obj_b_c = null;

注意しておきたいのが、オブジェクトへの参照が0になってもプロパティへの参照があれば、そのオブジェクトはガーベジコレクションの対象とはならない点です。例えば上記コードでは①で変数obj``obj2の参照先をnullとしており、objに格納されていたオブジェクト(便利のためOと表記します)を参照する変数がない状態です。しかし、変数obj_aがオブジェクトのプロパティaを参照しているため、Oガーベジコレクションの対象にはなりません。同様に、②でobj.a``obj.bへの参照を0としていますが、obj.b.cへの参照があるため、この段階でも対象となることはありません。③でobj.b.cへの参照を無くして初めて、Oはコレクションの対象となります。

また、Reference Pointerアルゴリズムでは、参照のサイクルを構成されるとコレクト不可能になる問題があります。参照のサイクルとは、以下のようにあるオブジェクトの参照先が自身のオブジェクトを参照しているような場合を指します。

var obj = {};
var obj2 = {};
obj.a = obj2;
obj2.a = obj;
obj = null;
obj2 = null;

この場合、常に互いが参照し合う状態になるため、どちらも参照カウンタが(少なくとも)1の状態になり、ガーベジコレクションの対象になりません。

Mark & Sweep

JavaScriptにおけるMark & Sweepアルゴリズムでは、rootと呼ばれるトップレベルのオブジェクトから参照可能なオブジェクトを次々に辿っていき、参照可の印をつけていきます(この処理をトラバースと言います)。トラバースは複数回実行され、印がつかなくなるまで実行されます。ヒープ領域に存在するにも関わらず参照されないオブジェクトには印がつけられず、そのようなオブジェクトはガーベジコレクションの対象となります。

rootオブジェクトからの参照可能性を検証するため、上記のような参照サイクルが原因でヒープを開放できない問題を回避できます。また、参照カウントの仕組みを持たないため、ガーベジコレクションが動かない間はReference Pointerよりも動作が高速になります。

まとめ

今回はJavaScriptの動作モデルと、ヒープ領域に関連してガーベジコレクションについて少し掘り下げました。JavaScriptレンダリングエンジン内で動作する仕組みの詳細は今回扱ったモデルよりずっと複雑みたいですが、興味はあるので後日また調べてみたいと思います。

参考文献

Concurrency model and Event Loop - JavaScript | MDN

Memory Management - JavaScript | MDN


*1:掲載した図はMDNを参考に独自に作成したものです