并发问题:并行写入

Har*_*ari 5 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++)
        {
            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++;
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

我希望我能看到小于2000000的输出.我想象中的模型如下:更多线程同时读取变量a,a的所有本地副本都是相同的,线程递增它并且写入发生并且这种方式"丢失"一个或多个增量.

虽然输出是违反这个推理的.一个示例输出(来自corei5机器):

2000000
1497903
1026329
2000000
1281604
1395634
1417712
1397300
1396031
1285850
1092027
1068205
1091915
1300493
1357077
1133384
1485279
1290272
1048169
704754
Run Code Online (Sandbox Code Playgroud)

如果我的推理是真的,我会偶尔看到2000000,有时候数字会少一些.但我看到的偶尔是2000000,数字不到2000000.这表明幕后发生的事情不仅仅是一些"增量损失",而是还有更多的事情发生.有人可以解释一下情况吗?

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

cas*_*One 8

这种行为源于你在使用increment运算符()时同时使用volatile关键字以及不锁定对变量的访问这一事实(尽管你在不使用时仍然得到随机分布,使用确实改变了分布的性质,这在下面探讨).a++volatilevolatile

使用增量运算符时,它相当于:

a = a + 1;
Run Code Online (Sandbox Code Playgroud)

在这种情况下,你实际上做了三个操作,而不是一个:

  1. 读取值 a
  2. 将值加1 a
  3. 将结果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)
Run Code Online (Sandbox Code Playgroud)

甚至这个:

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
Run Code Online (Sandbox Code Playgroud)

特别注意步骤5-7,线程2已将值写回a,但由于线程3具有旧的陈旧值,它实际上会覆盖先前线程已写入的结果,基本上消除了这些增量的任何跟踪.

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

volatile将阻止您破坏a由于两次写入同时发生的值,或者a由于读取期间发生写入而导致的损坏读取,但是在这种情况下它不会做任何事情来处理使操作成为原子的(因为你正在进行三项操作).

在这种情况下,由于访问的序列化,volatile确保值的分布a在0到2,000,000之间(每个线程四个线程*500,000次迭代)a.如果没有volatile,那么你就冒着a成为任何东西的风险,因为a当读取和/或写入同时发生时,你可能会遇到腐败的问题.

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

你的情况怎么样?

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

虽然这不太可能,但不应期望你不会丢掉大量的写作.

另外,Task是一种抽象.实际上(假设您使用的是默认调度程序),它使用ThreadPool该类来获取线程来处理您的请求.该ThreadPool最终与共享操作(一些内部的CLR,即使在这种情况下),即使这样,它的东西像工作窃取,使用操作,最终在某个时刻当前线程下降到操作系统的一些获得线程执行工作的级别.

因此,你不能假设有一个随机分配的覆盖将被跳过,因为总会有更多的东西会抛出你期望窗口的任何顺序; 处理顺序未定义,工作分配永远不会均匀分配.

如果要确保不会覆盖添加,那么您应该使用Interlocked.Increment方法中的DoStuff方法,如下所示:

for (int i = 0; i < 500000; i++)
{
    Interlocked.Increment(ref a);
}
Run Code Online (Sandbox Code Playgroud)

这将确保所有写入都将发生,并且您的输出将是2000000二十次(根据您的循环).

它还会使对volatile关键字的需求无效,因为您正在进行所需的操作.

volatile当你需要原子操作仅限于一个单一的读取或写入关键字是好的.

如果您不得不执行任何操作不是读取或写入,那么volatile关键字精细,您需要更粗略的锁定机制.

在这种情况下,它是Interlocked.Increment,但如果你有更多,你必须做,那么该lock声明很可能是你所依赖的.