メモリを使用する、とは

この投稿は「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__さん、おねがいします!

さようなら、Windows 5。

とうとう近づいてきた

win20032
最近、良く目につくようになった、「Windows Server 2003移行」の文字。GoogleやBingで検索すると、技術情報よりも延命措置の有償サービスが上位に表示されます。私の業務環境では既にレガシーWindowsを扱っていない事もあり、現実的な問題がどんなものかは想像するしかない状態です。

公式がどこかいまいち良く判らないので、リンクを張っておきます:Windowsライフサイクルのファクトシート

Windows XPが2014年4月にサポート切れとなり、もうすぐWindows Server 2003のサポートも切れます(2015年7月15日)。つまり、Windowsとしてサポートが存在するバージョンはVista以降、という事になります。

Windows XPはともかくとして、Windows Server 2003は個人的にとても良い印象のWindowsでした。Windows XPと大体において同じカーネルを持ち、Windows XPのように色々ゴテゴテと付いていないOSと見た時、2003の扱いやすさが光るのです。

# ええと、Vistaや7が嫌いと言う訳ではなく、むしろ「見た目」で入る製品も好きなのですが、手の内でほぼコントロール出来る感も、それはそれで良いと思ってます。

また、Windows 5系列は、初めてx64のサポートが追加されたバージョンでもあります。初物だけあって、Windows XP x64 Editionは色々な制約があって、ユーザーサイドで必ずしも良いOSとは言えませんでしたが、Iteniumと違ってx86にやさしい(互換性が高い)事が良く、後続のWindows x64の基礎を作りました。また、同じx64カーネルを持つWindows Server 2003 x64はサーバー向けという事もあって、より良く改良されていました。

# Windows Server 2003 x64はWindows XP x64よりも、全ての面で安定性が高かった。
# デバイスドライバについて言えば、サードパーティはWindows XP x64を半ば無視ししていたのに対し、2003 x64での開発に注力していたことも大きいです。


カーネルモードの5

win20031Windows XPやWindows Server 2003は、Win32プログラミングを行う方なら知っているかもしれませんが、GetVersionEx APIで得られる「メジャーバージョン番号」番号は「5」です。この記事のタイトルはそこから来ている訳ですが、このバージョン5はWindows 2000からWindows Server 2003 R2までの系譜です。分かりつらいですね。Windows 5の一覧を載せましょう:

 

  • Version 5.0 – Windows 2000 x86 (Windows 2000 Professional/Server/Advanced server)
  • Version 5.1 – Windows XP x86 (Professional/Home/Starter)
  • Version 5.2 – Windows Server 2003 x86/x64/Itenium (無印/R2) / Windows XP Professional x64/Itenium

Windows XP x64は実は5.2なので、2003と同じ扱いである事が分かります。既にどうでもいいトリビアですが、Windows 2000、つまり5.0は、唯一のNEC PC-9800シリーズ向けの実装が存在しました。

# そういえば、NTの頃はMIPSやらAlphaやらもサポートされました(一時はSPARCの噂までありました)。

以前私はWDKを使って、Windows向けのストレージ系ツールを設計・開発していました。このブログのWDKのカテゴリでいくつか記事を書いているのは、その時得られた知見に基づいたものです。このツールは、Windowsのカーネルモードドライバ(フィルタードライバ)を含んでいて、ディスクI/Oを監視して色々操作するものでした。

テストの為に、ユーザーモードのアプリケーションで非常に高負荷なI/Oを発生させ、ntfs.sys・フィルタードライバ・物理ドライバ間のパフォーマンス測定を行っていました。その時に、Windows 5系列の各スタックは、全体的にパフォーマンスが高いなと思ったのです。

Windows 6(つまりVista以降)と5の間には、「乗り越える事が出来ない」程のパフォーマンス差がありました。残念ながら開発はWindows 7・2008R2までであったため、最新のWindows 8でどの程度改善されたのかは分かりませんが、それでもWindows 5系列の方が良いと思われます。その位の差がありました。

# VistaではSSE2への最適化も謳われていましたが、「そんなものは無かった」程の違いです。結局「Vista重い」というのは、エンドユーザーの正直な感想で大体合っていたなーと思います。

勿論、この例はシンセティックなベンチマークのようなものです。現実的なパフォーマンスの測定には、他のあらゆる要素が関係してきます。また、システムの利便性は、ベンチマーク結果だけで測る事は出来ません。ですが、ベンチマーク速度の話が出て来る度に、「あぁ、5のスタックだけ使えたらなぁ」という、不毛な妄想をしたものです。

実行環境について:Windows XP以上は、今の所HYPER-Vのゲスト拡張機能は問題なく動作するようです。Windows 2000については、リリースビルドは問題ありませんが、デバッグビルド(デバイスドライバデバッグ用の、アサーションが大量に挿入されたWindows)は動作しませんでした。


終焉

Windows XPの当初の印象は良くない物でした。また、Windows XPの終盤では、IE6の不具合と思しき症状も放置されたためにWindows Updateが出来ないという、最悪の状態でしたが、最後には修正されてほっとしました。

さて、現在、主に.NETの世界の住人として気になる事は、CLRとのバージョンの関係でしょう。Windows 5の系列は、大ざっぱにはCLR 2.0(.NET 2.0~3.5)のバージョンの扱いと重なります。つまりは、Windows 5が終焉するのに合わせて、CLR 2.0も終焉と考えても良いかなと言う点です。

