メモリを使用する、とは

この投稿は「Windows & Microsoft技術 基礎 Advent Calendar 2015」の16日目の記事です。

本稿では、Windows(広く一般のOSでも、基礎的な知識としては適合する)の、「メモリ使用量」の取り扱いについてまとめたものです。特に、コードからメモリを使用するとはどういうことなのかがちょっとでも明らかになれば良いかなと思っています。


普通の人、普通のプログラム、普通のプロセス

.NET環境であったり、C++で各ネイティブなコードであったり、通常プログラムを書くと「ユーザープロセス空間」で動くコードがビルドされます。C#でコードを書けば、newしたりすることで、「どこかにあるメモリ」を適量確保し、それを使用可能にしてくれます。

このメモリ使用量はどのように決まってくるのか? 例えば以下のコード:

var data = new byte[10 * 1000 * 1000];

は、配列の型がbyteなので、要素数=メモリ使用量となる事が分かります(配列管理に使用する付加的なメモリなどを除く)。他の型であれば、それぞれの「ストレージスペース」と呼ばれるサイズだけ使用されます。.NET環境の場合、クラス(参照型)の場合はもっと事情は複雑ですが、なんにせよ、その使用量は予測可能と言えます。

一般にメモリ使用量を見積もるのが大変な作業であると見られている理由の一つは、こういった個々のメモリ確保量(と解放量)がトータルでどのぐらいなのかを積算するのが難しいからだと思われます。上に挙げたような、コードを見ただけで一目瞭然の場合は問題ありませんが、

// ファイルパスを示す正規表現(Dobon.netから)
var regex = new Regex(@"\A(?:[a-z]:|{1})(?:{1}[^{0}]+)+\z");

のような、実装がカプセル化されている場合、そのメモリ使用量はどのぐらいでしょうか? プログラム全体(例えばコンソールアプリケーション)でどのぐらいのメモリ使用量かと言うのは、この例がスケールアップされたものとして考えられます。

他にもメモリ使用量の見積もりを難しくしている要因はあるのでしょうか? 実際、あまり理解されていない、重要な側面があるのではないかと思っています。以下の記事で、このメモリ使用量について掘り下げていきたいと思います。


Windowsにおけるメモリ使用量の指標

procexpmemoryこのスクリーンショットは、Process Explorerのシステムメモリ使用量の表示です。

赤枠で示した部分に、メモリ使用量の統計情報が表示されています。

  • Commit Charge: コミットチャージとは、アプリケーションが「これだけのメモリを使用する」と宣言した総量です。例えば前述のようなコードで示したメモリ確保コードによって、このコミットチャージが増加します。
  • Physical Memory: 物理メモリ使用量です。PCに搭載されているメモリと言えば、物理メモリを指すわけで、実際にこの物理的なメモリをどれだけ使っているのかを示します。
  • Kernel Memory: Windowsカーネルが使用しているメモリ量です。実際にはWindowsシステムだけではなく、デバイスドライバが確保したメモリなども、多くの場合ここに含まれます。
  • Paging: ページングメモリとは、カーネルが自由に移動可能なメモリの事です。

何故、このようにメモリ使用量を示す指標が沢山あるのかと言うと、「仮想メモリ技術」が存在するからです。


仮想メモリ技術とは

仮想メモリ技術は、文字通り、メモリを仮想化します。メモリを仮想化すると何が嬉しいのかと言うと、2つの見方があります。

ページング・スワッピング処理

physical-virtual-mapping搭載されている物理メモリの総量を超えて、更に多くのメモリを使用可能にします。この処理を、ページングやスワッピングと言います。

ページングとは、物理メモリをある一定のサイズ(現代のOSはほぼ4KBを基準とする)で区切り、これを1ページとして、この単位でメモリの管理を行います。このページを仮想メモリ空間に「割り付ける」事で、プロセスがメモリにアクセス出来るようにしています。

この図中に、緑の線で関連付けられたページがあります。このように、単一の物理メモリ空間を、複数のプロセスから同時に参照できるようにマッピングすることも可能です。こうすることで、コストがほぼ0の共有メモリが実現します。

また、わざとらしく、プロセス123と456の仮想メモリ空間の同じ位置にマッピングしていますが、マッピングする位置が同一でなくても構いません。この図で同一の場所にマッピングした例を示したのは、WindowsのPEローダー(EXEやDLLをメモリにロードする処理)がこの手法を使うためです。EXEやDLLをメモリにロードするとき、基本的に「コード」は不変です(自己書き換えと言う手法もありますが)。PEローダーはロード時にリロケーション処理と呼ばれる処理を行って、コードを少し修正します。その結果は、仮想メモリ空間の同じ位置にロードしようとする場合に限り、全く同じように修正されます。と言う事は、既に物理メモリ上にリロケーション処理済みのコードがある場合は、単にそのページを新しいプロセスの同じ仮想メモリ空間にマッピングすれば、再利用出来ることになります。

