次の方法で共有


C#

C# メモリ モデルの理論と実践 (第 2 部)

Igor Ostrovsky

 

今回は、C# メモリ モデルについての 2 回連載の 2 回目です。MSDN Magazine の 12 月号の第 1 回 (msdn.microsoft.com/magazine/jj863136) で説明したように、コンパイラとハードウェアはプログラムのメモリ命令をわずかに変化させることがあり、その方法によってはシングルスレッドの動作に影響しなくても、マルチスレッドの動作に影響することがあります。たとえば、次のメソッドを考えてみます。

void Init() {
  _data = 42;
  _initialized = true;
}

_data と _initialized が通常の (volatile ではない) フィールドであれば、コンパイラとプロセッサはこれらの命令の順序を入れ替えることが許され、Init は次のコードのように実行されます。

void Init() {
  _initialized = true;
  _data = 42;
}

前回は、抽象型の C# メモリ モデルについて説明しました。今回は、Microsoft .NET Framework 4.5 でサポートされるさまざまなアーキテクチャに、C# メモリ モデルが実際にどのように実装されるかを説明します。

コンパイラの最適化

前回述べたように、コンパイラがコードを最適化することによってメモリ命令の順序が入れ替えられる場合があります。.NET Framework 4.5 では、C# を IL にコンパイルする csc.exe コンパイラはそれほど多くの最適化を行わないため、メモリ命令は並べ替えられません。ただし、以下で説明するように、IL を マシン コードに変換する Just-in-Time (JIT) コンパイラは、実際にメモリ命令の順序を入れ替えるいくつかの最適化を行います。

ループ読み取りのホイスト: 次のポーリング ループ パターンを考えてみましょう。

class Test
{
  private bool _flag = true;
  public void Run()
  {
    // Set _flag to false on another thread
    new Thread(() => { _flag = false; }).Start();
    // Poll the _flag field until it is set to false
    while (_flag) ;
    // The loop might never terminate!
  }
}

この場合、.NET 4.5 JIT コンパイラはこのループを次のように書き直すことがあります。

if (_flag) { while (true); }

シングルスレッドの場合、この変換はまったく問題なく、一般に、ループからの読み取りのホイストは優れた最適化です。ただし、別のスレッドで _flag が false に設定されると、この最適化によってハングが引き起こされることがあります。

_flag フィールドが volatile の場合、JIT コンパイラはループからの読み取りのホイストは行いません (このパターンの詳細については、12 月号の「ポーリング ループ」を参照してください)。

読み取りの省略: マルチスレッド コードではエラーとなる可能性があるもう 1 つのコンパイラの最適化の例を次に示します。

class Test
{
  private int _A, _B;
  public void Foo()
  {
    int a = _A;
    int b = _B;
    ...
  }
}

このクラスには、_A と _B という volatile ではない 2 つのフィールドがあります。メソッド Foo は、まずフィールド _A を読み取り、次に _B を読み取ります。ただし、このフィールドは volatile ではないため、コンパイラは 2 つの読み取りを自由に入れ替えることができます。そのため、アルゴリズムの正確さが読み取りの順序に依存している場合、プログラムにはバグが含まれます。

読み取りの順序を変えることでコンパイラにどのようなメリットがあるかを想像するのは困難です。上記の Foo が記述された方法では、コンパイラは読み取りの順序を入れ替えないでしょう。

ただし、次のように Foo メソッドの先頭に簡単なステートメントを追加すると並べ替えが行われます。

public bool Foo()
{
  if (_B == -1) throw new Exception(); // Extra read
  int a = _A;
  int b = _B;
  return a > b;
}

Foo メソッドの最初の行で、コンパイラは _B の値をレジスタに読み込みます。その後の _B の 2 回目の読み込みでは、実際の読み込み命令を実行するのではなく、既にレジスタにある値を単純に使用します。

実際にコンパイラは、Foo メソッドを次のように書き直します。

public bool Foo()
{
  int b = _B;
  if (b == -1) throw new Exception(); // Extra read
  int a = _A;
  return a > b;
}

このコード サンプルは、コンパイラがコードを最適化する方法をおおまかに示したものにすぎませんが、コードを逆アセンブルした結果を見てみるのも役に立ちます。