Microsoft .NET Frameworkサポートライフサイクルポリシーを見ると、何だかややこしい事になっていますが、開発者の目線で見ると、もはやNuGetのサポートは避けられず、Visual StudioがWPFベースで実装されている事もあり、Visual Studio 2012での開発が最低レベル(少なくともCLR 4.0)という状況です。

NuGetは2010向けもありますが、パッケージの方が2010で検証していないものもあり、結局NuGetを使うなら、ほぼ最新のVisual Studioを使わなければならないため、古いバージョンでの開発は互換性問題を解決するよりも、問題を引き起こす方が大きいという、想像もしなかった状況になってきています。

また、NuGetのパッケージを使い、そのパッケージに脆弱性が見つかった場合、殆どのプロジェクトは最新版に対して修正を行うと思われます(そもそもNuGetにはバージョン分岐の概念が無い)。すると、脆弱性対策を行うには、パッケージを最新に更新すれば良い(=最新に更新しなければならない)、という事を意味します。

# 既にASP.NETのプロジェクトは、思いっきりNuGetに依存しているので、これを回避するのはかなり辛いです。

つまり、今後の開発は、同じプラットフォームバージョンが維持されることを前提に開発するのはほとんど不可能で、常に最新に追従し続けなければならない、事を意味します。これは既にスマートフォンベースの開発(iOS及びAndroid)では当たり前になってきており、好むと好まざるとにかかわらず、Windowsもその位の、素早く、変化に追従できる開発が求められる、という事です。

マイクロソフト周辺での事情の変化も大きい、IT全体での変化もとても大きなものでした。

そんな今日の状況を感じつつ、Windows 5を見送りたいと思います。

RIP Windows 5 versions.

win20033


※なお、文中で「GetVersionEx API」の話を持ち出しましたが、このAPIは非推奨です。「Version Helper functions」を使って下さい。

中華タブX89Winをアレする – 1円にもならない無駄な技術 Advent Calendar 2014

これは「1円にもならない無駄な技術 Advent Calendar 2014」の25日目の記事です。


Welcome中華タブ!

tablet1

それは忘れもしない、Microsoft Conference 2014の2日目、「MSC反省会」と称した呑み会の席の事。何だかごっつい「日本正規版 Windows Phone」と、もう一つ目に留まる自慢の一品を見た事から始まったのです。

これは、いわゆる中華タブレット「Teclast X89HD」。そこで目にした衝撃とは、7.9インチにて、2048×1536の超高解像度LCD、つまり「Retinaディスプレイ」だったのです。「iなんとか」、の世界ではもはや当たり前のこの液晶、Windows界隈では殆ど見かけません。そして何といっても、これならVisual Studioが捗るじゃありませんか!!!

tablet2

そう、それはつまり、こういう事なのです。

このサイズにしてこの解像度! むーん、欲すぃ…

しかし、このX89HD、この時点で再入荷の目途が立っていないとの事。翌日秋葉原に視察に行ったのですが、やはり売っていない orz
それ以来、再入荷されるのを心待ちにしていた所、とうとう販売開始、しかもマイナーバージョンアップして、「X89Win」として販売。速攻で予約、一週間程度で発送開始ですぐに来ました。

キタキタ、早速キーボードやマウスを接続し、BIOSがどんな風になっているのかを確認。おぉ、何だか普通のマザボBIOSみたいだ(AMI)。東芝タブレットのBIOSが本当に何も出来ないどころか、やたら使いにくいのに比べると、AMI BIOSで親近感があって良いです。もっとも、タッチパネルで操作出来ないので、BIOS触るときは必ずUSBキーボードが必要になります。

所でこのBIOS、設定可能項目が山のようにあります。それこそ、オーバークロッカー向けマザボの如く、です。これは面白そうだ、きっと何も考えずにconfiguration項目を全部有効にしているんじゃないだろうか?とか思いつつ、BIOS設定を色々弄っていて… あれ、起動しなくなった。


文鎮…

tablet3

もう、いきなりですよ。まったくダメ。電源長押しでも無反応。「これが噂の中華タブ洗礼か…」仕方が無いので、リセット的な何かが無いかと分解したのがこの写真です。分解してみたところ、半分ぐらいはバッテリー、もう半分がマザーボードという感じで、CPUと思わしき部分に広範囲の「ガム状のヒートシンク」が貼ってありました(写真では取り除いています)。

ちなみに、使い続けるなら分解はしない方が良いです。ツメがプラなので破壊しないで開けるのはかなり難しいです。無理にこじ開けた場合、ツメが緩くなって元通りキッチリとは閉まらなくなります。

しかし、マザーボード上を見ても、リセットジャンパーなどは見当たらず(あまり期待はしていなかったけど)、何か出来そうなものは皆無。空きスペースとして、3GモデムとかSIMスロットとオボシキパターンがあるので、今後3Gモデルが出るかも知れません。

tablet4

何かの参考になるかもしれません、アップの写真も撮っておきました。それにしても、中華タブの品質の悪さが揶揄されているものの、「ここまでの物をあの価格で実現してしまう」のは、かなりのインパクトがあるという印象でした。彼らはもっと製品の魅力にフォーカスすれば良いのに、とも思います。

