15

C# では、これはスレッド セーフな方法でイベントを呼び出すための標準コードです。

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);

場合によっては別のスレッドで、コンパイラによって生成された add メソッドが使用Delegate.Combineして新しいマルチキャスト デリゲート インスタンスを作成し、それをコンパイラによって生成されたフィールドに設定します (インターロックされた比較交換を使用)。

(注: この質問の目的のために、イベント サブスクライバーで実行されるコードは気にしません。削除に直面してもスレッドセーフで堅牢であると仮定します。)


私自身のコードでは、次の行に沿って同様のことをしたいと考えています。

var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);

this.memberFoo別のスレッドで設定できる場所。(これは 1 つのスレッドなので、インターロックする必要はないと思いますが、ここに副作用があるのではないでしょうか?)

Foo(そして、明らかに、それがこのスレッドで使用されている間は積極的に変更しないように「十分に不変」であると仮定します。)


これで、これがスレッドセーフであるという明白な理由がわかりました。参照フィールドからの読み取りはアトミックです。ローカルにコピーすることで、2 つの異なる値を取得しないことが保証されます。(どうやら.NET 2.0 からのみ保証されているようですが、正常な .NET 実装では安全だと思いますか?)


しかし、私が理解していないのは、参照されているオブジェクトインスタンスによって占有されているメモリはどうですか? 特にキャッシュの一貫性に関しては? 「ライター」スレッドが 1 つの CPU でこれを行う場合:

thing.memberFoo = new Foo(1234);

new が割り当てられるメモリが、初期化されていない値で、「リーダー」が実行されている CPU のキャッシュにないことを保証するものは何Fooですか? localFoo.baz(上記)がガベージを読み取らないことを保証するものは何ですか? (そして、これはプラットフォーム間でどの程度保証されていますか?Monoで?ARMで?)

新しく作成された foo がたまたまプールから取得された場合はどうなるでしょうか。

thing.memberFoo = FooPool.Get().Reset(1234);

これは、メモリの観点から見ると、新しい割り当てと同じように見えますが、.NET アロケータが最初のケースを機能させる魔法を使っているのではないでしょうか?


これを尋ねる際の私の考えは、メモリバリアが必要になるということです-読み取りが依存していることを考えると、メモリアクセスを移動できないほどではありませんが、キャッシュの無効化をフラッシュするためのCPUへの信号として.

これの私の情報源はウィキペディアです。

(おそらく、ライタースレッドのインターロック比較交換がリーダーのキャッシュを無効にするのではないかと推測するかもしれません。それとも、すべての読み取りが無効化の原因になるのでしょうか?それとも、ポインターの逆参照が無効化の原因になるのでしょうか?私は特に、プラットフォーム固有のこれらのことがどのように聞こえるかを懸念しています。)


更新:質問がCPUキャッシュの無効化と.NETが提供する保証(およびそれらの保証がCPUアーキテクチャにどのように依存するか)に関するものであることをより明確にするために:

  • フィールドQ(メモリの場所) に格納された参照があるとします。
  • CPU A (ライター) では、メモリ位置 でオブジェクトを初期化し、へRの参照を書き込みますRQ
  • CPU B (リーダー) では、フィールドを逆参照Qし、メモリの場所を取得します。R
  • 次に、CPU Bで、値を読み取ります。R

GC がどの時点でも実行されていないと仮定します。他に興味深いことは何も起こりません。

質問: Aが初期化中にそれを変更する前から、 Bのキャッシュに存在することを妨げているものは何ですか? RBが最初にどこにあるかを知るために新しいバージョンを取得しているにもかかわらず、 Bがそれから読み取ったときに古い値を取得します? RQR

(別の言い方: への変更がRCPU Bに見える時点またはその前に、変更をCPU BQに見えるようにするもの。)

(そして、これは で割り当てられたメモリ、または任意のメモリにのみ適用されますnewか?)+


注:ここに自己回答を投稿しました。

4

5 に答える 5

2

これは本当に良い質問です。最初の例を考えてみましょう。