if (_B == -1) throw new Exception();
  push        eax
  mov         edx,dword ptr [ecx+8]
  // Load field _B into EDX register
  cmp         edx,0FFFFFFFFh
  je          00000016
int a = _A;
  mov         eax,dword ptr [ecx+4]
  // Load field _A into EAX register
return a > b;
  cmp         eax,edx
  // Compare registers EAX and EDX
...

アセンブリについて知らなくても、ここで行われていることはごく簡単に理解できます。条件 _B == -1 を評価するときに、コンパイラは _B フィールドを EDX レジスタに読み込みます。後で、フィールド _B が再度読み取られるときに、コンパイラは実際のメモリからの読み取りを実行するのではなく、EDX に既にある値を単純に再利用します。結果的に、_A と _B の読み取り順序が変わったことになります。

この場合、フィールド _A を volatile としてマークするのが適切な解決策です。これを行うと、_A の読み込みには読み込みと取得のセマンティクスが含まれるため、コンパイラは _A と _B の読み取りを入れ替えません。ただし、バージョン 4 までの .NET Framework ではこのケースが適切に処理されないことを指摘しておかなければなりません。実際、_A フィールドを volatile とマークしても読み取り順序の入れ替えは避けられません。この問題は、.NET Framework バージョン 4.5 で解決されました。

読み取りの導入: 先ほど説明したように、コンパイラは複数の読み取りを 1 つにまとめることがありますが、1 つの読み取りを複数の読み取りに分割することもあります。.NET Framework 4.5 では、読み取りの省略と比べて読み取りの導入はあまり一般的ではなく、行われるのはごくまれな特定の状況においてのみです。それでも、行われることがあります。

読み取りの導入を理解するため、次の例を見てみましょう。

public class ReadIntro {
  private Object _obj = new Object();
  void PrintObj() {
    Object obj = _obj;
    if (obj != null) {
      Console.WriteLine(obj.ToString());
    // May throw a NullReferenceException
    }
  }
  void Uninitialize() {
    _obj = null;
  }
}

PrintObj メソッドを見ると、obj.ToString 式で obj の値が null になることはないように思えます。ただし、このコード行は、実際に NullReferenceException をスローすることがあります。CLR JIT は、PrintObj メソッドを、次のように記述されているかのようにコンパイルする場合があります。

void PrintObj() {
  if (_obj != null) {
    Console.WriteLine(_obj.ToString());
  }
}

_obj フィールドの読み取りが、このフィールドの 2 回の読み取りに分割されているため、ToString メソッドが null ターゲットに対して呼び出されるようになることがあります。

x86-x64 の .NET Framework 4.5 では、このコード サンプルを使用して NullReferenceException を再現できないことに注意してください。読み取りの導入を .NET Framework 4.5 で再現するのは非常に困難ですが、それでも特別な状況では行われます。

x86-x64 での C# メモリ モデルの実装

メモリ モデルに関しては、x86 と x64 は同じ動作をするため、この 2 つのプロセッサ バリエーションをまとめて考えます。

一部のアーキテクチャとは異なり、x86-x64 プロセッサは、メモリ命令の順序の保証を非常に厳密に行います。実際、JIT コンパイラは volatile セマンティクスを実行するために x86-x64 で特別な命令を一切使用する必要がなく、それらのセマンティクスは通常のメモリ命令によって既に提供されています。それでも、x86-x64 プロセッサがメモリ命令を入れ替える特殊な場合があります。

x86-x64 のメモリの並べ替え: x86-x64 プロセッサは順序保証を非常に厳密に行うものの、特定の種類のハードウェアでは順序の入れ替えが起こる場合があります。

x86-x64 プロセッサは、2 つの書き込み順序を入れ替えることも、2 つの読み取り順序を入れ替えることもありません。ただし、1 つ (唯一) 起こり得る並べ替えの結果として、プロセッサが値を書き込んだときに、その値は即座に他のプロセッサで利用可能にならない場合があります。図 1 の例はこの動作を示しています。

図 1 StoreBufferExample

class StoreBufferExample
{
  // On x86 .NET Framework 4.5, it makes no difference
  // whether these fields are volatile or not
  volatile int A = 0;
  volatile int B = 0;
  volatile bool A_Won = false;
  volatile bool B_Won = false;
  public void ThreadA()
  {
    A = true;
    if (!B) A_Won = true;
  }
  public void ThreadB()
  {
    B = true;
    if (!A) B_Won = true;
  }
}