さて、諦めきれなかったため、バッテリーを外して放電リセットを試みました。が、駄目でした。東芝タブでは放電してしまうと痴呆になって、最悪のやり直しが可能だったのですが、どうやらこれはフラッシュかEEPROMかにBIOS設定を記憶するようで、もう手詰まりな感じです。

結局年末近くで色々忙しかったこともあり、リプレースパーツと割り切って、諦める事にしました。そう、「リプレースパーツ」として。


Welcome中華タブ! アゲイン

tablet5

買っちまいました。もう一台。もう後に引けません。

さて、教訓から、怪しげなBIOS設定項目には触らない、と誓いを立て、まずはイメージバックアップします。この製品は、少し前に話題になったマイクロソフトの施策、「Windows 8.1 with Bing」の中文版がプリインストールされています。念のため、まずはこれのフルバックアップを取っておきます。バックアップは、日本語版で言う所のコントロールパネルの「回復」から、「回復ドライブの作成」で可能です。あらかじめUSBメモリ(32GB)を用意して臨みます。容量的には8GBでも行けるような気がします。手元に日本語版Windowsのマシンを置いておき、画面を比較しながらやれば選択肢を間違う事も無いでしょう。

そして、きれいさっぱり、Windows 8.1日本語版を入れなおします。プリインストールされたWindowsには日本語言語パックを追加する事が可能です。そうすれば、そのまま日本語で使い始める事が可能です。が、何しろ中華タブなので、何が入っているかわからない。真っ新にしておきたいというのが動機です。

wimboot

また、Visual Studio 2013とOffice 2013を使う事も目的となるので、内蔵ドライブ(eMMCで32GB)が手狭すぎます。素ではインストール出来ません。そこで、WIMBootという手法でこれらも全部インストールし、ディスクの空き領域を増やす作戦です。

WIMBootについては、「Windows 8.1 Updateの新機能「WIMBoot」を試す」が詳しいので参照してください。簡単に構造を説明すると、あらかじめ収めておきたいファイル群(WindowsのシステムファイルやOfficeやVisual Studio等)を圧縮イメージ(WIMイメージ)でeMMC内に保持し、個々のファイルはこのイメージ内の圧縮されたデータをポイントする事で、劇的に小さい容量での運用を可能にする機能です。

MSDNのWIMBootの解説は、不必要に面倒な事が書いてありますが、出来るだけ簡単にする方法として以下の手順を考えます。

  • X89Winは、32ビットOSだけがブート可能です。どうもUEFI BIOSが32ビットOSイメージしかブートしなくなっているようで、東芝タブが逆の64ビットのみだった事もあり、しばらくハマっていました。
  • 母艦PCでHyper-V上に、ゲストOSとしてWindows 8.1 EE 32bitをインストールします。Proなら同様の手順で出来るかもと思いますが、無印は分かりません。ディスク形式はvhdx、仮想マシン世代はバージョン1で構いません。インストール時には、新規に適当なローカルユーザーアカウントを作成します。
  • ゲストOSでAdministratorユーザーを有効化(既定では無効)します。
  • ゲストOSでAdministratorでログオンし直し、インストール直後に作ったローカルユーザーアカウントを削除し、プロファイルも削除します。
  • Windows Updateや、必要なアプリケーションなどをインストールします。Windows installerなどを使わないアプリだと、WIMBoot後に正しく環境が再現されないかもしれません。当然OfficeやVisual Studioなら問題ありません。ユーザー固有のレジストリ(HKCUなど)を使うアプリは、その定義が失われるので、駄目かも知れません。
  • 「C:\Windows\System32\sysprep」フォルダのsysprep.exeを起動します。
    C:\Windows\System32\sysprep> sysprep.exe /generalize /oobe /shutdown
    

    sysprep

    このコマンドの処理後は、自動的にゲストOSがシャットダウンし、次回起動時にはプレインストールされたWindowsのように、新品の初期状態から起動するようになります。Administratorで作られたプロファイルは、sysprepによって削除されていますので、余計な容量を使ってしまう心配はありません。
    なお、このゲストOSは、インストールから日数が経過すると、アクティベーション出来ていない状態として警告されるようになってしまいます。その状態ではsysprepのgeneralizeに失敗するため、その場合はゲストOSを作り直す必要があります(sysprepし終わったイメージは、起動しなければ問題ありません)。

  • これでテンプレートとなるイメージが生成出来たので、このvhdxをマウントして、そこからWIMBootイメージを作成します。

さて、このような手順で進めるのですが、一つ問題があります。果たして標準のWindows 8.1のドライバー群がどこまでデバイスを認識出来るのか?と言う事です。残念ながら、この中華タブ、デバイスが非標準的な物ばかりで、殆どNGという状態。素のWindowsでは全く使い物になりません。

  • タッチパネルNG
  • センサーNG
  • グラフィックはIntel HD Graphicsの筈だがNG
  • SDIOがNG
  • SDIOにぶら下がるRealtekのWifiがNG
  • BluetoothがNG
  • パワーマネージメントがNG
  • その他いろいろNG

ですが、X89Winのデバイスドライバー群はどこから入手すれば良いのかと。Teclastではマスターイメージはダウンロード可能なようですが、個別のドライバパッケージは無いようです。姉妹機のX98シリーズのドライバは公開されていたのですが、認識されないものが多かったです(これを試すのにもかなり時間を使ってしまった)。Intel純正のドライバにしても、BayTrail-T関係のドライバはどうも公開されていないように見えます。NUC当たりのドライバも試したのですが、INFアップデートが行けたぐらいで、殆ど解決に至りません。


