5

ある日、スレッド化の概念をよりよく理解しようとしていたので、いくつかのテスト プログラムを作成しました。それらの1つは次のとおりです。

using System;
using System.Threading.Tasks;
class Program
{
    static volatile int a = 0;

    static void Main(string[] args)
    {
        Task[] tasks = new Task[4];

        for (int h = 0; h < 20; h++)
        {
            a = 0;
            for (int i = 0; i < tasks.Length; i++)
            {
                tasks[i] = new Task(() => DoStuff());
                tasks[i].Start();
            }
            Task.WaitAll(tasks);
            Console.WriteLine(a);
        }
        Console.ReadKey();
    }

    static void DoStuff()
    {
        for (int i = 0; i < 500000; i++) 
        {
            a++;
        }
    }
}

2000000 未満の出力が見られることを期待しました。私の想像のモデルは次のとおりです。より多くのスレッドが同時に変数 a を読み取り、a のすべてのローカル コピーが同じになり、スレッドがそれをインクリメントし、書き込みが行われます。このようにして、1 つ以上のインクリメントが「失われます」。

出力はこの推論に反していますが。1 つの出力例 (corei5 マシンから):

2000000
1497903
1026329
2000000
1281604
1395634
1417712
1397300
1396031
1285850
1092027
1068205
1091915
1300493
1357077
1133384
1485279
1290272
1048169
704754

私の推論が正しければ、2000000 がときどき表示され、場合によっては数値が少し小さくなることもあります。しかし、私が見ているのは 2000000 であり、2000000 よりはるかに少ない数です。誰か状況を説明してくれませんか?

編集: このテスト プログラムを書いていたとき、このスレッドを安全にする方法を十分に認識しており、2000000 未満の数値が表示されることを期待していました。出力に驚いた理由を説明しましょう: まず、上記の理由が正しい。2 番目の仮定 (これが私の混乱の元になる可能性が非常に高い): 競合が発生した場合 (実際に発生した場合)、これらの競合はランダムであり、これらのランダムなイベントの発生にはある程度正規分布が予想されます。この場合、出力の最初の行は次のように述べています。2 行目は、ランダム イベントが少なくとも 167365 回発生したことを示しています。0 と 167365 の差は大きすぎます (正規分布ではほとんど不可能です)。したがって、ケースは次のように要約されます: 2 つの仮定のうちの 1 つ (「増分損失」モデルまたは「ある程度正規分布した並列競合」モデル) は正しくありません。どちらが原因ですか?

4

3 に答える 3

8

volatileこの動作は、キーワードを使用していることと、インクリメント演算子 ( )aを使用するときに変数へのアクセスをロックしていないことに起因します( を使用しない場合でもランダムな分布が得られますが、使用すると分布の性質が変わります。これについては以下で説明します)。++volatilevolatile

インクリメント演算子を使用する場合、次と同等です。

a = a + 1;

この場合、実際には 1 つではなく3 つの操作を実行しています。

  1. の値を読み取りますa
  2. の値に 1 を加算します。a
  3. 2 の結果を代入するa

キーワードはvolatileアクセスをシリアル化しますが、上記の場合、アトミックな作業単位として、それらへのアクセスをまとめてシリアル化するのではなく、3 つの個別の操作へのアクセスをシリアル化します。

インクリメント時に 1 つではなく 3 つの操作実行しているため、削除される追加があります。

このことを考慮:

Time    Thread 1                 Thread 2
----    --------                 --------
   0    read a (1)               read a (1)
   1    evaluate a + 1 (2)       evaluate a + 1 (2)
   2    write result to a (3)    write result to a (3)

またはこれでも:

Time    a    Thread 1               Thread 2           Thread 3
----    -    --------               --------           --------
   0    1    read a                                    read a
   1    1    evaluate a + 1 (2)
   2    2    write back to a
   3    2                           read a
   4    2                           evaluate a + 1 (3)
   5    3                           write back to a
   6    3                                              evaluate a + 1 (2)
   7    2                                              write back to a

特に手順 5 ~ 7 では、スレッド 2 が値を a に書き戻していることに注意してください。ただし、スレッド 3 は古い古い値を持っているため、実際には以前のスレッドが書き込んだ結果を上書きし、基本的にそれらのインクリメントの痕跡を消去します。

ご覧のとおり、スレッドを追加すると、操作が実行される順序が混同される可能性が高くなります。

volatilea2 つの書き込みが同時に発生したためにの値が破損したり、読み取り中に書き込みが発生したために の読み取りが破損したりするのを防ぎますがa、この場合、操作をアトミックにする処理は何もしません ( 3 つの操作を実行しています)。

