gpt4 book ai didi

c# - 并发问题: parallel writes

转载 作者:太空狗 更新时间:2023-10-29 22:52:52 25 4
gpt4 key购买 nike

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

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之间的差异很大(对于正态分布而言几乎是不可能的)。因此,案例可以归结为以下几点:
这两个假设之一(“增量损失”模型或“某种程度的正态分布的并行碰撞”模型)是不正确的。哪一个是为什么?

最佳答案

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

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

a = a + 1;

在这种情况下,您实际上是在执行三个操作,而不是一个:
  • 读取a的值
  • 添加1到a的值
  • 将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可以确保 a的值分布在0到2,000,000之间(四个线程*每个线程500,000次迭代)。如果没有 volatile,则冒着 a成为任何东西的风险,因为当同时发生读取和/或写入操作时,可能会损坏 a值。

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

    您的情况如何?

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

    尽管这不太可能,但不应期望您不会丢弃大量写操作。

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

    因此,您不能假设会忽略跳过的随机分布,因为总会有更多的事情发生,这会把您期望的任何顺序扔出窗外。处理顺序不确定,工作分配将永远不会平均分配。

    如果要确保添加内容不会被覆盖,则应在 Interlocked.Increment方法中使用 DoStuff method,如下所示:
    for (int i = 0; i < 500000; i++)
    {
    Interlocked.Increment(ref a);
    }

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

    当您进行所需的原子操作时,它也使对 volatile关键字的需求无效。

    当您需要进行原子操作的操作仅限于单个读取或写入时, volatile关键字非常有用。

    如果除了读取或写入外还需要执行其他操作,则 volatile关键字过于精细,则需要更粗略的锁定机制。

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

    关于c# - 并发问题: parallel writes,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/13360966/

    25 4 0
    Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
    广告合作:1813099741@qq.com 6ren.com