やっと本題

このブログでは、プログラムの情報を発信している事もあり、こういったデバイスのレビューが主目的ではありません。そこで、稼働しているマシンやイメージからデバイスドライバを抽出する、というユーティリティプログラムを書いてみました。

GitHub: CenterCLR.ExtractDrivers
ツール: CenterCLR.ExtractDrivers-1.0.0.0.zip

このツールを、稼働状態にあるWindows 8.1(Windows 8.1にのみ対応)に対して使うと、そこにインストールされているドライバ(Windows標準のドライバを除く)をパッケージ毎に抽出してくれます。
例えば、Windowsのイメージ(*.wimをマウントした中身や物理的なファイル)が「D:\Images\x89win_windows\」配下にあり、いわゆる「Windowsフォルダ」がその下にある場合、以下のコマンドでそこからドライバ群を抽出します。

C:\WORK> CenterCLR.ExtractDrivers.exe D:\Images\x89win_windows\Windows

抽出されたドライバ群は、既定でカレントフォルダ配下の「DriverStore」に保存されます。注意点として:

  • INFファイル(*.inf)によって自動的にPnPインストールが可能なドライバだけが対象です。カスタム化された専用のインストーラーを使用するような、いい加減なドライバは抽出出来ません。Windows標準のドライバインストレーションに従っている必要があります。
  • マルチターゲットドライバ(32ビット・64ビット両対応)のドライバは、抽出に失敗するかも知れません。
  • 稼働状態と言っても、出来るだけオフラインで実行して下さい。X89Winで言うなら、Teclastからイメージをダウンロードして、その中に含まれている「install.wim」をdismでマウントして抽出するのが確実です。又はイメージバックアップしたものを母艦でマウントして、そこから抽出して下さい。動作中のWindowsから抽出する場合、ドライバファイルのコピー時にファイルのオープンに失敗する事があります。

さて、抽出と同時に、template.batを生成します。このファイルは、抽出したドライバ群を、WIMBootイメージに組み込むための「dismコマンド」のサンプルスクリプトです。これで、WIMBootイメージにドライバ群を組み込んでイメージを生成する事で、実機で起動時にドライバが自動的に組み込まれ、あたかも工場出荷時のように振る舞わせる事が出来ます。

この辺りの具体的かついい加減な説明 (;´Д`) や、使用したバッチファイル等は、GitHubに上げておいたので参考にして下さい。

最後に一点、X89Win固有の話ですが、センサーデバイスだけは自動認識されませんでした。理由は良く分かりませんがUMDFとしての認識に失敗しているようです(署名でエラーが出るので、これが原因かもしれません。多少不安ですが)。これはデバイスマネージャから「レガシーデバイスの追加」で「Kionix Sensor Fusion Device」を手動追加すると正しく認識されます。更に、レジストリエントリを追加する必要があります(GitHubにスクリプトをアップしてある「kxfusion.reg」ので使って下さい。これを入れないと、持ち替え時にディスプレイの方向が正しく変わりません)。

さぁ、これで心置きなくHack出来ますね!


あとがき

tablet6

このネタ、いつ書こうかと思っていたのですが、Qiitaで偶然に「1円にもならない無駄な技術」なるAdvent Calendarがある事を知って、エントリーしてみました。何と25日のトリを取ってしまって、このカレンダーを始めた方のイメージに合っているのかやや不安ですが、何かの参考になれば幸いです。

今年もあと少しですね。1円にもならない技術からでも、知識の発展はあるかなと思っています(むしろ)。来年も無駄な事をやりましょう!

Async訪ねて3000里 (6) : I/O完了ポートによるワーカースレッドの運用

前回、TaskCompletionSourceによるタスクの継続に、APCコールバックを使用した場合の欠点を示しました。

APCコールバックはユーザーモードから見た場合の挙動がわかりやすい(コールバックが実行される)のですが、MsgWaitForMultipleObjectsEx APIなどのAPCコールバック対応APIで待機する必要があり、待機したスレッド(通常はメインスレッド)からコールバックが呼び出されるため、そのスレッドだけでI/Oの完了を捌く必要があります。

仮にシステムに多くのCPUコアが存在する場合でも、I/Oの完了による継続処理(翻ってawait後の処理)が、単一のスレッドだけで処理されることになり、パフォーマンスが上がりません。


手が遅いので指摘されてしまいました (;´Д`) 最後のピースを埋めましょう。

カーネルモードからユーザーモードに、I/Oの完了を通知する方法は、APCコールバック以外にも方法があります。

ファイルハンドルやイベントオブジェクトなどのWin32カーネルオブジェクトは、「シグナル状態」と呼ばれる一種のフラグを持っています。このフラグは、それぞれ決められたタイミングで「非シグナル状態」から「シグナル状態」と変化します。たとえば、ファイルハンドルであれば、直前のI/O操作が完了した(IoCompleteRequest)タイミングで、ファイルハンドルがシグナル状態となります。

APCコールバックを使用する場合は、I/Oが完了した事をプロセス毎のAPCキューに挿入することで通知しましたが、シグナル状態による通知であれば、キューを操作するコストを削減出来、さらにAPCコールバック対応のAPIで待機することなく、完了した事を通知することができます。但し、シグナル状態による通知は、前述のとおり一種のフラグであるため、コールバックとは異なり、この契機で能動的に処理を行うことができません。