StoreBufferExample の新しいインスタンスで、異なるスレッドから ThreadA と ThreadB が呼び出される場合を考えてみます (図 2 参照)。図 2 のプログラムで起こり得る結果を考えると、次の 3 つの場合が考えられます。

  1. スレッド 2 が開始される前にスレッド 1 が完了する。結果は、A_Won=true、B_Won=false となります。
  2. スレッド 1 が開始される前にスレッド 2 が完了する。結果は A_Won=false、B_Won=true となります。
  3. スレッドがインタリーブされる。結果は、A_Won=false、B_Won=false となります。

Calling ThreadA and ThreadB Methods from Different Threads
図 2 異なるスレッドから ThreadA メソッドと ThreadB メソッドを呼び出す

しかし、驚くべきことに、第 4 のケースとして、このコードが完了した後、A_Won フィールドと B_Won フィールドがどちらも true になる場合があります。Store Buffer が原因で格納が "遅延" され、その結果として、その後の読み込みと順序を入れ替えられることがあります。この結果は、Thread 1 と Thread 2 の実行のどのインタリーブとも一貫性がなくても、発生する場合があります。

この例が興味深いのは、比較的厳密な順序を持つプロセッサ (x86-x64) を使用し、すべてのフィールドが volatile なのにもかかわらず、メモリ命令の並べ替えが起こるためです。A への書き込みが volatile で、A_Won からの読み取りも volatile でも、この防御はどちらも片方向のため、事実上この入れ替えが可能になります。そのため、ThreadA メソッドは次のように記述されているかのように実行されることがあります。

public void ThreadA()
{
  bool tmp = B;
  A = true;
  if (!tmp) A_Won = 1;
}

1 つの可能な解決策は、ThreadA と ThreadB の両方にメモリ バリアを挿入することです。更新した ThreadA メソッドは、次のようになります。

public void ThreadA()
{
  A = true;
  Thread.MemoryBarrier();
  if (!B) aWon = 1;
}

CLR JIT は、メモリ バリアの場所に "lock or" 命令を挿入します。x86 命令をロックすると、Store Buffer をフラッシュするという二次効果が得られます。

mov         byte ptr [ecx+4],1
lock or     dword ptr [esp],0
cmp         byte ptr [ecx+5],0
jne         00000013
mov         byte ptr [ecx+6],1
ret

余談ですが、興味深いことに Java プログラミング言語は別の方法を採用しています。Java メモリ モデルには、格納と読み込みの入れ替えを許可しない、やや強力な "volatile" 定義があり、通常 x86 の Java コンパイラは volatile 書き込み後に命令をロックします。

x86-x64 の備考: x86 プロセッサは、非常に厳密なメモリ モデルで、ハードウェア レベルで順序の入れ替えが起こり得るのは Store Buffer のみです。Store Buffer は、書き込みをその後続の読み取りと順序を入れ替えることがあります (格納と読み込みの並べ替え)。

また、いくつかのコンパイラの最適化の結果、メモリ命令の順序が入れ替えられる場合があります。注目すべきことに、複数の読み取りがメモリの同じ場所にアクセスする場合、コンパイラは読み取りを 1 回だけ実行し、次の読み取りのために値をレジスタに保存することがあります。

ちょっとした興味深い話ですが、C# volatile のセマンティクスは、x86-x64 ハードウェアで行われるハードウェアの並べ替えの保証と密接に対応します。結果的に、volatile フィールドの読み取りと書き込みには、x86 で特別な命令が必要なく、通常の読み取りと書き込み (たとえば、MOV 命令を使用) で十分です。もちろん、これらの実装の詳細は、ハードウェア アーキテクチャとおそらくは .NET のバージョンによって異なるため、コードを実装に依存させてはいけません。

Itanium アーキテクチャでの C# メモリ モデルの実装

Itanium ハードウェア アーキテクチャのメモリ モデルは、x86-x64 より脆弱です。Itanium は、バージョン 4 までの .NET Framework でサポートされていました。

Itanium は .NET Framework 4.5 ではサポートされなくなりましたが、Itanium メモリ モデルについて理解しておくと、.NET メモリ モデルに関する過去の記事を読むときや、そうした記事で推奨されているコードをメンテナンスする必要がある場合に役立つでしょう。