この場合、へのアクセスがシリアル化されるためvolatile、 の値の分布がa0 ~ 2,000,000 (4 スレッド * スレッドあたり 500,000 回の反復) になるようにしaます。がないと、読み取りおよび/または書き込みが同時に発生したときに値が破損する可能性があるため、何かvolatileになるリスクがありaます。a

インクリメント操作全体aで へのアクセスを同期していないため、(前の例で見たように) 上書きされる書き込みがあるため、結果は予測できません。

あなたの場合はどうですか?

あなたの特定のケースでは、上書きされている書き込みがいくつかあります。それぞれ 200 万回ループを書き込む 4 つのスレッドがあるため、理論的にはすべての書き込みが上書きされる可能性があります(2 番目の例を 4 つのスレッドに拡張し、ループをインクリメントするために数百万行を追加するだけです)。

可能性が高いとは言えませんが、膨大な量の書き込みを落とさないとは期待できません。

さらに、Task抽象化です。実際には (デフォルトのスケジューラーを使用していると仮定して)、このThreadPoolクラスを使用して、要求を処理するスレッドを取得します。は最終的に他の操作 (この場合でも CLR の内部) とThreadPool共有され、その場合でも、現在のスレッドを操作に使用してワークスティーリングなどを実行し、最終的にある時点でオペレーティング システムにドロップダウンします。作業を実行するスレッドを取得する レベル。

このため、スキップされる上書きのランダムな分布があると想定することはできません。これは、ウィンドウから予想される順序をスローするより多くのことが常に行われるためです。処理の順序が定義されていないため、作業の割り当てが均等に分散されることはありません

追加が上書きされないようにしたい場合は、次のようにInterlocked.Incrementメソッド内でメソッドを使用する必要があります。DoStuff

for (int i = 0; i < 500000; i++)
{
    Interlocked.Increment(ref a);
}

これにより、すべての書き込みが確実に行われ、出力は2000000(ループに従って) 20 回になります。

volatileまた、必要な操作をアトミックにしているため、キーワードの必要性も無効になります。

キーワードは、volatileアトミックにする必要がある操作が単一の読み取りまたは書き込みに限定されている場合に適しています。

読み取りまたは書き込み以外のことを行う必要がある場合、キーワードvolatile細かすぎるため、より粗いロック メカニズムが必要になります。

この場合はInterlocked.Incrementですが、やらなければならないことがもっとある場合は、lockステートメントが頼りになる可能性が高くなります。

于 2012-11-13T12:59:03.930 に答える
0

金額を増やしてみてください。結論を出すには、期間が短すぎます。通常のIOはミリ秒の範囲であり、この場合、1つのブロッキングIO-opだけで結果が役に立たなくなることに注意してください。

これに沿った何かがより良いです:(またはなぜintmaxではないのですか?)

     static void DoStuff()
     {
        for (int i = 0; i < 50000000; i++) // 50 000 000
           a++;
     }

私の結果(「正しい」は400 000 000):

63838940
60811151
70716761
62101690
61798372
64849158
68786233
67849788
69044365
68621685
86184950
77382352
74374061
58356697
70683366
71841576
62955710
70824563
63564392
71135381

実際には正規分布ではありませんが、そこに到達しています。これは正しい量の約35%であることに注意してください。

ハイパースレッディングのために4と見なされますが、2つの物理コアで実行しているため、結果を説明できます。つまり、実際の追加中に「ht-switch」を実行するのが最適な場合、追加の少なくとも50%は「削除されました」(htの実装を正しく覚えている場合はそうなります(つまり、他のスレッドデータをロード/保存しながらALUの一部のスレッドデータを変更します))。そして残りの15%は、プログラムが実際に2つのコアで並列に実行されているためです。

私の推奨事項

  • ハードウェアを投稿する
  • ループ数を増やす
  • TaskCountを変更します
  • ハードウェアが重要です!
于 2012-11-13T13:37:06.160 に答える
0

他に何かが起こっているとは思いません。ただ、たくさん起こっているだけです。「ロック」またはその他の同期手法 (整数を最大 65535 までインクリメントするスレッドセーフな方法) を追加すると、完全な 2,000,000 インクリメントを確実に取得できます。

ご想像のとおり、各タスクは DoStuff() を呼び出しています。

private static object locker = new object();

static void DoStuff()
{
    for (int i = 0; i < 500000; i++)
    {
        lock (locker)
        {
            a++;
        }
    }
}
于 2012-11-13T12:59:13.223 に答える