このシグナル状態は、WaitForSingleObjectWaitForMultipleObject APIで監視することができます。つまり、これらのAPIを使用すると、I/O処理が完了するタイミングまで、スレッドを効率よく待機(ハードブロック)することができます。

WaitForSingleObjectでは単一のスレッドがシグナル状態となるまで待機しますが、これをワーカースレッドによる複数のスレッドにまで拡張したものが「I/O完了ポート」です。I/O完了ポートを使用すると、複数のワーカースレッドをあらかじめ待機させておき、シグナル状態となる通知によって、次々とワーカースレッドを使って完了後の処理を実行することができます。

実はasync/awaitの非同期インフラストラクチャーは、APCコールバックではなく、I/O完了ポートによって実装されています。I/O完了ポートに紐づけられるワーカースレッド群は、ThreadPoolクラスのI/Oスレッドとして内部的に割り当てられています。ThreadPoolクラスのワーカースレッドは、古くはIAsyncResultインターフェイスによる初期の非同期インフラの駆動用として使用されてきました。.NET 3.5以降でThreadPoolの実装が見直され、より効率よく動作するようになり、TaskAwaiterのコールバック駆動用として使用しています。

つまり、APCコールバックではAPC待機APIの呼び出しで待機したスレッドがコールバックを実行していましたが、現在の非同期インフラはThreadPoolクラスが保持するI/Oスレッド群が、TaskAwaiterのコールバックを実行しています。そして管理はI/O完了ポートで行っているため、大量のI/O要求の完了処理も、複数のスレッドで同時に処理できるのです。

MSDNブログに、.NETにおけるI/O完了ポートの使用方法が示されていたので、参考にして下さい。


以上をまとめると、async/awaitによる非同期処理は、ハードウェアからの完了通知としてもっともロスが少ないと考えられ、理想的な環境であれば、余計なワーカースレッドは使われません。但し、I/Oの完了がユーザーモードに通知された際に、TaskAwaiterのコールバック処理(await文以降の処理)を実行するためにスレッドが必要なため、この時に限り、効率的にプールされたワーカースレッドを使います。

唯一このタイミングでのみ、わずかな期間(IoCompleteRequestからI/O完了ポートでスレッドが処理に割り当てられるまで)ですが、スレッドのハードブロックと見なせる空白の時間が存在しますが、その他の手法と比べて無駄がありません。モニタAPIや待機APIによる、余計なハードブロックを起こさないのですから、多数のCPUコアで並列実行する場合でも、コンテキストスイッチによるロスは皆無です。

ケースバイケースですが、従来は自前ワーカースレッドによる複雑な実装しか選択肢が無かったものが、async/awaitの構文だけで効率の高い処理を簡便に実現できるようになったのです。ぜひ、非同期処理を実践して行きましょう。

せがゆうさん、ありがとうございました!

Async訪ねて3000里 (5) : TaskCompletionSourceでTaskの継続へ

とうとうAPCコールバックが呼び出されました。ここで、TaskCompletionSourceクラスSetResultメソッドを呼び出して、結果をユーザーモードのコードに返却します。これは、疑似的に以下のようなコードとなります。

// ReadFileEx APIのAPCコールバックエントリーメソッド(疑似コード例)
private static void OnReadCompleted(uint errorCode, uint bytesTransferred, ref OVERLAPPED overlapped)
{
    // OVERLAPPED構造体のメンバーに保存しておいたTaskCompletionSourceを得る
    var taskCompletionSource = overlapped.TaskCompletionSource;

    // TaskCompletionSourceに結果をセットする
    taskCompletionSource.SetResult(overlapped.Buffer);

    // (アンマネージ構造体の解除など)
}

前回の説明で示したように、このコールバックはAPCキューを拾い上げたスレッドが実行します。そのため、何らかのスレッドが、事前にSleepExやWaitForMultipleObjectsExなどのAPIを呼び出し、APCの待機状態でなければなりません。それはさておき、これでTaskCompletionSourceクラスのTaskが非同期処理を継続できることになります。

ここまでの長い道のりで重要なのは、

  • 非同期I/Oを要求してから、非同期処理が完了するまでの間、いわゆるワーカースレッドで「疑似的」に並列動作を行っているのではなく、デバイスのハードウェア並列実行性をそのまま使用して、CPUが完全にフリーな状態を作り出している。

ことです。

async8

ワーカースレッドによる疑似的な並列実行では、並列性を担保するために「CPUコアのコンテキストスイッチング」を使用します。この機能は強力で、全く無関係なコードグラフ(実行単位)を、あたかも同時に動作させているかのようにふるまわせる事が出来ますが、切り替えには大きなオーバーヘッド、そしてメモリを消費します。

しかし、今まで述べた非同期I/Oに必要なオーバーヘッドは、「仕方なく必要となるリソース」以外にありません。仕方なく、とは、

  • 割り込み処理ではどうにもならないためにDPCキューを使うこと
  • スレッドとプロセスを特定するためにAPCキューを使うこと
  • 各ドライバ階層の個別に必要となる処理で要求されるリソース
  • WDMとカーネルがI/O要求を処理するために必要とするリソース