Itanium の並べ替え: Itanium の命令セットは x86-x64 とは異なり、その命令セットにメモリ モデルの考え方が見られます。Itanium は、通常の読み込み (LD) と読み込みと取得 (LD.ACQ)、通常の格納 (ST) と格納と解放 (ST.REL) をそれぞれ区別します。

通常の読み込みと保存は、シングルスレッドの動作が変わらない限り、ハードウェアで自由に入れ替えが行われます。たとえば、次のコードを見てください。

class ReorderingExample
{
  int _a = 0, _b = 0;
  void PrintAB()
  {
    int a = _a;
    int b = _b;
    Console.WriteLine("A:{0} B:{1}", a, b);
  }
  ...
}

PrintAB メソッドの _a と _b の 2 つの読み取りを考えてみます。読み取りは、volatile ではない通常のフィールドにアクセスするため、コンパイラは通常の LD (LD.ACQ ではない) を使用して読み取りを実装します。その結果、2 つの読み取りは事実上ハードウェアで入れ替えられ、PrintAB は次のように記述されているかのように動作します。

void PrintAB()
{
  int b = _b;
  int a = _a;
  Console.WriteLine("A:{0} B:{1}", a, b);
}

実際に、入れ替えが行われるかどうかは、予測できないさまざまな要素、つまりプロセッサ キャッシュの内容、プロセッサ パイプラインの負荷状況などによります。ただし、プロセッサはデータに依存関係がある場合は、2 つの読み取りを入れ替えません。メモリの読み取りで返される値によってその後続の読み取りの場所が決まるときに、2 つの読み取りにデータの依存関係が生じます。

次の例は、データの依存関係がある場合を示しています。

class Counter { public int _value; }
class Test
{
  private Counter _counter = new Counter();
  void Do()
  {
    Counter c = _counter; // Read 1
    int value = c._value; // Read 2
  }
}

Do メソッドで、Read 1 は通常の読み込みで、読み込みと取得ではないものの、Itanium は Read 1 と Read 2 を入れ替えることはありません。2 つの読み取りを入れ替えてはいけないことは明らかなように見えるかもしれません。最初の読み取りによって、2 つ目の読み取りがどのメモリの場所にアクセスするかが決まります。ただし、Itanium 以外のいくつかのプロセッサは、実際に読み取り順序を入れ替える場合があります。プロセッサは、Read 1 が返す値を予測し、Read 1 がまだ完了していなくても、推測に基づき Read 2 を実行する場合があります。ただここでも、Itanium はこれを行いません。

データの依存関係については、後で Itanium について説明する際に少し触れます。そこで、C# メモリ モデルとの関連性がよりわかりやすくなるでしょう。

また、制御に依存関係がある場合、Itanium は 2 つの通常の読み取りを入れ替えません。読み取りで返される値によって次の命令を実行するかどうかが決まるときに、制御の依存関係が生じます。

次の例では、_initialized と _data の読み取りは制御の依存関係が生じています。

void Print() {
  if (_initialized)            // Read 1
    Console.WriteLine(_data);  // Read 2
  else
    Console.WriteLine("Not initialized");
}

_initialized と _data が通常の (volatile でない) 読み取りの場合でも、Itanium プロセッサがこの順序を入れ替えることはありません。それでも JIT コンパイラは 2 つの読み取りを自由に入れ替えることができ、実際にいくつかの場合では入れ替えが行われることに注意してください。

また、x86-x64 プロセッサと同様に、Itanium も Store Buffer を使用するため、図 1 の StoreBufferExample は Itanium でも x86-x64 の場合と同じ順序の入れ替えが行われることを指摘しておく必要があるでしょう。興味深い豆知識ですが、Itanium ですべての読み取りに LD.ACQ を使用し、すべての書き込みに ST.REL を使用する場合、基本的に x86-x64 メモリ モデルが採用され、Store Buffer が並べ替えの唯一の原因になります。

Itanium におけるコンパイラの動作: CLR JIT コンパイラには、Itanium において驚くべき動作が 1 つあります。それは、すべての書き込みが ST としてではなく ST.REL として生成されることです。その結果、volatile の書き込みと volatile ではない書き込みは、多くの場合 Itanium の同じ命令を生成します。ただし、通常の読み取りは LD として生成され、volatile フィールドの読み取りのみ LD.ACQ として生成されます。

