非同期コード、シェア変数、スレッドプールスレッド、スレッドセーフ


8

async / awaitを使用して非同期コードを作成するとき、通常ConfigureAwait(false)はコンテキストのキャプチャを回避するために、コードがスレッドプールスレッドから次のスレッドプールスレッドにジャンプしていawaitます。これにより、スレッドの安全性に関する懸念が生じます。このコードは安全ですか?

static async Task Main()
{
    int count = 0;
    for (int i = 0; i < 1_000_000; i++)
    {
        Interlocked.Increment(ref count);
        await Task.Yield();
    }
    Console.WriteLine(count == 1_000_000 ? "OK" : "Error");
}

変数iは保護されておらず、複数のスレッドプールスレッド*によってアクセスされます。アクセスのパターンは非並行ですが、理論的には各スレッドがローカルにキャッシュされた値をインクリメントiして、1,000,000回を超える反復が可能になるはずです。しかし、実際にはこのシナリオを作成することはできません。上記のコードは、私のマシンでは常にOKを印刷します。これは、コードがスレッドセーフであることを意味しますか?またはilock?を使用して変数へのアクセスを同期する必要があります。

(*私のテストによれば、スレッドの切り替えは平均して2回の反復ごとに発生します)


1
なぜi各スレッドにキャッシュされると思いますか?より深く掘り下げるには、このSharpLab ILを参照してください。
AndreasHassing

1
@AndreasHassing私の懸念は、次 ようなステートメントによって提起されます。コンパイラ、CLR、またはCPUは、変数への割り当てがすぐに他のスレッドに表示されないように、キャッシュの最適化を導入することがあります。パート4:高度なスレッディング
Theodor Zoulias

回答:


2

スレッドセーフティの問題は、メモリの読み取り/書き込みに関するものです。これが別のスレッドで続行できる場合でも、ここでは何も並行して実行されません。


理論的には、スレッドはメインRAMの代わりにローカルキャッシュの読み書きを行うことができ、この方法で他のスレッドによって行われた更新がありません。変数ivolatileロックによって宣言も保護もされていないため、コンパイラーの理解から、ジッターとハードウェア(CPU)はすべて、このような最適化を行うことが許可されています。
Theodor Zoulias

@TheodorZoulias継続を再開するためのスレッドの交換は、同時アクセスとは異なります。上にリンクされているシャープラボでは、ローカルをプライベートフィールドにカプセル化するステートマシン全体が、継続を実行するスレッドに渡されます。常に1つのスレッドのみがアクセスiしています。
JohanP

@JohanP private int <i>5__2状態マシンのフィールドは宣言されていませんvolatile。私の懸念は、更新中のスレッドが別のスレッドに割り込むことではありませんi。この場合、これは起こり得ません。メインRAMからi新しい値をフェッチする代わりに、CPUのコアのローカルキャッシュにキャッシュされた古い値を使用し、前のループからそこに残されたスレッドに関する懸念がありiます。ローカルキャッシュへのアクセスは、メインRAMへのアクセスよりも安価です。そのため、最適化をオンにすると、そのようなことが可能になります(私が読んだことによると)。
Theodor Zoulias

@TheodorZouliasこのループにasyncコードが含まれていない場合にも同じ懸念がありますか?
JohanP

2
@TheodorZouliasスレッドAが実行され、増分されますi。コードがヒットするとawait、スレッドAはすべての状態をスレッドBに渡し、プールに戻ります。スレッドBの増分i。ヒットawait。次に、スレッドBがすべての状態をスレッドCに渡し、プールなどに戻ります。どの時点でもへの同時アクセスiはなく、スレッドセーフは必要ありません。スレッドの切り替えが発生したことは問題ではありません。必要な状態は、継続を実行する新しいスレッドに渡されます。共有状態がないため、同期は必要ありません。
JohanP

0

スティーブントウブによるこの記事は、これについていくつかの光を当てることができると思います。特に、これはコンテキスト切り替え中に何が起こるかについての関連する文章です:

コードが、まだ完了していないと待機者が言う(つまり、待機者のIsCompletedがfalseを返す)awaitableを待機するときはいつでも、メソッドは一時停止する必要があり、待機者の継続を介して再開します。これは、前に参照した非同期ポイントの1つなので、ExecutionContextは、待機を発行するコードから継続デリゲートの実行まで流れる必要があります。これはフレームワークによって自動的に処理されます。asyncメソッドが中断しようとしているとき、インフラストラクチャはExecutionContextをキャプチャします。アウェイターに渡されるデリゲートは、このExecutionContextインスタンスへの参照を持ち、メソッドを再開するときにそれを使用します。これにより、ExecutionContextによって表される重要な「環境」情報が、待機中にフローできるようになります。

YieldAwaitableによって返されるはTask.Yield()常にを返すことに注意してくださいfalse


ダニエルに答えてくれてありがとう。正直なところExecutionContext、スレッドからスレッドへのフローが、スレッドのローカルキャッシュを無効化するメカニズムとしても機能した場合は、驚きます。しかし、それも不可能ではありません。
Theodor Zoulias

@RaymondChenのような専門家があなたの答えが正しいか間違っているかを主張するかもしれません。この問題に関する信頼できる情報源として役立つことができるのは、世界中のごく少数の人だと思います。
Theodor Zoulias

「スレッドのローカルキャッシュを無効にする」とは、スレッドがコンテキストスイッチを実行するときに、この1つのコンテキストに固有のキャッシュも維持することを意味します。つまり、このキャッシュされたデータは、コンテキストに似たものに保存する必要があります...しかし、実際のコンテキストを実行する必要があるスレッドが実際のコンテキストを利用できるのはなぜですか?また、どの2つのコンテキストが「同じ」であるかを決定するという問題が発生しますが、実行の後半のポイントを表すだけです。もちろん、私は専門家であると主張するのではなく、問題をメンタルエクササイズとして推論しようとするだけです。
Daniel Crha

また、私が間違っている場合は、「インターネットで正しい答えを得る最善の方法は質問をすることではなく、間違った答えを投稿することです」というカニンガムの法則を適用するかもしれません。
Daniel Crha

1
ただし、ハードウェアキャッシュはスレッド固有ではありません。実際、シングルスレッドのコードでさえ、OS側からのプリエンプティブマルチタスキングによって強制的に生成される可能性があり、別のプロセッサ(つまり、別のL1およびL2キャッシュ)で実行を再開できます。このキャッシュの無効化は、asyncまたはに固有のものではありませんawait。コンテキスト切り替え中のキャッシュの無効化は、シングルスレッドコードとマルチスレッドコードに同じように影響します。
Daniel Crha
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.