var handler = SomethingHappened;
if(handler != null)
    handler(this, e);

なぜこれが安全なのですか?その質問に答えるには、まず「安全」とは何かを定義する必要があります。NullReferenceException から安全ですか? はい、デリゲート参照をローカルにキャッシュすることで、null チェックと呼び出しの間の厄介な競合が解消されることは簡単にわかります。複数のスレッドがデリゲートに触れても安全ですか? はい、デリゲートは不変であるため、1 つのスレッドによってデリゲートが中途半端な状態になることはありません。最初の 2 つは明らかです。しかし、スレッド A がループ内でこの呼び出しを実行し、後でスレッド B が最初のイベント ハンドラーを割り当てるシナリオはどうでしょうか。スレッド A が最終的にデリゲートの null 以外の値を参照するという意味で、それは安全ですか? これに対するやや驚くべき答えは、おそらく. その理由は、イベントのaddおよびremoveアクセサーの既定の実装がメモリ バリアを作成するためです。私は、CLR の初期のバージョンが明示的なものを取り、lockそれ以降のバージョンが使用されたと信じていInterlocked.CompareExchangeます。独自のアクセサーを実装し、メモリ バリアを省略した場合、答えはノーになる可能性があります。実際には、Microsoft がマルチキャスト デリゲート自体の構築にメモリ バリアを追加したかどうかに大きく依存すると思います。

2 番目のより興味深い例に進みます。

var localFoo = this.memberFoo;
if(localFoo != null)
    localFoo.Bar(localFoo.baz);

いいえ。申し訳ありませんが、これは実際には安全ではありません。次のように定義されたmemberFoo型であるとします。Foo

public class Foo
{
  public int baz = 0;
  public int daz = 0;

  public Foo()
  {
    baz = 5;
    daz = 10;
  }

  public void Bar(int x)
  {
    x / daz;
  }
}

次に、別のスレッドが次のことを行うとします。

this.memberFoo = new Foo();

プログラマーの意図が論理的に保持されている限り、コードで定義された順序で命令を実行しなければならないことを強制するものは何もないと考える人もいるかもしれません。C# または JIT コンパイラは、実際には次の一連の命令を定式化できます。

/* 1 */ set register = alloc-memory-and-return-reference(typeof(Foo));
/* 2 */ set register.baz = 0;
/* 3 */ set register.daz = 0;
/* 4 */ set this.memberFoo = register;
/* 5 */ set register.baz = 5;  // Foo.ctor
/* 6 */ set register.daz = 10; // Foo.ctor

memberFooコンストラクターが実行される前に、への代入がどのように発生するかに注意してください。それを実行するスレッドの観点からは意図しない副作用がないため、これは有効です。ただし、他のスレッドに大きな影響を与える可能性があります。memberFoo書き込みスレッドが命令 #4 を終了したときに、読み取りスレッドで null チェックが発生した場合はどうなりますか? リーダーは null 以外の値を確認し、変数が 10 に設定されるBar前に呼び出しを試みます。それでもデフォルト値の 0 を保持するため、ゼロ除算エラーが発生します。もちろん、これはほとんど理論上のものです。Microsoft の CLR の実装では、これを防ぐ書き込みにリリース フェンスが作成されるためです。しかし、仕様は技術的にそれを可能にします。この質問を参照してくださいdazdaz関連コンテンツ用。

于 2015-08-16T03:17:21.070 に答える
0

この場合、この記事を 参照してください。これにより、コンパイラは単一スレッドによるアクセスを想定した最適化を実行しなくなります。

イベントは以前はロックを使用していましたが、C# 4 以降ではロックのない同期を使用しています。正確にはわかりません (この記事を参照)。

編集:インターロックされたメソッドは、すべてのスレッドが更新された値を読み取るようにするメモリバリアを使用します(正常なシステム上)。すべての更新を Interlocked で実行する限り、メモリ バリアなしで任意のスレッドから安全に値を読み取ることができます。これは、System.Collections.Concurrent クラスで使用されるパターンです。

于 2015-07-20T23:13:26.007 に答える