C#
C# メモリ モデルの理論と実践
今回は、C# メモリ モデルについて 2 回にわたってお伝えする連載の第 1 回です。今回は、C# メモリ モデルが保証することについて説明し、この保証を推進するコード パターンを紹介します。次回は、この保証が Microsoft .NET Framework 4.5 のさまざまなハードウェア アーキテクチャでどのように実現されるかを詳しく説明します。
コンパイラとハードウェアは、プログラムのメモリ命令をわずかに変化させることがあり、その方法によってはシングルスレッドの動作に影響しなくても、マルチスレッドの動作に影響することがあるため、これがマルチスレッド プログラミングを複雑にする要因の 1 つになることがあります。次のメソッドを考えてみます。
void Init() {
_data = 42;
_initialized = true;
}
_data と _initialized が通常の (つまり、volatile ではない) フィールドであれば、コンパイラとプロセッサはこれらの命令を並べ替えることが許され、Init は次のコードのように実行されます。
void Init() {
_initialized = true;
_data = 42;
}
コンパイラでもプロセッサでもさまざまな最適化が行われ、その結果このような並べ替えが行われます。このことについては、第 2 回に説明します。
シングルスレッドのプログラムでは、Init のステートメントが並べ替えられてもプログラムの意味は一切変わりません。メソッドから戻る前に _initialized と _data が両方更新されていれば、代入の順序は問題になりません。シングルスレッドのプログラムでは、2 つの更新の間の状態を読み取る可能性がある第 2 のスレッドはありません。
ところが、マルチスレッドのプログラムでは、Init の実行中に他のスレッドがフィールドを読み取る場合があるため、代入の順序が問題になります。つまり、命令の並べ替えが行われた Init で、別のスレッドが _initialized=true と _data=0 を読み取る場合があります。
C# のメモリ モデルとは一連の規則で、どの種類のメモリ命令の並べ替えが許可されているか、許可されていないかが示されています。すべてのプログラムは、仕様で定義された保証に従って書き込まれます。
ただし、コンパイラとプロセッサがメモリ命令を並べ替えることが許可されているからといって、実際には必ず並べ替えが行われるわけではありません。抽象型の C# メモリ モデルに従えば "バグ" を含んでいる多くのプログラムでも、特定のバージョンの .NET Framework を実行する特定のハードウェアでは正常に実行されます。とりわけ、x86 と x64 のプロセッサは特定の限られた場合にしか命令の並べ替えを行わず、同様に CLR の just-in-time (JIT) コンパイラは許可されている変換の多くを実行しません。
新しいコードを作成する際に想定するのは抽象型の C# メモリ モデルですが、既存のコードの動作を把握しようとする場合は特に、異なるアーキテクチャでメモリ モデルが実際にどのように実行されるのかを理解しておくと役に立ちます。
ECMA-334 に従う C# メモリ モデル
C# メモリ モデルの公式定義については、標準 ECMA-334 C# 言語仕様 (bit.ly/MXMCrN、英語) を参照してください。では、この仕様における定義に従って C# メモリ モデルについて考えて行きましょう。
メモリ命令の並べ替え: ECMA-334 によると、あるスレッドが別のスレッドが書き込んだメモリの場所を C# で読み取るときに、意味のない値と見なすことがあります。図 1 に、この問題を示します。
図 1 メモリ命令の並べ替えが危険性を生じるコード
public class DataInit {
private int _data = 0;
private bool _initialized = false;
void Init() {
_data = 42; // Write 1
_initialized = true; // Write 2
}
void Print() {
if (_initialized) // Read 1
Console.WriteLine(_data); // Read 2
else
Console.WriteLine("Not initialized");
}
}
DataInit の新しいインスタンスでは Init と Print が並列に (つまり、別のスレッドで) 呼び出されるとします。Init と Print のコードを確認すると、Print が出力するのは "42" か "Not initialized" のいずれかにしかならないように思えます。しかし、Print は "0" を出力することもあります。
C# メモリ モデルは、シングルスレッドの実行動作が変わらない限り、メソッドのメモリ命令の並べ替えを許可します。たとえば、コンパイラとプロセッサは Init メソッドの命令を次のように並べ替えることができます。
void Init() {
_initialized = true; // Write 2
_data = 42; // Write 1
}
このような並べ替えを行っても、シングルスレッドのプログラムの Init メソッドの動作は変わりません。ところが、マルチスレッド プログラムでは、Init がいずれか一方のフィールドのみを変更しもう一方を変更していない状態で、別のスレッドが _initialized フィールドと _data フィールドを読み取ることがあり、この場合は並べ替えによってプログラムの動作が変わります。その結果、Print メソッドは最終的に "0" を出力することになります。
Init の並べ替え以外にも、このコード サンプルには問題の原因となり得るものが含まれています。Init の書き込み順序が最終的に並べ替えられなくても、Print メソッドの読み取り順序が変更される場合があります。
void Print() {
int d = _data; // Read 2
if (_initialized) // Read 1
Console.WriteLine(d);
else
Console.WriteLine("Not initialized");
}
書き込み順序の並べ替えと同様、読み取り順序の変更はシングルスレッド プログラムには一切影響しませんが、マルチスレッド プログラムの動作が変わることがあります。つまり、書き込み順序の並べ替えとまったく同じように、読み取り順序の並べ替えによっても 0 が出力される可能性があります。
次回のコラムで、さまざまなハードウェア アーキテクチャを詳しく説明する中で、このような変更が実際に行われる方法と理由を紹介します。
volatile フィールド: C# プログラミング言語は、メモリ命令の並べ替え方法を制限する volatile フィールドを提供します。ECMA 仕様によると、volatile フィールドは取得/解放のセマンティクスを提供します (bit.ly/NArSlt、英語)。
volatile フィールドの読み取りには取得セマンティクスがあり、その後の命令との並べ替えを行うことができません。volatile の読み取りは 1 方向に限定されます。つまり、前の命令とは並べ替えることができますが、後の命令とは並べ替えることができません。次の例を考えてみます。
class AcquireSemanticsExample {
int _a;
volatile int _b;
int _c;
void Foo() {
int a = _a; // Read 1
int b = _b; // Read 2 (volatile)
int c = _c; // Read 3
...
}
}
Read 1 と Read 3 は volatile ではなく、Read 2 は volatile です。Read 2 と Read 3 を並べ替えることはできませんが、Read 1 と Read 2 を並べ替えることはできます。図 2 に、Foo 本体の有効な並べ替えを示します。
図 2 AcquireSemanticsExample における読み取りの有効な並べ替え
int a = _a; // Read 1 int b = _b; // Read 2 (volatile) int c = _c; // Read 3 |
int b = _b; // Read 2 (volatile) int a = _a; // Read 1 int c = _c; // Read 3 |
int b = _b; // Read 2 (volatile) int c = _c; // Read 3 int a = _a; // Read 1 |
一方、volatile フィールドの書き込みには解放のセマンティクスがあり、前の命令との並べ替えは許可されません。次の例に示すように、volatile の書き込みは 1 方向に限定されます。
class ReleaseSemanticsExample
{
int _a;
volatile int _b;
int _c;
void Foo()
{
_a = 1; // Write 1
_b = 1; // Write 2 (volatile)
_c = 1; // Write 3
...
}
}
Write 1 と Write 3 は volatile ではなく、Write 2 は volatile です。Write 2 と Write 1 を並べ替えることはできませんが、Write 3 と Write 2 を並べ替えることはできます。図 3 に、Foo 本体の有効な並べ替えを示します。
図 3 ReleaseSemanticsExample における書き込みの有効な並べ替え
_a = 1; // Write 1 _b = 1; // Write 2 (volatile) _c = 1; // Write 3 |
_a = 1; // Write 1 _c = 1; // Write 3 _b = 1; // Write 2 (volatile) |
_c = 1; // Write 3 _a = 1; // Write 1 _b = 1; // Write 2 (volatile) |
後半の「volatile フィールドによる公開」で、取得/解放のセマンティクスについて再び取り上げます。
アトミック性: 認識しておく必要があるもう 1 つの事柄は、C# では値が必ずしもアトミックにメモリに書き込まれるわけではないことです。次の例を考えてみます。
class AtomicityExample {
Guid _value;
void SetValue(Guid value) { _value = value; }
Guid GetValue() { return _value; }
}
あるスレッドが繰り返し SetValue を呼び出し、別のスレッドが GetValue を呼び出す場合、読み取り側のスレッドは書き込み側のスレッドが書き込んでいない値を読み取ることがあります。たとえば、書き込み側のスレッドが GUID 値 (0,0,0,0) と (5,5,5,5) を指定して SetValue を交互に呼び出すと、GetValue は、SetValue が代入を行っていない、(0,0,0,5)、(0,0,5,5)、(5,5,0,0) などを読み取ることがあります。
このような "現象" が生じる理由は、代入 "_value = value" がハードウェア レベルでアトミックに実行されないためです。同様に、_value の読み取りもアトミックに実行されません。
C# ECMA 仕様では、アトミックに書き込まれることを保証する型として、参照型、bool、char、byte、sbyte、short、ushort、uint、int、および float を挙げています。ユーザー定義の値型などの型の値がメモリに書き込まれるときは、アトミックな書き込みが複数回行われる場合があります。結果として、読み取りスレッドは異なる値の寄せ集めから成る、"切れ切れの" 値を読み取ることがあります。
通常はアトミックに読み取りと書き込みが行われる型 (int 型など) でも、値がメモリ内で正しく整列されていなければ非アトミックに読み取りや書き込みが行われる場合があることに注意します。通常は、C# は値が正しく整列されるようにしますが、ユーザーは StructLayoutAttribute クラス (bit.ly/Tqa0MZ、英語) を使用してその整列をオーバーライドすることができます。
並べ替え以外の最適化: 一部のコンパイラの最適化では、特定のメモリ命令が組み込まれたり、取り除かれたりすることがあります。たとえば、コンパイラはフィールドを繰り返し読み取る場合、1 つの読み取りに置き換えることがあります。同様に、コードがフィールドを読み取り、ローカル変数に値を格納して、その変数を繰り返し読み取る場合、コンパイラは代わりにフィールドを繰り返し読み取ることを選択する場合があります。
ECMA C# 仕様では、並べ替え以外の最適化を規則から排除していないため、これは許可されていると考えられています。実際、次回に説明しますが、JIT コンパイラはこのような種類の最適化を実行します。
スレッドのコミュニケーション パターン
メモリ モデルの目的は、スレッドのコミュニケーションを可能にすることです。あるスレッドがメモリに値を書き込み、別のスレッドがメモリから読み取る場合、メモリ モデルは読み取りスレッドがどの値を読み取るかを指定します。
ロック: ロックは、多くの場合、複数のスレッドでデータを共有する最も簡単な方法です。ロックを適切に使用すれば、基本的にはメモリ モデルの懸念事項を一切気にする必要はありません。
スレッドがロックを取得するときには必ず、CLR はそのスレッドが、以前にロックを保持していたスレッドが行ったすべての更新を読み取ることを保証します。冒頭に紹介した例にロックを追加してみましょう (図 4 参照)。
図 4 ロックを使ったスレッドのコミュニケーション
public class Test {
private int _a = 0;
private int _b = 0;
private object _lock = new object();
void Set() {
lock (_lock) {
_a = 1;
_b = 1;
}
}
void Print() {
lock (_lock) {
int b = _b;
int a = _a;
Console.WriteLine("{0} {1}", a, b);
}
}
}
Print と Set にロックの取得を追加すると、シンプルなソリューションが実現されます。これで、Set と Print は互いにアトミックに実行されるようになります。lock ステートメントは、複数スレッドから呼び出される場合でも、Print と Set の本体がある意味シーケンシャルに実行されるように見えることを保証します。
図 5 は、Thread 1 が Print を 3 回、Thread 2 が Set を 1 回、Thread 3 が Print を 1 回呼び出す場合に起こり得る 1 つの順序を示しています。
図 5 ロックを使ったシーケンシャルな実行
ロックをかけたコードのブロックを実行するときは、ロックをかけた順序でそのブロックに先行するブロックのすべての書き込みが読み取られることが保証されます。また、ロックをかけた順序でそのブロックの後続ブロックの書き込みは前のブロックでは読み取られないことが保証されます。
要するに、ロックはメモリ モデルからあらゆる予測不能で複雑怪奇な動作を隠ぺいします。ロックを適切に使用すれば、メモリ命令の並べ替えについて心配する必要はありません。ただし、ロックは正しく使用しなければなりません。Print と Set のいずれか一方しかロックを使用しない場合、または Print と Set が 2 つの異なるロックをかけた場合は、メモリ命令は並べ替えられるようになり、メモリ モデルの複雑さが舞い戻ります。
スレッド API による公開: ロックは、スレッド間で状態を共有するための非常に一般的で強力なメカニズムです。スレッド API による公開は、同時実行プログラミングで頻繁に使用されるもう 1 つのパターンです。
スレッド API による公開を簡単に説明するため、次の例を使用します。
class Test2 {
static int s_value;
static void Run() {
s_value = 42;
Task t = Task.Factory.StartNew(() => {
Console.WriteLine(s_value);
});
t.Wait();
}
}
上記のコード サンプルを調べると、おそらく画面上に "42" が出力されると予想するでしょう。実際、その直感は当たっています。このコード サンプルは "42" と出力することが保証されています。
この例について説明する必要があるというのが驚きかもしれませんが、実際のところ、少なくとも理論上は "42" の代わりに "0" と出力することを許可する StartNew の実装もあり得ます。結局のところ、volatile ではないフィールドでコミュニケーションするスレッドが 2 つあるので、メモリ命令は並べ替えられる可能性があります。このパターンを、図 6 に示します。
図 6 volatile ではないフィールドによる 2 つのスレッドのコミュニケーション
StartNew の実装は、Thread 1 の s_value の書き込みが <start task t> の後には移動されず、Thread 2 の s_value の読み取りが <task t starting> の前には移動されないようにします。実際、StartNew API はこれを保証します。
Thread.Start や ThreadPool.QueueUserWorkItem など、.NET Framework の他のすべてのスレッド API も同様のことを保証します。実際のところ、ほとんどすべてのスレッド API には、正常に機能するためになんらかの防御壁となるセマンティクスが必要です。ほとんど文書化されていませんが、API を有益にするために保証をどのようにするべきかを考えれば、多くの場合自然と導き出されるでしょう。
型の初期化による公開: 複数のスレッドに確実に値を公開するもう 1 つの方法は、静的初期化子か静的コンストラクターで静的フィールドに値を書き込む方法です。次の例を考えてみます。
class Test3
{
static int s_value = 42;
static object s_obj = new object();
static void PrintValue()
{
Console.WriteLine(s_value);
Console.WriteLine(s_obj == null);
}
}
Test3.PrintValue が複数のスレッドで同時に呼び出された場合、それぞれの PrintValue の呼び出しは "42" と "false" を出力することが保証されるでしょうか。それとも、いずれかの呼び出しが "0" か "true" を出力するでしょうか。前の例と同様、予想どおりの動作が行われ、各スレッドは必ず "42" と "false" を出力します。
ここまでに説明したパターンはすべて、予想どおりに動作します。次に取り上げる例の動作は、予想外かもしれません。
volatile フィールドによる公開: 多くの同時実行プログラムは、以上で説明した 3 つのシンプルなパターンを .NET の System.Threading 名前空間と System.Collections.Concurrent 名前空間の同時実行プリミティブと共に使用して構築できます。
次に説明するパターンは、"volatile" キーワードのセマンティクスを中心に設計された非常に重要なパターンです。実際、volatile キーワード セマンティクスを覚える一番の近道は、前半に説明した抽象的な規則を覚えようとするのではなく、このパターンを覚えることです。
図 7 のコードの例から始めます。図 7 の DataInit クラスには Init と Print の 2 つのメソッドがあり、どちらも複数のスレッドから呼び出される可能性があります。メモリ命令が並べ替えられなければ、Print は "Not initialized" か "42" しか出力しませんが、Print が "0" を出力する可能性が 2 つあります。
- Write 1 と Write 2 が並べ替えられた場合
- Read 1 と Read 2 が並べ替えられた場合
図 7 volatile キーワードの使用
public class DataInit {
private int _data = 0;
private volatile bool _initialized = false;
void Init() {
_data = 42; // Write 1
_initialized = true; // Write 2
}
void Print() {
if (_initialized) { // Read 1
Console.WriteLine(_data); // Read 2
}
else {
Console.WriteLine("Not initialized");
}
}
}
_initialized が volatile としてマークされていない場合、どちらの並べ替えも許可されることになります。ただし、_initialized が volatile としてマークされていれば、どちらの並べ替えも許可されません。書き込みの場合、volatile の書き込みの前に通常の書き込みが置かれ、volatile の書き込みが先行するメモリ命令と並べ替えられることはありません。読み取りの場合は、通常の読み取りの前に volatile の読み取りが置かれ、volatile の読み取りは後続のメモリ命令と並べ替えられることはありません。
そのため、Print は DataInit の新しいインスタンスで Init と同時実行で呼び出されたとしても "0" を出力することはありません。
_data フィールドが volatile でマークされ、_initialized がマークされていない場合、どちらの並べ替えも許可されることに注意してください。そのため、この例を覚えるのが volatile セマンティクスを覚える近道です。
初期化の遅延: volatile フィールドによる公開のよくある変化形の 1 つが初期化の遅延です。図 8 の例で初期化の遅延を示します。
図 8 初期化の遅延
class BoxedInt
{
public int Value { get; set; }
}
class LazyInit
{
volatile BoxedInt _box;
public int LazyGet()
{
var b = _box; // Read 1
if (b == null)
{
lock(this)
{
b = new BoxedInt();
b.Value = 42; // Write 1
_box = b; // Write 2
}
}
return b.Value; // Read 2
}
}
この例では、LazyGet が常に "42" を返すことが保証されます。ただし、_box フィールドが volatile でなかった場合、LazyGet は、読み取りが並べ替えられ得る、または書き込みが並べ替えられ得るという 2 つの理由で "0" を返すことが許可されるようになります。
この点をさらに強調するため、次のクラスを考えてみます。
class BoxedInt2
{
public readonly int _value = 42;
void PrintValue()
{
Console.WriteLine(_value);
}
}
ここで、少なくとも理論上は、メモリ モデルの問題により PrintValue は "0" を出力する可能性があります。次に、この可能性を現実にする BoxedInt の使用例を示します。
class Tester
{
BoxedInt2 _box = null;
public void Set() {
_box = new BoxedInt2();
}
public void Print() {
var b = _box;
if (b != null) b.PrintValue();
}
}
BoxedInt のインスタンスが (volatile ではないフィールドの _box を通じて) 誤って公開されているため、Print を呼び出すスレッドは部分的に構築されたオブジェクトを読み取る可能性があります。繰り返しになりますが、_box フィールドを volatile にすればこの問題が解決します。
インタロックがかけられた命令とメモリの防御壁: インタロックがかけられた命令は、マルチスレッドのプログラムでロックを減らすためにときどき使用されるアトミック命令です。次の、シンプルでスレッドセーフなカウンター クラスを考えます。
class Counter
{
private int _value = 0;
private object _lock = new object();
public int Increment()
{
lock (_lock)
{
_value++;
return _value;
}
}
}
Interlocked.Increment を使用して、プログラムを次のように書き直すことができます。
class Counter
{
private int _value = 0;
public int Increment()
{
return Interlocked.Increment(ref _value);
}
}
Interlocked.Increment を使って書き直すと、少なくとも一部のアーキテクチャではこのメソッドの実行が高速になります。Interlocked クラス (bit.ly/RksCMF、英語) では、Increment 操作以外にさまざまなアトミック操作のメソッドを公開します。これには、値の追加、値の条件付き置き換え、値を置き換えて元の値を返すなどの操作があります。
インタロックがかけられたメソッドはすべて、他のメモリ命令と並べ替えられないという非常に興味深い性質があります。そのため、インタロックがかけられた命令の前後を問わず、メモリ命令とインタロックがかけられた命令の順序を入れ替えることはできません。
インタロックがかけられたメソッドと密接に関係する命令は Thread.MemoryBarrier で、これはダミーのインタロックがかけられる命令と考えることができます。インタロックがかけられたメソッドと同様、Thread.MemoryBarrier は前後のどのメモリ命令とも並べ替えられません。ただし、インタロックがかけられたメソッドとは違い、Thread.MemoryBarrier には副作用なく、メモリの並べ替えを制限するだけです。
ポーリング ループ: ポーリング ループは、一般に推奨されませんが、実際には (いくぶん残念なことに) 頻繁に使用されるパターンです。図 9 に機能しないポーリング ループを示します。
図 9 機能しないポーリング ループ
class PollingLoopExample
{
private bool _loop = true;
public static void Main()
{
PollingLoopExample test1 = new PollingLoopExample();
// Set _loop to false on another thread
new Thread(() => { test1._loop = false;}).Start();
// Poll the _loop field until it is set to false
while (test1._loop) ;
// The previous loop may never terminate
}
}
この例では、メイン スレッドがループし、特定の volatile ではないフィールドをポーリングします。ヘルパー スレッドがその間フィールドを設定しますが、メイン スレッドは更新された値を読み取ることはありません。
では、_loop フィールドに volatile とマークされていたらどうでしょう。問題は解決するでしょうか。コンパイラはループ外の volatile フィールドの読み取りをホイストすることが許可されていないという点では専門家間で意見が一致しているようですが、ECMA C# 仕様がこれを保証しているかどうかについては議論の余地があります。
仕様では取得/解除セマンティクスに従うのは volatile フィールドのみであると示されているだけで、必ずしも volatile フィールドのホイストを認めていないとは言えません。一方、仕様で例に挙げられているコードは、実際に volatile フィールドをポーリングしており、volatile フィールドの読み取りはループ外でホイストできないことを暗に示しています。
x86 と x64 のアーキテクチャでは、PollingLoopExample.Main は多くの場合ハングします。JIT コンパイラは test1._loop フィールドを 1 回だけ読み取り、レジスタに値を格納し、レジスタの値が変わるまでループしますが、これが起こることがないのは明白です。
ただし、ループ本体にいくつかのステートメントが含まれている場合は、JIT コンパイラは他の目的でレジスタが必要になる場合があり、毎回の反復時に test1._loop を再度読み取ることになるでしょう。その結果、volatile ではないフィールドをポーリングする既存のプログラムでループを読み取るようになり、うまく行くでしょう。
同時実行プリミティブ: ほとんどの同時実行コードは、.NET Framework 4 で利用可能になった高度な同時実行プリミティブを活用できます。図 10 の表に、.NET 同時実行プリミティブの例をいくつか示します。
図 10 .NET Framework 4 の同時実行プリミティブ
型 | 説明 |
Lazy<> | 値の初期化を遅延 |
LazyInitializer | |
BlockingCollection<> | スレッド セーフなコレクション |
ConcurrentBag<> | |
ConcurrentDictionary<,> | |
ConcurrentQueue<> | |
ConcurrentStack<> | |
AutoResetEvent | 異なるスレッドの実行を調整するプリミティブ |
Barrier | |
CountdownEvent | |
ManualResetEventSlim | |
Monitor | |
SemaphoreSlim | |
ThreadLocal<> | 各スレッド用に別の値を保持するコンテナー |
多くの場合、これらのプリミティブを使用することで、(volatile の使用など) 複雑な方法でメモリ モデルに依存する低レベルのコードを避けられます。
次回予告
ここまでは、ECMA C# 仕様で定義された C# メモリ モデルについて説明し、メモリ モデルを定義するスレッド コミュニケーションの最も重要なパターンについて示してきました。
次回は、さまざまなアーキテクチャでメモリ モデルが実際にどのように実装されるかを説明します。これは、実際のプログラムの動作を理解するのに役立つでしょう。
ベスト プラクティス
- 作成するコードはすべて、今回説明したいずれかの実装の詳細ではなく、ECMA C# 仕様に規定される保証に従います。
- volatile フィールドの不必要な使用は避けます。ほとんどの場合、ロックや同時実行のコレクション (System.Collections.Concurrent.*) の方が、スレッド間でデータを交換するのに適しています。いくつかの場合には、volatile フィールドを使用して同時実行コードを最適化できますが、パフォーマンスを測定して、複雑さが増してもそれ以上のメリットが得られることを検証します。
- volatile フィールドを使用して初期化の遅延パターンを自身で実装する代わりに、System.Lazy<T> 型と System.Threading.LazyInitializer 型を使用します。
- ポーリング ループを避けます。多くの場合、ポーリング ループの代わりに、BlockingCollection<T>、Monitor.Wait/Pulse、イベントまたは非同期プログラミングを使用できます。
- 可能な限り、同等の機能を独自に実装するのではなく、標準の .NET の同時実行プリミティブを使用します。
Igor Ostrovsky は、マイクロソフトのシニア ソフトウェア開発エンジニアです。彼は、Parallel LINQ、タスク並列ライブラリなどの並列ライブラリと Microsoft .NET Framework のプリミティブに取り組んできました。Ostrovsky は、プログラミングのトピックに関するブログ (igoro.com、英語) を書いています。
この記事のレビューに協力してくれた技術スタッフの Joe Duffy、Eric Eilebrecht、Joe Hoag、Emad Omara、Grant Richins、Jaroslav Sevcik、および Stephen Toub に心より感謝いたします。