C# 并发问题:并行写入
有一天,我试图更好地理解线程的概念,所以我写了两个测试程序。其中一项是:C# 并发问题:并行写入,c#,concurrency,task-parallel-library,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++)
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人,有时数字会少一点。但我偶尔看到的是2000000,而数字远远少于2000000。这表明,幕后发生的不仅仅是几个“增量损失”,还有更多的事情正在发生。谁能给我解释一下情况吗
编辑:
当我编写这个测试程序时,我完全知道如何使这个thrad安全,我希望看到的数字少于2000000。让我解释一下为什么我对结果感到惊讶:首先让我们假设上面的推理是正确的。第二个假设(这很可能是我困惑的根源):如果冲突发生(而且确实发生),那么这些冲突是随机的,我希望这些随机事件的发生有点正态分布。在这种情况下,输出的第一行表示:从500000个实验中,随机事件从未发生。第二行写着:随机事件至少发生了167365次。0和167365之间的差异非常大(正态分布几乎不可能)。因此,本案可归结为以下几点:
两个假设中的一个(增量损失模型或“某种程度上正态分布的平行冲突”模型)是不正确的。哪一个是及其原因?该行为源于这样一个事实,即您在使用时既在使用变量
a
又没有锁定对变量的访问(尽管在不使用volatile
时仍然会得到随机分布,但使用volatile
确实会改变分布的性质,下面将对此进行探讨)
使用增量运算符时,它等效于:
a = a + 1;
在本例中,您实际上要执行三个操作,而不是一个操作:
读取a的值
将a的值加1
将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和2000000之间(四个线程*每个线程500000次迭代),因为对a
的访问是串行化的。如果没有volatile
,您将面临a
成为任何东西的风险,因为在读取和/或写入同时发生时,您可能会遇到值a
的损坏
因为您没有为整个增量操作同步访问a
,所以结果是不可预测的,因为您有被覆盖的写操作(如前一个示例所示)
你的情况如何?
对于您的特定情况,您有许多写入被覆盖,而不仅仅是少数写入;由于您有四个线程,每个线程写循环200万次,因此理论上所有写操作都可能被覆盖(将第二个示例扩展为四个线程,然后只添加几百万行以增加循环)
虽然这不太可能,但不应该期望您不会放弃大量的写操作
此外,任务
是一种抽象。实际上(假设您使用的是默认调度程序),它使用获取线程来处理您的请求。ThreadPool
最终与其他操作共享(一些是CLR内部的操作,即使在本例中也是如此),即使如此,它也会执行工作窃取之类的操作,使用当前线程进行操作,并最终在某个点下降到操作系统的某个级别,以获得一个线程来执行工作
正因为如此,你不能假设有一个随机分布的覆盖将被跳过,因为总是会有更多的事情发生,这将抛出你所期望的任何顺序的窗口;处理顺序未定义,工作分配永远不会均匀分布
如果要确保添加内容不会被覆盖,则应在DoStuff
方法中使用,如下所示:
for (int i = 0; i < 500000; i++)
{
Interlocked.Increment(ref a);
}
for(int i=0;i<500000;i++)
{
联锁增量(参考a);
}
这将确保所有写入都将发生,并且您的输出将2000000
20次(根据您的循环)
它还使对volatile
关键字的需要无效,因为您正在使所需的操作成为原子操作
当需要使原子化的操作仅限于一次读取或写入时,volatile
关键字很好
如果您需要执行的不仅仅是读或写操作,那么volatile
关键字太细粒度了,您需要更粗糙的锁定机制
在这种情况下,它是联锁的。增量
,但如果您有更多
for (int i = 0; i < 500000; i++)
{
Interlocked.Increment(ref a);
}
private static object locker = new object();
static void DoStuff()
{
for (int i = 0; i < 500000; i++)
{
lock (locker)
{
a++;
}
}
}
static void DoStuff()
{
for (int i = 0; i < 50000000; i++) // 50 000 000
a++;
}
63838940
60811151
70716761
62101690
61798372
64849158
68786233
67849788
69044365
68621685
86184950
77382352
74374061
58356697
70683366
71841576
62955710
70824563
63564392
71135381