コンパイラは volatile ではない書き込みに ST.REL を生成する必要がないため、この動作には驚かれるでしょう。欧州電子計算機工業会 (ECMA) C# 仕様に関する限り、コンパイラは通常の ST 命令を生成することになります。ST.REL の生成は、コンパイラが任意で実行できる追加事項で、特定の共通の (ただし、理論的には正しくない) パターンが想定通りに実行されるようにします。

書き込みには ST.REL を使用する必要があり、読み取りには LD で十分な場合に、この重要なパターンがどうなるかを想像するのは困難な場合があります。ここで紹介した PrintAB の例では、読み取り順序が入れ替えられる可能性があるため、書き込みを制限するだけでは不十分です。

通常の LD で ST.REL を使用するだけで十分な、1 つの非常に重要なシナリオがあります。それは、読み込み自体がデータの依存関係を使用して入れ替えられる場合です。このパターンは遅延初期化で生じ、非常に重要なパターンです。図 3 は、遅延初期化の例を示しています。

図 3 遅延初期化

// Warning: Might not work on future architectures and .NET versions;
// do not use
class LazyExample
{
  private BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      lock(this)
      {
        if (_boxedInt == null)
        {
          b = new BoxedInt();
          b._value = 42;  // Write 1
          _boxedInt = b; // Write 2
        }
      }
    }
    int value = b._value; // Read 2
    return value;
  }
}

複数のスレッドで同時に GetInt が読み出された場合でも、このコード部分が常に 42 を返すようにするために、Read 1 と Read 2、および Write 1 と Write 2 は入れ替えが行われてはいけません。データには依存関係があるため、Itanium プロセッサでは読み取りは入れ替えられません。また、CLR JIT は ST.REL として生成するため、書き込みも入れ替えられません。

_boxedInt フィールドが volatile の場合、ECMA C# 仕様に従ってコードは正しくなります。これは最も適切で、唯一の真に正しいコードだと言えます。ただし、_boxed が volatile の場合でも、現在のバージョンのコンパイラはコードが実際には Itanium で機能することを保証します。

もちろん、ループ読み取りのホイスト、読み取りの省略、読み取りの導入は、x86-x64 の場合と同様に Itanium でも CLR JIT によって行われることがあります。

Itanium の備考: この説明で Itanium が興味深い部分となるのは、.NET Framework を実行する脆弱なメモリ モデルを持つ最初のアーキテクチャだったためです。

結果として、C# メモリ モデルと volatile のキーワードおよび C# に関する多くの記事を執筆する際、多くの場合 Itanium を念頭に置いてきました。とりわけ、.NET Framework 4.5 が登場するまでは、Itanium は .NET Framework を実行する唯一の x86-x64 以外のアーキテクチャでした。

その結果、「.NET 2.0 メモリ モデルでは、volatile ではないフィールドへの書き込みを含む、すべての書き込みが volatile です」といったことを言うかもしれません。このことは、Itanium では CLR はすべての書き込みを ST.REL として生成すること表しています。この動作は、ECMA C# 仕様では保証されておらず、そのため将来のバージョンの .NET Framework や将来のアーキテクチャには当てはまらないでしょう (そして実際、ARM の .NET Framework 4.5 には当てはまりません)。

同様に、保持するフィールドが volatile でなくても、遅延初期化は .NET Framework で適切だと主張する人もいるでしょうし、フィールドは volatile である必要があるという人もいるでしょう。

そして当然ですが、開発者はこうした想定に沿って (ときには反して) コードを作成しました。そのため、この説明の Itanium の部分を理解することは、他人が作成した同時実行コードを理解しようとするときや、過去の記事を読むとき、さらに他の開発者との会話でも役立つことになります。

ARM での C# メモリ モデルの実装

ARM アーキテクチャは、.NET Framework でサポートされるアーキテクチャのリストに最も新しく追加されました。Itanium と同様、ARM のメモリ モデルは x86-x64 よりも脆弱です。