この図から、プロセスに割り当てられている仮想メモリは、必ずしも物理メモリ上に連続で配置されている必要も、昇順で並んでいる必要もない事にも注意してください。裏を返せば、Windowsカーネルは、物理メモリ上でいつでもこれを好きなように再配置することが出来て、しかも各プロセスはその事を知らない(感知できない)と言う事です。

pagefile搭載されている物理メモリが8GBとします。もし、仮想メモリ技術が無い場合は、どう頑張っても8GBを超えるメモリを使用する事は出来ません。8GBというと、複数のアプリケーション(複数のプロセス)が同時にメモリを要求するので、簡単に使い切ってしまう恐れがあります。

スワッピングは、仮想メモリ技術を使って、ハードディスクなどの外部ストレージに一時的に使用メモリ情報を(ページ単位で)退避したり復元したりする事で、まるで搭載メモリ以上のメモリが使用可能であるかのように見せかけます。この技術は、Windowsカーネルやデバイスドライバが「自動的」に行っているため、アプリケーションがこの操作を直接認識することは出来ません(Process Explorerのように、統計情報として取得することは出来ます)。

スワッピングは、物理メモリとディスクとの間で退避・復元を行う事に注意してください。プロセスから見える仮想メモリ空間から、直接ディスクが見えているわけではありません。これは、MapViewOfFile APIのような、ファイルマッピング技術においても同様です。つまり、スワッピングを成立させるには、必ず対応する物理メモリが必要です(原理上は不可能ではありませんが、実用的ではないため)。

メモリ空間のセパレーション

動作中の各アプリケーションのプロセス同士は、特別な手順を踏まない限りは不可侵です。不可侵とは、互いのプロセスのメモリ空間内を覗き見ることが出来ないことを指します(前述の共有されたページを除く)。

各プロセスは、それぞれが自身に専用に用意されたメモリを「個別に」十分に持っているかのように見えます。まるで、プロセス毎に専用の物理メモリが存在するかのようです。しかし、当たり前ですがそんな事はあり得ません。仮想メモリ技術が、本物の物理メモリから、一部のページを、それぞれのプロセスにマッピングして分け与えているのです。

セパレーションが行われる前提に立つと、アプリケーションコードは常に同じメモリ環境で動くことを想定出来るので、管理が簡略化されます。かつてのプログラムは、同一のメモリ空間に同居する必要があり、メモリを分割して影響を与えないようにするのは、プログラムを書いた人の責任でした。

# リロケーションと呼びますが、現在のコードは自動リロケーションも可能なので、この点での利点は薄れつつあります。
# また、.NETのようにメモリの管理がランタイムによって完全に支配的に行われる環境では、安全のためにメモリ空間を分割するという意味合いは多少薄れています。


メモリの確保とは

ここで、以下のようなC#のコードを使って、Win32のメモリ割り当てAPIを使用してみます。C++でコードを書いても結果はほとんど同じです。Win32 APIを直接使用する事で、.NETのガベージコレクタやヒープマネージャ、Visual C++のランタイムライブラリが行う追加の操作で結果が分かりにくくなることを回避します。

[Flags]
private enum AllocationType : uint
{
	COMMIT = 0x1000,
	RESERVE = 0x2000,
}

[Flags]
private enum MemoryProtection : uint
{
	READWRITE = 0x04,
}

[DllImport("kernel32.dll", SetLastError = true)]
private static extern UIntPtr VirtualAlloc(
	UIntPtr lpAddress,
	UIntPtr dwSize,
	AllocationType flAllocationType,
	MemoryProtection flProtect);

public static unsafe void Main(string[] args)
{
	// [1]: 仮想メモリ領域を割り当てる
	var size = 30UL*1000*1000*1000;
	var p = VirtualAlloc(
		UIntPtr.Zero,
		new UIntPtr(size),
		AllocationType.COMMIT | AllocationType.RESERVE,
		MemoryProtection.READWRITE);
	if (p == UIntPtr.Zero)
	{
		Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error());
	}

	// [2]: 割り当てたメモリ領域に値を書き込む
	var pBase = (byte*)p.ToPointer();
	var count = size / 4;
	Parallel.ForEach(
		Enumerable.Range(0, 4),
		baseIndex =>
		{
			var pRangeBase = (ulong*)(pBase + count * (ulong)baseIndex);
			for (ulong index = 0; index < (count / 8); index++)
			{
				*(pRangeBase + index) = ulong.MaxValue;
			}
		});
}