の事で、これ以外の処理は、何をしても「追加リソース」としてオーバーヘッドを伴うことが分かります。よって、非同期I/O要求は最小のコストで実現出来る、同時並列実行を高めるよい方法となります。


ところで、あまり突っ込んで説明していないが重要なポイントがあるので、さらに掘り下げておきましょう。

本当にワーカースレッドは使われないのか?

実はこれは巧妙な嘘です。というのも、これまでに紹介した非同期I/Oの流れは、いわゆる「最適な場合」に相当します。

async9

WDMではスタック化されたドライバ群が存在しますが、その中間処理で「ワーカースレッド」を使っている可能性があります。そこでは、独自の何らかのキューを使用して要求をバッファリングし、ワーカースレッドで処理を行っていると思われます。これは、サードパーティ製ドライバは勿論、Windows標準のカーネルモードドライバにも当てはまります。
(そして、若干ですが、処理の都合上避けられない理由のためにワーカースレッドを使うこともあります)

デバイス寄りのドライバコードであれば、ここにデバイス製品の優劣が生まれます。如何に最小のコスト(ワーカースレッドを使わない)で、I/O処理を捌く事が出来るかによって、製品の性能が左右されることになります。ユーザーモードのコードと同様に、完全に非同期I/O要求だけで駆動するドライバの開発は難易度が高くなり、処理をオフロードするため、ハードウェアにリッチなロジックが必要になる可能性があります。または、ワーカースレッドを使うことで、CPUリソースを消費してしまうものの、安価にデバイスを開発できる可能性もあります。

あるいは、デバイスによってはハードウェア割り込みが限定的にしか機能せず、「ポーリング」という古典的なループ処理で実現しなければならない場合もあります。これは勿論、ワーカースレッドで半永久ループを行うことで実現しているため、CPUリソースを消費します。

APCコールバックを実現するためのスレッドをどうやって待機させるのか

APCコールバックを実行するためには、あらかじめスレッドを待機状態にしておく必要があることは説明しました。SleepExやWaitForMultipleObjectsEx APIをコールすることで、APC待機状態を作る事が出来ます。

これらのAPIのバリエーションに、「MsgWaitForMultipleObjectsEx」もあり、これを使えばUIスレッドのメッセージループ中で、メッセージを待機しながらAPCも待機する事が出来ます。メインスレッドのメッセージループでこのAPIを使うことで、非同期I/Oの完了をメインスレッドで受け取り、APCコールバックをメインスレッドで実行する事が出来ます。

これはつまり、APCコールバックメソッド中で、自由にウインドウ操作が可能になることを意味します。

しかし、.NETのTaskクラスは、async/awaitで待機・継続するときにUIスレッドを自動的にキャプチャ出来るので、余計なお世話に見える

その通りです。APCコールバックを使用することで、本来ならTaskAwaiter.OnCompleteメソッドのUIスレッドキャプチャ処理がやってくれた作業が不要となります。しかし、.NETの世界でTaskクラスを使うのが前提であれば、APCキューを使った結果の返却は、却ってオーバーヘッドのように見えます。

async10

また、APCコールバックが実行されるためには、メッセージループでMsgWaitForMultipleObjectsExを使用する必要がありますが、これは大きな制限です。.NETの世界ではこのようなAPIを直接使用することはまれであり(通常、Applicationクラスに隠ぺいされている)、また既存の実装との親和性が気になります。

更には、APCコールバックでは、処理に対応するスレッドが限定されてしまうため、I/Oの完了が立て込む時にパフォーマンスが悪くなります。APCキューはまさしく「直列化されたキュー」に相当し、スレッドが一つずつ処理を行うため、完了した非同期I/Oの後続処理を並列実行したい(例えばシステムにCPUコアが沢山存在するなど)場合でも、APCキューのために並列実行が制限されます。

Async訪ねて3000里 (4) : I/Oの完了とAPC

前回からの続きです。

DPCのキューからキューエントリが取り出されて、DPCコールバックエントリが実行されます。ここで、ディスクデバイスからの結果を処理し(場合によっては、DMAで転送されたデータを移動や加工する)、「I/O要求の完了」とします。

I/O操作を完了するには、「IoCompleteRequest」カーネルモードAPIを呼び出します。これで、STATUS_PENDINGしていた要求が完了した事になります。

Async6但し、(2)で解説した通り、WDMドライバはスタッキングした階層構造になっています。ディスクデバイスの処理が完了しても、そこに至るまでの各階層のドライバが、個別に完了処理を行いたい場合があります。ここではその方法は述べませんが、あらかじめ準備しておけば、これらのドライバも完了と追加処理のタイミングを得る事が出来ます。

重要なのは、この一連の処理は、単一のCPUスタックコンテキスト(スレッドは不明なのでスレッドコンテキストではない)で実行される事です。階層の途中のドライバが再びSTATUS_PENDINGで処理を保留にしない限り、DPCのコールバックからの連続したコールスタックで実行されます。つまり、ここでもワーカースレッドは使われていない、と言う事です。

話を簡単にするため、全ての階層ドライバがI/Oをそのまま完了させたとします。すると、この階層化されたI/Oの要求の出発点である、「ZwReadFile」カーネルモードAPIまで辿ります。ここで本来の(カーネルモード)I/O要求が完了した事になります。

