-
01-07-2019 - |
質問
まだ少しはっきりしていないのですが、いつラップするか ロック いくつかのコードの周り。私の一般的な経験則は、静的変数の読み取りまたは書き込み時に操作をロックでラップすることです。ただし、静的変数が読み取りのみの場合 (例:これは型の初期化中に設定される読み取り専用です)、アクセスするには lock ステートメントでラップする必要はありませんね。最近、次の例のようなコードを見たので、私のマルチスレッドに関する知識にギャップがあるのではないかと思いました。
class Foo
{
private static readonly string bar = "O_o";
private bool TrySomething()
{
string bar;
lock(Foo.objectToLockOn)
{
bar = Foo.bar;
}
// Do something with bar
}
}
それは私にはまったく意味がわかりません。なぜレジスタの読み取りで並行性の問題が発生するのでしょうか?
また、この例では別の疑問も生じます。これらのうちの 1 つは他のものよりも優れていますか?(例えば。例 2 では、ロックを保持する時間が短くなりますか?) MSIL を逆アセンブルできると思います...
class Foo
{
private static string joke = "yo momma";
private string GetJoke()
{
lock(Foo.objectToLockOn)
{
return Foo.joke;
}
}
}
対
class Foo
{
private static string joke = "yo momma";
private string GetJoke()
{
string joke;
lock(Foo.objectToLockOn)
{
joke = Foo.joke;
}
return joke;
}
}
解決
作成したコードは初期化後に静的フィールドを変更しないため、ロックの必要はありません。新しい値が古い値の読み取り結果に依存しない限り、文字列を新しい値に置き換えるだけでも同期は必要ありません。
同期が必要なのは静的フィールドだけではありません。変更される可能性のある共有参照は同期の問題に対して脆弱です。
class Foo
{
private int count = 0;
public void TrySomething()
{
count++;
}
}
TrySomething メソッドを実行する 2 つのスレッドは問題ないと思われるかもしれません。しかし、そうではありません。
- スレッド A はカウント (0) の値をレジスタに読み取り、カウントを増分できるようにします。
- コンテキストスイッチ!スレッド スケジューラは、スレッド A に十分な実行時間があったと判断します。次はスレッド B です。
- スレッド B はカウント (0) の値をレジスタに読み取ります。
- スレッド B はレジスタをインクリメントします。
- スレッド B は結果 (1) をカウントに保存します。
- コンテキストは A に戻ります。
- スレッド A は、スタックに保存されているカウント (0) の値をレジスタに再ロードします。
- スレッド A はレジスタをインクリメントします。
- スレッド A は結果 (1) をカウントに保存します。
したがって、count++ を 2 回呼び出したにもかかわらず、count の値は 0 から 1 になっただけです。コードをスレッドセーフにしてみましょう。
class Foo
{
private int count = 0;
private readonly object sync = new object();
public void TrySomething()
{
lock(sync)
count++;
}
}
ここで、スレッド A が中断された場合、スレッド B はロック ステートメントにヒットし、スレッド A が同期を解放するまでブロックされるため、カウントをいじることはできません。
ちなみに、Int32 と Int64 のインクリメントをスレッドセーフにする別の方法もあります。
class Foo
{
private int count = 0;
public void TrySomething()
{
System.Threading.Interlocked.Increment(ref count);
}
}
質問の 2 番目の部分に関しては、どちらか読みやすい方を選択すればよいと思います。パフォーマンスの違いは無視できるものです。早期最適化は諸悪の根源、などなど。
他のヒント
32 ビット以下のフィールドの読み取りまたは書き込みは、C# ではアトミックな操作です。私の知る限り、あなたが提示したコードにはロックの必要はありません。
最初のケースではロックは不要であるように思えます。静的初期化子を使用して bar を初期化すると、スレッド セーフであることが保証されます。値を読み取るだけなので、値をロックする必要はありません。値が決して変更されない場合、競合は発生しません。そもそも、なぜロックするのでしょうか?
汚い読み物ですか?
私の意見では、別のスレッドから読み書きする必要がある位置に静的変数を配置しないように最大限の努力をすべきです。この場合、これらは基本的に自由に使用できるグローバル変数であり、グローバル変数はほとんどの場合悪いものになります。
そうは言っても、そのような位置に静的変数を配置する場合は、念のため、読み取り中にロックすることをお勧めします。別のスレッドが急襲して値を変更した可能性があることに注意してください。 その間 読み取りが行われると、データが破損する可能性があります。読み取りは、ロックによるものであることを確認しない限り、必ずしもアトミック操作であるとは限りません。書き込みも同様で、常にアトミックな操作であるとは限りません。
編集:Mark が指摘したように、C# の特定のプリミティブでは、読み取りは常にアトミックです。ただし、他のデータ型には注意してください。
ポインターに値を書き込むだけの場合、そのアクションはアトミックであるため、ロックする必要はありません。一般に、最初と最後の間で状態が変化しないことに依存する少なくとも 2 つのアトミック アクション (読み取りまたは書き込み) を伴うトランザクションを実行する必要がある場合は、常にロックする必要があります。
とはいえ、私は Java の出身で、変数の読み取りと書き込みはすべてアトミックなアクションです。ここでの他の回答は、.NET が異なることを示唆しています。
「どちらが良いか」という質問に関しては、関数スコープが他のものに使用されていないため、どちらも同じです。