并发问题:并行写入

时间:2012-11-13 12:41:27

标签: 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++;
        }
    }
}

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

虽然输出反对这种推理。一个示例输出(来自corei5机器):

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

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

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

3 个答案:

答案 0 :(得分:8)

这种行为源于这样一个事实,即在使用volatile keyword时,您同时使用increment operator (++)以及未锁定对变量a的访问权限(尽管您仍然可以获得随机分布)如果不使用volatile,则使用volatile确实会改变分发的性质,这将在下面进行探讨。

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

a = a + 1;

在这种情况下,您实际上正在执行三个操作,而不是一个:

  1. 阅读a
  2. 的值
  3. a
  4. 的值加1
  5. 将结果2分配回a
  6. 虽然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到2,000,000之间(每个线程四个线程* 500,000次迭代),因为{{1}的访问序列化}。如果没有a,则存在volatile 任何的风险,因为当读取和/或写入发生时,您可能会遇到值a的损坏时间。

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

    你的情况怎么样?

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

    虽然它不是可能的,但不应期望不会删除大量的写入。

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

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

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

    DoStuff

    这将确保所有写入都会发生,并且您的输出将是for (int i = 0; i < 500000; i++) { Interlocked.Increment(ref a); } 二十次(根据您的循环)。

    它还使2000000关键字的需要无效,因为您正在进行需要原子操作。

    当您需要将原子操作限制为单个读取或写入时,volatile关键字是好的。

    如果您必须执行 more 而不是读取或写入操作,那么volatile关键字粒度,您需要更粗略的锁定机制

    在这种情况下,它是volatile,但如果您还有更多需要做的事情,那么lock statement很可能就是您所依赖的。

答案 1 :(得分:0)

我不认为这是其他任何事情 - 它只是发生了很多。如果你添加'锁定'或其他一些同步技术(Best thread-safe way to increment an integer up to 65535),你将可靠地获得完整的2,000,000增量。

每个任务都按照您的预期调用DoStuff()。

private static object locker = new object();

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

答案 2 :(得分:0)

尝试增加数量,时间跨度简单地缩短以得出任何结论。请记住,正常IO在毫秒范围内,在这种情况下只有一个阻塞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%。

我可以解释我的结果,因为我在2个物理核心上运行,虽然由于超线程被视为4,这意味着如果在实际添加期间进行“ht-switch”是最佳的,至少50%的添加将被“删除”(如果我记得正确实现它将是(即在加载/保存其他线程数据时修改ALU中的一些线程数据))。剩余的15%由于该程序实际上并行运行在2个核心上。

我的建议

  • 发布您的硬件
  • 增加循环次数
  • 改变TaskCount
  • 硬件很重要!