我写了这个实验来向某人证明,使用多个线程连接访问共享数据是一个很大的禁忌。令我惊讶的是,无论我创建了多少个线程,我都无法创建并发问题,并且值总是导致平衡值为0.我知道增量运算符不是线程安全的,这就是为什么有方法例如Interlocked.Increment()
和Interlocked.Decrement()
(此处也注明Is the ++ operator thread safe?)。
如果递增/递减运算符不是线程安全的,那么为什么下面的代码执行时没有任何问题并且结果达到预期值?
以下代码段创建了2,000个主题。 1,000不断递增,1,000不断递减,以确保多个线程同时访问数据。更糟糕的是,在正常的程序中,你几乎没有多少线程。然而,尽管为了创建并发问题而夸大了数字,但该值总是会导致0的平衡值。
static void Main(string[] args)
{
Random random = new Random();
int value = 0;
for (int x=0; x<1000; x++)
{
Thread incThread = new Thread(() =>
{
for (int y=0; y<100; y++)
{
Console.WriteLine("Incrementing");
value++;
}
});
Thread decThread = new Thread(() =>
{
for (int z=0; z<100; z++)
{
Console.WriteLine("Decrementing");
value--;
}
});
incThread.Start();
decThread.Start();
}
Thread.Sleep(TimeSpan.FromSeconds(15));
Console.WriteLine(value);
Console.ReadLine();
}
我希望有人可以向我提供解释,以便我知道编写线程安全软件的所有努力都没有白费,或者这个实验可能在某种程度上存在缺陷。我也尝试过所有线程递增并使用++ i而不是i ++。该值始终产生预期值。
答案 0 :(得分:4)
如果有两个线程在非常关闭时间递增和递减,通常只能看到问题。 (还有内存模型问题,但它们是分开的。)这意味着你希望它们花费大部分时间递增和递减,以便为你提供操作冲突的最佳机会。
目前,您的线程将大部分时间花在浩大的上,或者写入控制台。这大大减少了碰撞的可能性。
此外,我注意到缺少证据并不是缺席的证据 - 并发问题确实很难引发,特别是如果您碰巧在具有强内存模型和内部原子递增/递减的CPU上运行JIT可以使用的说明。可能是您永远不会在您的特定计算机上引发问题 - 但是同一程序可能会在另一台计算机上失败。
答案 1 :(得分:1)
除了Jon Skeets的回答:
一个简单的测试,至少在我的双核心上很容易显示问题:
Sub Main()
Dim i As Long = 1
Dim j As Long = 1
Dim f = Sub()
While Interlocked.Read(j) < 10 * 1000 * 1000
i += 1
Interlocked.Increment(j)
End While
End Sub
Dim l As New List(Of Task)
For n = 1 To 4
l.Add(Task.Run(f))
Next
Task.WaitAll(l.ToArray)
Console.WriteLine("i={0} j={1}", i, j)
Console.ReadLine()
End Sub
i和j应该都具有相同的最终值。但他们没有!
修改强>
如果您认为,C#比VB更聪明:
static void Main(string[] args)
{
long i = 1;
long j = 1;
Task[] t = new Task[4];
for (int k = 0; k < 4; k++)
{
t[k] = Task.Run(() => {
while (Interlocked.Read(ref j) < (long)(10*1000*1000))
{
i++;
Interlocked.Increment(ref j);
}});
}
Task.WaitAll(t);
Console.WriteLine("i = {0} j = {1}", i, j);
Console.ReadLine();
}
它不是;)
结果:我比j低约15%(百分比!)。在我的机器上。拥有一个八线程机器,probabyl甚至可能使结果更加迫在眉睫,因为如果几个任务真正并行运行并且不仅仅是先发制人,那么错误更有可能发生。
上述代码当然有缺陷:(
如果任务被抢占,就在i ++之后,所有其他任务继续增加i和j,所以我希望与j不同,即使“++”是原子的。但是有一个简单的解决方案:
static void Main(string[] args)
{
long i = 0;
int runs = 10*1000*1000;
Task[] t = new Task[Environment.ProcessorCount];
Stopwatch stp = Stopwatch.StartNew();
for (int k = 0; k < t.Length; k++)
{
t[k] = Task.Run(() =>
{
for (int j = 0; j < runs; j++ )
{
i++;
}
});
}
Task.WaitAll(t);
stp.Stop();
Console.WriteLine("i = {0} should be = {1} ms={2}", i, runs * t.Length, stp.ElapsedMilliseconds);
Console.ReadLine();
}
现在可以在循环语句中的某处抢占任务。但这不会影响i
。因此,如果任务在i
语句处被抢占,那么在i++
上看到效果的唯一方法就是这样。这就是要显示的内容:它可能会发生,并且当您的运行任务较少但运行时间较长时,更有可能发生这种情况。
如果您编写Interlocked.Increment(ref i);
而不是i++
,则代码运行的时间会更长(因为锁定),但i
正是应该的!
答案 2 :(得分:1)
IMO这些循环太短了。我敢打赌,到第二个线程启动时,第一个线程已经完成了循环并退出。尝试大幅增加每个线程执行的迭代次数。此时你甚至可以只生成两个线程(删除外部循环),它应该足以看到错误的值。
例如,使用以下代码我的系统上的结果完全错误:
static void Main(string[] args)
{
Random random = new Random();
int value = 0;
Thread incThread = new Thread(() =>
{
for (int y = 0; y < 2000000; y++)
{
value++;
}
});
Thread decThread = new Thread(() =>
{
for (int z = 0; z < 2000000; z++)
{
value--;
}
});
incThread.Start();
decThread.Start();
incThread.Join();
decThread.Join();
Console.WriteLine(value);
}