【问题标题】:Concurrency issue: parallel writes并发问题:并行写入
【发布时间】:2012-11-14 02:44:05
【问题描述】:

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

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 之间的差异太大(正态分布几乎不可能)。所以案件归结为以下几点: 两个假设之一(“增量损失”模型或“有点正态分布的并行冲突”模型)是不正确的。是哪一个?为什么?

【问题讨论】:

  • 请记住,任务不是线程,它们是更高级别的抽象。见stackoverflow.com/questions/4130194/…
  • 你真的应该在第一个内部循环中使用Task.Factory.StartNew(DoStuff) :p
  • @Baboon 如果你想真的学究气,那么你会使用在.NET 4.5中引入的Task.Run
  • 当我运行你的程序时,我看到一致的数字在大约。 1.1 和 180 万。你不可能在没有竞争条件的情况下进行 200 万次增量......你能重现你的数字吗?
  • @Jan 在两次单数运行之间不可能重现数字,更不用说在不同机器上运行了;这一切都取决于处理器何时获得变量,每次都是不确定的(取决于发生了什么)。重点是,values are being overwritten because of the increment operator (++)

标签: c# concurrency task-parallel-library


【解决方案1】:

该行为源于您同时使用volatile keyword 以及在使用increment operator (++) 时未锁定对变量a 的访问(尽管在不使用volatile 时您仍然会得到随机分布,使用volatile 确实会改变分布的性质,这将在下面进行探讨)。

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

a = a + 1;

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

  1. 读取a的值
  2. a的值加1
  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)

甚至这个:

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 次迭代),因为对a 的访问的这种序列化。如果没有volatile,您将面临a 成为任何东西的风险,因为当读取和/或写入同时发生时,您可能会遇到值a 的损坏。

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

您的情况如何?

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

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

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

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

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

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

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

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

volatile 关键字适用于您需要进行原子操作的操作仅限于单次读取或写入。

如果您需要做的事情更多而不是读取或写入,那么volatile 关键字过于粒度,您需要更粗略的锁定机制。

在这种情况下,它是Interlocked.Increment,但如果您还有更多工作要做,那么lock statement 很可能是您所依赖的。

【讨论】:

  • 不幸的是 ref 参数没有保留 volatile 功能,使得他的测试是假的。
  • @Baboon 添加适当的锁后,对 volatile 的需求就消失了,我现在添加它,谢谢。
  • 问题的重点不在于线程安全。而是关于在特定线程不安全情况下会发生什么。请看我的编辑
  • @Hari 请参阅“您的案例中发生了什么”部分,该部分针对您的具体案例。虽然这与线程安全无关,但您需要了解这里要做的事情比您想象的要多(即,增量运算符的行为以及 volatile 序列化访问的程度,以及它们在组合时如何给您带来意想不到的结果)。其他一切都是关于为什么会出现这种情况,以及如何避免它(如果你愿意的话)。
  • @Hari 更新了更多关于为什么看不到随机分布的具体点;当您收到Task 时,发生的事情太多了,以确保工作的平均分配(并且通过扩展,覆盖的平均分配)。
【解决方案2】:

我认为这并没有发生其他任何事情——只是发生了很多。如果您添加“锁定”或其他一些同步技术 (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++;
        }
    }
}

【讨论】:

  • “只是发生了很多”相当模糊,不能很好地解释正在发生的事情,即使在高达 65535 的值上使用 lock 也是过大的当你有Interlocked.Increment;在执行操作时,CLR 会将所有内容升迁为 32 位整数无论如何,因此您不妨使用int
  • OP 的问题表明他在从多个线程递增值时意识到线程问题。他说他期待“减少损失”,并询问是否发生了“其他事情”。他明白这不是原子的。 lock 只是一个示例,我包含的链接还演示了 Interlocked.Increment。
【解决方案3】:

尝试增加数量,时间跨度太短,无法得出任何结论。请记住,正常的 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
  • 硬件很重要!

【讨论】:

  • 你不能永远依赖正态分布,因为Task 会使用工作窃取等。除非您自己处理分区,否则您将永远能够依赖均匀的工作分配。
  • @casperOne Never 是一个非常强大的词,窃取工作实际上与生成的机器指令无关,理论上值的分布在更长的时间跨度内将是正常的。对于大部分偏差,我仍然押注硬件,而不是软件。
  • 您的回答启发了我在 c++ 中进行相同的测试结果与我的问题中包含的 .Net Task Parallel Lib 版本完全不同。 c++ 输出看起来像我一开始所期望的(类似于某种正态分布)。所以@casperOne 是对的,这种情况的主要因素是一些更高级别的 .Net 抽象。
猜你喜欢
  • 2011-12-19
  • 2011-11-27
  • 1970-01-01
  • 2013-01-13
  • 1970-01-01
  • 1970-01-01
  • 2015-05-09
相关资源
最近更新 更多