C# 并发问题:并行写入

C# 并发问题:并行写入,c#,concurrency,task-parallel-library,C#,Concurrency,Task Parallel Library,有一天,我试图更好地理解线程的概念,所以我写了两个测试程序。其中一项是: 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++)

有一天,我试图更好地理解线程的概念,所以我写了两个测试程序。其中一项是:

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人,有时数字会少一点。但我偶尔看到的是2000000,而数字远远少于2000000。这表明,幕后发生的不仅仅是几个“增量损失”,还有更多的事情正在发生。谁能给我解释一下情况吗

编辑: 当我编写这个测试程序时,我完全知道如何使这个thrad安全,我希望看到的数字少于2000000。让我解释一下为什么我对结果感到惊讶:首先让我们假设上面的推理是正确的。第二个假设(这很可能是我困惑的根源):如果冲突发生(而且确实发生),那么这些冲突是随机的,我希望这些随机事件的发生有点正态分布。在这种情况下,输出的第一行表示:从500000个实验中,随机事件从未发生。第二行写着:随机事件至少发生了167365次。0和167365之间的差异非常大(正态分布几乎不可能)。因此,本案可归结为以下几点:
两个假设中的一个(增量损失模型或“某种程度上正态分布的平行冲突”模型)是不正确的。哪一个是及其原因?

该行为源于这样一个事实,即您在使用时既在使用变量
a
又没有锁定对变量
的访问(尽管在不使用
volatile
时仍然会得到随机分布,但使用
volatile
确实会改变分布的性质,下面将对此进行探讨)

使用增量运算符时,它等效于:

a = a + 1;
在本例中,您实际上要执行三个操作,而不是一个操作:

  • 读取
    a的值
  • a的值加1
  • 将2的结果分配回
    a
  • 虽然
    volatile
    关键字序列化了访问,但在上述情况下,它将访问序列化为三个单独的操作,而不是将访问作为一个原子工作单元一起序列化

    因为递增时执行三个操作,而不是一个操作,所以添加的内容将被删除

    考虑这一点:

    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有一个旧的、过时的值,因此它实际上覆盖了以前线程所写的结果,基本上清除了这些增量的任何痕迹

    正如您所看到的,当您添加更多线程时,您有更大的可能混淆操作的执行顺序

    volatile
    将防止由于同时发生两次写入而损坏
    a
    的值,或由于读取期间发生写入而损坏
    a
    的读取,但在这种情况下,它不会处理使操作原子化的问题(因为您正在执行三个操作)

    在这种情况下,
    volatile
    确保
    a
    的值分布在0和2000000之间(四个线程*每个线程500000次迭代),因为对
    a
    的访问是串行化的。如果没有
    volatile
    ,您将面临
    a
    成为任何东西的风险,因为在读取和/或写入同时发生时,您可能会遇到值
    a
    的损坏

    因为您没有为整个增量操作同步访问
    a
    ,所以结果是不可预测的,因为您有被覆盖的写操作(如前一个示例所示)

    你的情况如何?

    对于您的特定情况,您有许多写入被覆盖,而不仅仅是少数写入;由于您有四个线程,每个线程写循环200万次,因此理论上所有写操作都可能被覆盖(将第二个示例扩展为四个线程,然后只添加几百万行以增加循环)

    虽然这不太可能,但不应该期望您不会放弃大量的写操作

    此外,
    任务
    是一种抽象。实际上(假设您使用的是默认调度程序),它使用获取线程来处理您的请求。
    ThreadPool
    最终与其他操作共享(一些是CLR内部的操作,即使在本例中也是如此),即使如此,它也会执行工作窃取之类的操作,使用当前线程进行操作,并最终在某个点下降到操作系统的某个级别,以获得一个线程来执行工作

    正因为如此,你不能假设有一个随机分布的覆盖将被跳过,因为总是会有更多的事情发生,这将抛出你所期望的任何顺序的窗口;处理顺序未定义,工作分配永远不会均匀分布

    如果要确保添加内容不会被覆盖,则应在
    DoStuff
    方法中使用,如下所示:

    for (int i = 0; i < 500000; i++)
    {
        Interlocked.Increment(ref a);
    }
    
    for(int i=0;i<500000;i++)
    {
    联锁增量(参考a);
    }
    
    这将确保所有写入都将发生,并且您的输出将
    2000000
    20次(根据您的循环)

    它还使对
    volatile
    关键字的需要无效,因为您正在使所需的操作成为原子操作

    当需要使原子化的操作仅限于一次读取或写入时,
    volatile
    关键字很好

    如果您需要执行的不仅仅是读或写操作,那么
    volatile
    关键字太细粒度了,您需要更粗糙的锁定机制

    在这种情况下,它是
    联锁的。增量
    ,但如果您有更多
    for (int i = 0; i < 500000; i++)
    {
        Interlocked.Increment(ref a);
    }
    
    private static object locker = new object();
    
    static void DoStuff()
    {
        for (int i = 0; i < 500000; i++)
        {
            lock (locker)
            {
                a++;
            }
        }
    }
    
         static void DoStuff()
         {
            for (int i = 0; i < 50000000; i++) // 50 000 000
               a++;
         }
    
    63838940
    60811151
    70716761
    62101690
    61798372
    64849158
    68786233
    67849788
    69044365
    68621685
    86184950
    77382352
    74374061
    58356697
    70683366
    71841576
    62955710
    70824563
    63564392
    71135381