ZwReadFile APIはユーザーモードに遷移して、この事を元のプログラムに通知しなければなりません。ユーザーモードのReadFileEx APIは、処理完了時にAPCコールバックを呼び出す事になっています。しかし、ここに大きな壁があります。

  1. I/O要求が完了した時、コンテキストはDPCのCPUスタックコンテキストです。そのため、Windowsカーネルから見ると、スレッドさえ良く分からないという微妙な状態にあります。このままでは、ユーザーモードに遷移する事は出来ません。
  2. ユーザーモードに遷移するためには、呼び出し元のプロセスが特定出来なければなりません。上記のようにスレッドが不明では当然プロセスは特定出来ません。これは2つのパターンがあります。
    • DPCがCPUコアのアイドル状態から呼び出された場合は、スレッドは完全に不定です。スレッドが不定では、プロセスも特定出来ません。CPUがアイドリングでストールしている状態を、「アイドルスレッド」のように表現する事がありますが、このようなスレッドが実際に存在するわけではありません。実際、スレッドを管理するためのTEB(Thread environment block)構造体は存在しません。
    • DPCが不特定なスレッド(Arbitrary thread context)から呼び出された場合、理論上は何らかのスレッドのスレッドコンテキスト上で実行していることになりますが、このスレッドは、呼び出し元のプロセスとは全く関係ない可能性があります。というよりも、殆どの場合、知らないプロセスの知らないスレッドのスレッドコンテキストを間借りして実行しています。

スレッドについて補足:Windowsカーネルは、全てのプロセスの全てのスレッドを等価に扱います。ユーザーモードから見ると、まずプロセスが存在し、その中にスレッドが存在するように扱われていますが、実際にはすべてがスレッドで、スレッドの一部にプロセスが割り当てられているというイメージです。また、カーネルモードで生成されるスレッドは、特定のプロセスに紐づいていません。この連載で「スレッド」と呼ぶ場合は、これらをひっくるめたすべてのスレッドを指します。

Async7I/O要求を完了するときのDPCを実行するコンテキストが、「偶然に」呼び出し元のスレッドと同じという可能性は殆どあり得ません(タスクマネージャやProcess Explorerで確かめて下さい。Windows稼働時は、常にシステム全体で数千のスレッドが待機しています)。従って、このコンテキストでそのままユーザーモードに遷移し、APCコールバックを呼び出す事は出来ない事になります。


さて、APCコールバックを「APC」と呼んでいる理由を説明する時が来ました。APCの響きはDPCに似ていますね。実は、概念的にAPCとDPCは良く似ています。APCは「Asynchronous Procedure Call」の略で、カーネルモードからユーザーモードに遷移する時に、APCキューに要求を入れ、目的のプロセス空間の目的のスレッドでキューエントリーを取り出して実行するために使います。

DPCキューが割り込み処理からDPCコールバックコンテキストで実行させることを目的としたのに対し、APCキューが異なるスレッドから目的のプロセスのスレッドコンテキストで実行させることに対比出来ます。

(1)の解説で、ユーザーモードのReadFileEx APIからI/O要求を投げていたので、この時のスレッドでAPCキューの内容を取り出して、このスレッドコンテキストでコールバックを実行させる訳です。しかし、このスレッドは、わざわざ非同期I/O処理を要求したので、ReadFileExがERROR_IO_PENDINGを返した直後から、何か別の処理を行っている筈です。そのため、このスレッドが明示的にAPCキューを確認する機会を与える必要があります。

ReadFileEx APIで非同期I/Oを実行した後、何らかの並列実行を行って「手持ちぶたさ」が生じたときに、「SleepEx」ユーザーモードAPI「WaitForSingleObjectEx」ユーザーモードAPI「WaitForMultipleObjecstEx」ユーザーモードAPI「MsgWaitForMultipleObjecstEx」ユーザーモードAPIのいずれかを呼び出すと、APCキューがチェックされ、キューエントリーが存在すると、それに対応したAPCコールバックが実行されます。キューエントリーが無い場合は待機し、新たにキューエントリーが追加されるとAPCコールバックを実行します。

これらのAPIを呼び出していないと、元のスレッドでAPCコールバックを実行する事が出来ないため、キューの内容は保留されたままとなります。

Async訪ねて3000里 (3) : ハードウェア割り込みとDPC

前回、カーネルモードでのデータ読み取り要求が、最終的にディスクコントローラーにコマンドとして発行されるところまでを解説しました。ほとんどの場合、コマンドの発行は瞬時に完了するため、ディスクデバイスドライバーも「STATUS_PENDING」をすぐに返却します。

全体を通して、ReadFileEx APIの呼び出しから、ERROR_IO_PENDINGの返却までにかかる時間は一瞬、と見て良いでしょう。そして、ユーザーモードのコードは、直後から自由に別の処理を開始する事が出来ます。裏ではディスクコントローラーがハードディスクからデータを読み取るために、ヘッドを目的のトラックに移動しようとしている所です。

Async3

ここから、CPUは自由に別の処理を実行する事が出来ます。


さて、しばらくして、ディスクコントローラーがハードディスクからデータを読み取った、としましょう。読み取ったデータはSATA等のインターフェイスでコントローラーに送られてきますが、最初のコマンドに記述されていた通り、所定の物理メモリにこのデータを格納します。これはCPUではなく、ディスクコントローラーが自力でメモリに格納します。この動作を「DMA」(ダイレクトメモリアクセス)と呼びます。