このコードは、VirtualAlloc APIを使用して、プロセス内に30GBのメモリを割り当てます。このときのProcess Explorerの様子を見てみます(テストしたシステムは16GBの物理メモリを搭載したWindows 10 x64です)。

※それぞれのタイミングをブレークポイントで一旦停止させています。

procexpmemory2これは、[1]の仮想メモリの割り当てが完了した直後の状態です。Commit Charge及び、上段の”System Commit”のグラフが36GBに跳ね上がっています。VirtualAlloc APIを呼び出すことで、プロセスの「仮想メモリ」の割り当てが行われたのです。物理メモリは16GBなので、明らかにそれ以上のメモリが「存在する」かのように扱われています。実際、VirtualAllocの戻り値には、メモリの読み書きに使用可能な、有効なポインタが返されます。

commit-bytesと、同時に興味深いこともわかります。Physical Memoryの使用量(Total – Available)又は、上段の”Physical Memory”のグラフは、全く変化していないのです。これはつまり、仮想メモリが割り当てられ、ポインタまで取得できて読み書き可能な状態であるのに、全く物理メモリの割り当てに変化がない、より直接的に言うならば「物理メモリは消費していない」のです。

更に、VirtualAllocの処理は一瞬で完了します。まるで、Windowsカーネルは、メモリを割り当てた「ふり」をして、実際には何もしていないかのようです。このような、メモリを割り当てることを宣言する(必ずしも物理メモリに関連づかない)事を「コミットする(Committed bytes)」と言います。

procexpmemory3この状態から、[2]を実行したときの様子がこのスクリーンショットです。

[2]の処理は、VirtualAllocで返されたポインタが、本当に正しい値を返しているのか、念のため実際にメモリにアクセス(ライト)しています。Parallel.ForEachを使っている理由は後で説明しますが、要するに高速化です(同時に4コアで4分割した領域にライトする)。

VirtualAllocが返したポインタが不誠実なものであれば、このコードは何らかの例外(AccessViolationExceptionなど)を発生させて死ぬでしょう。しかし、実際にはエラー無く成功します。但し、「とてつもなく遅い」のです。

  • ① コードの実行が開始されると、”Physical Memory”のグラフが跳ね上がり、16GB近辺まで上昇します。ここで、Windowsシステム全体が操作不能に近いぐらいにスローダウンします。グラフはスパイク状になっていますが、実際には上昇し切った時点でProcess Explorerもログ不能に陥ったようで、操作が回復するまでログが取れなかったためです。
  • ② 操作が回復したとき、Physical Memoryの値が急激に低下していますが、Parallel.ForEachの処理は終わっても、まだプロセスは終了していません。
  • ③ コードの実行が完了してプロセスが終了すると、System Commitのグラフも低下し、プロセス起動前の状態に戻っています。

physical-full仮想メモリへのポインタにアクセスした途端に、物理メモリを消費しはじめ、ほぼ実装メモリ量上限の16GBまで使い切っています。この事から、仮想メモリは実際に使用するまで、物理メモリを消費しない事が分かります。プロセスの仮想メモリ空間には、いつでもアクセス可能なメモリがそこに存在するように見えますが、使うまでは物理メモリにノータッチです。

①の最初に起きたことが、この図です。物理メモリ空間の未使用のページが、全てプロセスの仮想メモリ空間にマッピングされています。物理メモリ空間が全てマッピングされたため、物理メモリの空きが無くなりました。しかし、要求している仮想メモリは30GBなので不足しており、どうやってもこれ以上割り当てることは出来ません。

physical-full2ここで、スワッピングが始まります。物理メモリ上で「使用頻度の低い」ページが検索され、これらのページがディスクに書き出されます(スワップアウトと言う)。使用頻度が低い、とは、例えばですが、LRUアルゴリズムに従って判断したりすることになります。Windowsの場合はこのアルゴリズムは非公開であるので、どのようにページが決定されているのかはわかりません。

この図では、今まさに大量のアクセスが発生しているページではなく、プログラム開始時に確保した物理メモリ(仮想メモリ空間での緑色枠)が選択されたと仮定して、これらがディスクに書き込まれ、対応する物理メモリのページは「未使用(Unused)」としてマークされました。

プロセス内の仮想メモリ空間で、スワップアウトされたページ(緑色枠)は、「そこにメモリは存在する事になっているが、実際はディスクに存在していて、対応する物理メモリは存在しない」状態です。しかし、実害はありません。プロセス内のコードがこのページにアクセスしないうちは、この事実を知る者は(プロセス内には)居ないからです。この状態は、丁度VirtualAllocでメモリを確保した直後の状態に似ています。