ARM の並べ替え: Itanium と同様、ARM は通常の読み取りと書き込みを自由に入れ替えることができます。ただし、ARM が読み取りと書き込みの動作を管理するために提供している解決策は、Itanium とは異なります。ARM は、完全なメモリ バリアとして機能する DMB という 1 つの命令を公開しています。どのようなメモリ操作でも、いずれの方向にも DMB をまたがって入れ替えることはできません。

DMB 命令による制限に加えて、ARM はデータの依存関係も考慮しますが、制御の依存関係は考慮しません。データと制御の依存関係については、上記の「Itanium の並べ替え」を参照してください。

ARM におけるコンパイラの動作: C# で volatile のセマンティクスを実装するために DMB 命令を使用します。ARM では、CLR JIT は通常の読み取り (LDR など) に続けて DMB 命令を使用して volatile フィールドの読み取りを実装します。DMB 命令によって volatile の読み取りが後続する命令と入れ替えられなくなるため、この解決策は取得のセマンティクスを正しく実装します。

volatile フィールドへの書き込みは、通常の書き込み (STR など) の前に DMB 命令を置いて実装されます。DMB 命令によって volatile の書き込みが前の命令と入れ替えられなくなるため、この解決策は解放のセマンティクスを正しく実装します。

Itanium プロセッサと同様、ECMA C# 仕様を超え、遅延初期化パターンの機能を維持しているのがすばらしい点です。これには、多くの既存のコードが依存しています。ただし、DBM 命令は非常に負荷がかかるため、すべての書き込みを実際に volatile にするのは、ARM では優れた解決策ではありません。

.NET Framework 4.5 では、CLR JIT は遅延初期化を機能するようにするために少し異なる手法を使います。次のものが "解放" バリアとして処理されます。

  1. ガベージ コレクター (GC) ヒープにおける参照型フィールドへの書き込み
  2. 参照型の静的フィールドへの書き込み

結果的に、オブジェクトを発行することがあるすべての書き込みは、解放バリアとして扱われます。

これは、LazyExample の重要な部分です (いずれのフィールドも volatile ではないことに注意してください)。

b = new BoxedInt();
b._value = 42;  // Write 1
// DMB will be emitted here
_boxedInt = b; // Write 2

オブジェクトを _boxedInt フィールドに発行する前に CLR JIT が DMB 命令を生成するため、Write 1 と Write 2 の入れ替えは行われません。また、ARM はデータの依存関係を考慮するため、遅延初期化パターンの読み取り順序の入れ替えは行われず、コードは ARM で適切に機能します。

そのため、CLR JIT は追加の操作を行い (ECMA C# 仕様で必須の範囲を超え)、ARM で機能する不適切な遅延初期化という最もよくあるバリエーションが残っています。

ARM についての最後のコメントとして、x86-x64 や Itanium と同様に、CLR JIT に関する限り、ループ読み取りのホイスト、読み取りの省略、および読み取りの導入はすべて適切な最適化です。

例: 遅延初期化

遅延初期化パターンのいくつかの異なるバリエーションを確認し、さまざまなアーキテクチャでどのように動作するのかを考えておくことは有益でしょう。

正しい実装: 図 4 の遅延初期化の実装は、ECMA C# 仕様で定義された C# メモリ モデルに従っていて適切で、現在および将来のバージョンの .NET Framework でサポートされるあらゆるアーキテクチャで動作することが保証されます。

図 4 遅延初期化の正しい実装

class BoxedInt
{
  public int _value;
  public BoxedInt() { }
  public BoxedInt(int value) { _value = value; }
}
class LazyExample
{
  private volatile BoxedInt _boxedInt;
  int GetInt()
  {
    BoxedInt b = _boxedInt;
    if (b == null)
    {
      b = new BoxedInt(42);
      _boxedInt = b;
    }
    return b._value;
  }
}

コード サンプルは正しいですが、それでも実際には Lazy<T> か LazyInitializer 型を使用する方が適切なことに注意してください。

不適切な実装その 1: 図 5 は、C# メモリ モデルでは不適切な実装を示しています。にもかかわらず、この実装は x86-x64、Itanium、および ARM の .NET Framework で動作します。このバージョンのコードは正しくありません。_boxedInt は volatile でないため、C# コンパイラは Read 1 と Read 2、または Write 1 と Write 2 の順序を入れ替えることができます。どちらの入れ替えも、GetInt から 0 が返されることになる可能性があります。

図 5 遅延初期化の不適切な実装

// Warning: Bad code
class LazyExample
{
  private BoxedInt _boxedInt; // Note: This field is not volatile
  int GetInt()
  {
    BoxedInt b = _boxedInt; // Read 1
    if (b == null)
    {
      b = new BoxedInt(42); // Write 1 (inside constructor)
      _boxedInt = b;        // Write 2
    }
    return b._value;        // Read 2
  }
}

ただし、このコードはすべてのアーキテクチャの .NET Framework のバージョン 4 とバージョン 4.5 で適切に動作します (必ず 42 を返します)。

  • x86-x64:
    • 書き込みと読み取りの順序は入れ替えられません。このコードには、格納と読み込みのパターンはなく、コンパイラがレジスタに値をキャッシュする理由もありません。
  • Itanium:
    • ST.REL が生成されるため書き込みの順序は入れ替えられません。
    • データに依存関係があるため、読み取り順序は入れ替えられません。
  • ARM:
    • "_boxedInt = b" の前に DMB が生成されるため、書き込みの順序は入れ替えられません。
    • データに依存関係があるため、読み取り順序は入れ替えられません。

もちろん、この情報は、既存のコード動作を理解するためにのみ使用してください。新しいコードを作成するときは、このパターンを使用しないでください。

不適切な実装その 2: 図 6 の不適切な実装は、ARM と Itanium の両方で失敗します。

図 6 遅延初期化の不適切な実装その 2

// Warning: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (!_initialized) // Read 1
    {
      _value = 42;
      _initialized = true;
    }
    return _value;     // Read 2
  }
}

