5

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

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。这表明幕后发生的不仅仅是几个“增量损失”,而是更多的事情正在发生。有人可以解释一下情况吗?

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

4

3 回答 3

8

该行为源于您在使用增量运算符 ( )时同时使用volatile关键字以及未锁定对变量的访问这一事实(尽管在不使用时您仍然会得到随机分布,但 using确实会改变分布的性质,这将在下面进行探讨)。a++volatilevolatile

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

a = a + 1;

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

  1. 读取值a
  2. 值加 1a
  3. 将 2 的结果分配回a

虽然volatile关键字序列化访问,但在上述情况下,它是对三个独立操作的序列化访问,而不是对它们的集体序列化访问,作为一个原子工作单元。

因为您在递增而不是1时执行了三个操作,所以您有被删除的添加。

考虑一下:

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由于aa. 如果没有volatile,您将面临a成为任何东西a的风险,因为当读取和/或写入同时发生时,您可能会遇到值损坏。

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

你的情况是怎么回事?

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

虽然这不太可能,但不应期望您不会丢弃大量写入。

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

因此,您不能假设随机分布的覆盖会被跳过,因为总会有更多的事情发生,这会抛出您期望的任何顺序。处理的顺序是不确定的,工作的分配永远不会均匀分布

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

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

这将确保所有写入都会发生,并且您的输出将是200000020 次(根据您的循环)。

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

volatile当您需要使原子化的操作仅限于单个读取或写入时,该关键字很好。

如果你必须做的不仅仅是读或写,那么volatile关键字细了,你需要一个更粗略的锁定机制。

在这种情况下,它是Interlocked.Increment,但如果您有更多事情要做,那么该lock语句很可能就是您所依赖的。

于 2012-11-13T12:59:03.930 回答
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 回答
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% 的添加将是“删除”(如果我记得 ht 的实现正确的话(即在加载/保存其他线程数据的同时修改 ALU 中的一些线程数据)。剩下的 15% 是由于程序实际在 2 个内核上并行运行。

我的建议

  • 发布您的硬件
  • 增加循环次数
  • 改变TaskCount
  • 硬件很重要!
于 2012-11-13T13:37:06.160 回答