physical-full3さて、これで幾らか物理メモリに空きが出来たので、仮想メモリ空間に割り当てることが出来ました。

が、まだまだ足りない…

physical-full4そうすると、「更に」、物理メモリ上で「使用頻度の低い」ページが検索され、これらのページがディスクに書き出されます。この場合、明らかに使用頻度が低いページはもうないのですが、直近ではスタート直後にライトしたページが、使用頻度が低いと言えなくもないです。もう一度言いますが、どのページが使用頻度が低いと判断するのかは非公開のため、実際にはここで述べたような判断基準ではない可能性がありますが、とにかく何らかの方法で犠牲(?)となるページを決定し、これをディスクに書き出してスワップアウトします。

仮にですが、このアルゴリズムがアホだったりすると、現在アクセスが頻繁に発生しているページをスワップアウトし、すぐにアクセスされてスワップインするという、システムがとてつもなくパフォーマンス低下を引き起こす原因となります。だから、どのページをスワップアウトさせるのかという決定ロジックは非常に重要です。

# 例えば、単にファイルがマッピングされているような状態であれば、復元はファイルを再マッピングするだけなので、ページの解放はディスクにスワップアウトを必要としません。このようなページがあれば、優先的に解放することが出来ます。EXEやDLLであれば、固定データやリソースデータ(COFFの.rdataや.rsrcセクション)などが該当します。

あとは、これをひたすら繰り返し、プロセスが仮想メモリにアクセスするの止める(実行を完了する)まで、仮想メモリへのアクセスを満足させればよいのです。

結局、問題となるのはどこでしょうか? 物理メモリをスワップアウトさせてディスクに書き込んだり、スワップインで復元させる時、ここが最も時間のかかる処理であるため、スワップ処理で忙しくなると、システム全体の応答性が悪くなるのです。

メモリ確保のまとめ

これが、メモリの仮想化による恩恵で、「不要」な、使用中のメモリの情報を一旦ディスクに退避し、これにより物理メモリ空間を未使用状態にし、新たに必要になった仮想メモリ空間に再配置して使用可能にする操作です。この操作はWindowsカーネルが内部で行っているので、アプリケーションは全くそんな事を感知せず、問題なく動作を継続します。

但し、ディスクへの退避と、あとで復元するために読み出す操作は、当然非常に時間がかかります。それが、システムがスローダウンした原因です。また、Parallel.ForEachで書き込み処理を並列化したのは、この退避処理を忙しくてそのほかの処理を行う余裕がないように仕向けるためです。もし、ディスクが非常に高速であった場合、システムがスローダウンせず、更にはスワッピングが高速に行われることで、物理メモリを上限まで消費しない可能性があったためです。

ここまでの実験で、物理メモリ(Physical Memory)の使用量を眺めていても、アプリケーションが消費するメモリ量というのは全く予測出来ない事が分かると思います。この結果から②がメモリ消費の見積もりとして何を意味するのかを考えるのは、無意味であることも想像できます。②で何故物理メモリ使用量が低下したのか、は、アプリケーションのメモリ使用量の考察とは何の関係もないからです。

# 大量の物理メモリを使用した後に、物理メモリのページが解放される挙動は、Windowsカーネルの最適化処理によるものと考えられます。メモリ不足が深刻な状況では、素早く空き物理メモリを確保した方が良いと思われるため、積極的に先回りして解放する手法が考えられます。


ワーキングセットと言う名の亡霊

前節のような、超高負荷アプリケーションが動作していても、ほかのプロセスが異常終了することはありません。もちろん、スワッピングに使用するディスク容量が残っていれば、の話です。しかし、あるプロセスがこのように大量のページに対して読み書きしていたら、ほかのプロセスに割り当てられているページはどうなってしまうのでしょうか?

スワッピングアルゴリズムの例を紹介しましたが、この動作がシステムの全てのプロセスに対して行われると考えればわかります。つまり、「システム全体で見て」使用頻度の低いページをスワップアウトして行くのです。例えば、Windowsのサービスプロセスは、用事があるまで待機したままである事がほとんどですが、このようなプロセスの仮想メモリは、対応する物理メモリがいつもスワップアウトしていても構いません。何故なら「時々しか動作せず、その時にだけページにアクセスされる」からです。