そして、すべからく処理が完了した時点で、CPUに対して「俺、用事あるんだけど?」と通知を行います。この事を「ハードウェア割り込み」と呼びます。
殆どの場合、この通知を受け取ったCPUは、すぐに現在処理中の作業を中断し(後で何事もなく作業続行出来るようにするため、状態を全部記憶し)、「割り込み処理」と呼ばれる特別なメソッドの実行を開始します。

割り込み処理のコードは、ディスクデバイスドライバ内にあります。ここで、ディスクデバイスドライバは、改めてディスクコントローラーに状態を確認し、「あ、さっき要求したコマンドが完了したんだ」と言う事が分かります。なので、すぐにユーザーモードのコードに完了を通知したい…のですが、事は単純ではありません。

CPUは直前まで実行していた「何らかの処理」を放り出して、現在の割り込み処理コードを実行しています。つまり、直前までどのような状態であったのかを知ることは不可能に近いのです。例えば、

  • ユーザーモードの.NET ILコードを実行中
  • ユーザーモードの.NET CLRのガベージコレクタを実行中
  • ユーザーモードのエクスプローラーのアドインコードを実行中
  • ユーザーモードのサービスプロセスを実行中
  • ユーザーモードのDirectXライブラリを実行中
  • カーネルモードのWindowsメモリマネージャを実行中
  • カーネルモードのUSBデバイスドライバを実行中
  • カーネルモードのプロセススケジューラーを実行中
  • カーネルモードのディスプレイドライバを実行中
  • カーネルモードで完全にCPUがアイドル状態

など、他にも数限りない状態が考えられます。このような状況で、例えば、処理結果を返却する為に多少のメモリが必要で、動的に確保したいとします。

メモリを確保するには、「ExAllocatePool」カーネルモードAPIを使う事が出来ますが、このAPIの内部処理は、恐らくはWindowsメモリマネージャと、ページング・スワッピングカーネルコードが連携する必要があります。

しかし、上記リストにも示したように、Windowsメモリマネージャの何らかの処理を実行中に、その処理を放り出して割り込み処理を行っているかもしれません。このような状況下でメモリ割り当て処理をそのまま実行しようとすると、ステート管理が破たんする可能性があります。このような問題を「再入処理問題」と呼びます。

Async5従って、割り込み処理の実行中にはメモリ確保は出来ない事になります。他の処理なら可能なのでしょうか? 少し考えると分かりますが、結局、割り込み処理が実行される直前に何が行われていたのかが分からない限り、あらゆる処理は安全に処理できない可能性が非常に高い事となります。また、分かったとしても、あらゆる状況下を判断して個別に対応する事も、殆ど不可能と言って良いでしょう。

WDM(Windows Driver Model)では、ハードウェア割り込みの割り込み処理中でも安全に処理が可能なAPIを公開しています。しかし、そのようなAPIはごくわずかであり、やはり実作業的な操作は出来ません。一般的に推奨される割り込み処理は、DPC(Deffered Procedure Call・遅延プロシージャコール)キューに、キューエントリーを追加する事です。この処理のために、「IoRequestDpc」カーネルモードAPIを使う事が出来ます。もちろん、このAPIはハードウェア割り込み処理中でも使用可能です。


DPCとは一体何でしょうか? 何故このような事をする必要があるのでしょうか?

先ほど述べたように、割り込み処理中はあまりに制限が多く、事実上何も出来ないため、「都合のいいタイミングまで処理を遅延」するためにキューを使用します。DPCは割り込み処理コードと同じように、一種のコールバックです。割り込み処理コードでDPCキューにキューエントリーを追加しましたが、このエントリーが「いつかのタイミング」で拾い上げられ、指定されたコールバックが実行されます。このコールバック内なら(若干の制限はありますが)、割り込み処理では実現出来なかった様々な処理を安全に実行する事が出来ます。

Async4

一般のユーザーモードデベロッパーはDPCになじみが薄いと思うので、少しだけ垣間見えるものをお見せします。この図はSysinternals Process Explorerの表示です。赤枠の行が、ハードウェア割り込みとDPCの統計を表しています。1秒間当たりの割り込みとDPCコールバックの発生回数を示していますが、安静にしていても結構な回数発生している事が分かります。

DPCが「いつかのタイミング」で実行される、と書きましたが、具体的にはいつでしょうか? あまり深入りすると複雑なので省きますが、「CPUがアイドル状態」又は「カーネルモードAPI実行の区切りの良いタイミングを通過するスレッド」が、DPCキューからキューエントリを取り出して、コールバックを呼び出します。従って、ここでもワーカースレッドを生成する事はありませんが、後者の場合は、何らかの不特定なスレッド(Arbitrary thread context)を間借りして実行している、と言えます。このタイミングは、Windowsカーネルが完全に管理しているため、タイミングを任意にコントロールする事は出来ず、CPUアイドル実行か不特定スレッドによる実行かを選択する事も出来ません。また、DPCコールバックを実行中は、特定のスレッドに紐づかないため、スレッドを特定する事は無意味となります。

なお、これまでの説明は、CPUが1個のみ(1コア)である事を前提としています。マルチコア・マルチCPUの場合、これらの動作は更に複雑になりますが、基本的には同じ構造を踏襲しています。

さて、これでユーザーモードのコードに処理完了の通知が出来るようになりました。次回はどうやってユーザーモードに通知されるのかを追います。