このバージョンの遅延初期化では、データ (_value) と、フィールドが初期化されるかどうか (_initialized) を追跡するのに 2 つの別のフィールドを使用します。その結果、2 つの読み取り (Read 1 と Read 2) にはデータの依存関係が生じません。さらに、ARM では、書き込み順序が入れ替えられる場合があります。これは、次に紹介する不適切な実装 (その 3) と同じ理由によります。

その結果、実際にはこのバージョンは ARM と Itanium で失敗して 0 を返す場合があります。当然ながら、x86-x64 では GetInt は 0 を返すことができますが (また、JIT 最適化の結果として)、この動作は .NET Framework 4.5 では生じないと考えられます。

不適切な実装その 3: 最後に、この例を x86-x64 でも失敗するようにすることができます。これに必要なのは、影響がないように見える読み取りを 1 つ追加するだけです (図 7 参照)。

図 7 遅延初期化の不適切な実装その 3

// WARNING: Bad code
class LazyExample
{
  private int _value;
  private bool _initialized;
  int GetInt()
  {
    if (_value < 0) throw new 
      Exception(); // Note: extra reads to get _value
                          // pre-loaded into a register
    if (!_initialized)      // Read 1
    {
      _value = 42;
      _initialized = true;
      return _value;
    }
    return _value;          // Read 2
  }
}

_value が 0 未満かをチェックする追加の読み取りにより、コンパイラはレジスタに値をキャッシュするようになります。その結果、Read 2 はレジスタから読み取りを行うようになり、事実上 Read 1 と入れ替えが可能になります。その結果、このバージョンの GetInt は x86-x64 でも 0 を返す場合があります。

まとめ

一般に、新しいマルチスレッド コードを作成するときには、ロック、同時実行コレクション、タスク、並列ループなどの高いレベルの同時実行プリミティブを使用して C# メモリ モデルの複雑さを完全に回避することをお勧めします。CPU を集中的に使用するコードを作成する際には、アーキテクチャ固有の実装の詳細ではなく ECMA C# 仕様に従う限りにおいて、volatile フィールドを使用するのが適している場合があります。

Igor Ostrovsky は、マイクロソフトのシニア ソフトウェア開発エンジニアです。彼は、Parallel LINQ、タスク並列ライブラリなどの .NET Framework の並列ライブラリとプリミティブに取り組んできました。Ostrovsky は、プログラミングのトピックに関するブログ (igoro.com、英語) を書いています。

この記事のレビューに協力してくれた技術スタッフの Joe Duffy、Eric Eilebrecht、Joe Hoag、Emad Omara、Grant Richins、Jaroslav Sevcik、および Stephen Toub に心より感謝いたします。