しかし、現在のWindowsは、そもそも物理メモリに余裕がある場合は、積極的にスワップアウトは行わないようです。スワップアウトするにはディスクにアクセスする必要があります。不必要と判断されたときは「暇」だと考えられるので、スワップアウト自体問題になりませんが、いざ動き始めた時にページがスワップアウトされているとスワップインしなければならず、これは高コストであるため、応答性に影響を与えます。

workingsetWindowsのメモリ使用量統計に「ワーキングセット」という数値もあります。これはProcess Explorerでワーキングセットを表示させてみたものです。標準のタスクマネージャでも「メモリ」というあやふやなカラム名で表示されるので、この数値がプロセスが使用するメモリ使用量だと思っている方も多いと思います。

前節の検証により、物理メモリの使用量を見ていても、アプリケーションのメモリ使用量は分からないという話をしましたね?

workingset-detailこのスクリーンショットは、ProcessExplorerで特定のプロセスのプロパティを表示させたものです。赤枠内にワーキングセットの統計値が出ています。が、この枠をよく見て下さい。”Physical Memory”と書いてあります。

ワーキングセットとは、ある時間において、そのプロセスの仮想メモリにマッピングされている物理メモリの総容量(総ページ数)です。つまり、この値は「物理メモリ量」を表しているのですよ!!!

「一体何を見ていたんだ俺たちは…」と思っていただけましたか?

# ほとんどこれが言いたかった (´Д`)

workingset3実際、先ほどのテストコードを実行したときに、テストコード「以外」の各プロセスのワーキングセットの推移を見てみると、とても面白い現象が目撃できます。

これは、テストコード実行直後のスクリーンショットですが、各プロセスのワーキングセットが軒並み低い値になっています。これは、テストコードのプロセスがあまりに大量の物理メモリを必要としたため、ほかのプロセスに割り当てられていた物理メモリもスワップアウトされて流用された結果です。かろうじて残っているワーキングセットは、「頻繁に使用されているページ」と判断されたのでしょう。

# 例えば、ウインドウプロシージャなどのコールバック関数が配置されたページは、コールバックが発生したらすぐに動作する必要があるため、対応するページは保持される可能性が高いでしょう。他にもロックされたページの可能性はありますが、ここでは述べません。

さて、ワーキングセットの統計値ですが、まあ、全く無意味な指標と言うわけでもありません。この値が普段から大きいと言う事は、メモリ参照の局所性が低い可能性があります。テストコードの場合は、30GBのメモリを丸々アクセスしているので仕方ありませんが、特に思い当たる節もなくワーキングセットが大きい場合、効率が良くないアルゴリズムを使っていたりすることが考えられます。

大げさなたとえですが、配列で保持すれば済むようなデータを、わざわざリンクリストで保持したりすると、ページにまたがってデータが分散する可能性があるので、少ない消費量でも複数ページをあっという間に消費する可能性があるのです。

ワーキングセットが大きいと、それだけスワッピングが発生したときのコストが高くなります。また、CPU内のキャッシュにも収まらなくなり、パフォーマンスが低下する原因となります。だから、その予兆を見極める目的で、ワーキングセットを使う事が出来ます。


メモリ使用量を見積もる、と言う事

私たちはシステム導入に際して、PCやサーバーに必要なの物理メモリ量の見積もりをするわけですが(実際のところ、あまりやりたくはない)、一体何を基準に見積もるのか、と言う事は十分考える必要があります。

見積もりの方法の一つとして、原則的にスワッピングやページングを発生させないほどの物理メモリを搭載する、と考える方法があります。これならば、System CommitsがPhysical Memoryを超えないぐらいに物理メモリを乗せれば良いわけで、基準としてはアリだと思います。しかし、この基準を適用すると、システムの直観的な消費量からは程遠い程の大量のメモリが必要になるかもしれません。それは、Committed bytesよりもプロセスのワーキングセットが極端に小さくなるような、メモリ参照局所性の高いアプリケーションに当てはまります。

自分が見積もりを行なおうとしているアプリケーションは、果たして前者のような極端なメモリ量依存の高いアプリケーションなのか、それとも、メモリ参照局所性の高いアプリケーションなのか? あるいはその中間なのか? 今まで示した仮想メモリの処理を前提に、それはどうやったら分かると思いますか?

どうしても見積もってくれと言われれば、そりゃ、計測するしかないですよね、アプリケーションそのものを実環境に近い環境で動かすか、あるいはそれに近しいシミュレーションコードで(それって、コスト掛かる作業ですよね。検証に見合うの?)。なので、私は、前提も明らかではない確定的なメモリ使用量の見積もりを見ると疑ってかかります。

「なにこの数字!どっから来たんだよっ!!」


次は、__yossy__さん、おねがいします!