我们有一个方法来维护应用程序中所有事件的全局序列索引。由于它是网站,它被期望有这样的方法线程安全。线程安全实现如下:
private static long lastUsedIndex = -1;
public static long GetNextIndex()
{
Interlocked.Increment(ref lastUsedIndex);
return lastUsedIndex;
}
然而,我们注意到,在一些不太重的负载下,系统中出现了重复的索引。简单的测试表明,100000次迭代大约有1500个重复。
internal class Program
{
private static void Main(string[] args)
{
TestInterlockedIncrement.Run();
}
}
internal class TestInterlockedIncrement
{
private static long lastUsedIndex = -1;
public static long GetNextIndex()
{
Interlocked.Increment(ref lastUsedIndex);
return lastUsedIndex;
}
public static void Run()
{
var indexes = Enumerable
.Range(0, 100000)
.AsParallel()
.WithDegreeOfParallelism(32)
.WithExecutionMode(ParallelExecutionMode.ForceParallelism)
.Select(_ => GetNextIndex())
.ToList();
Console.WriteLine($"Total values: {indexes.Count}");
Console.WriteLine($"Duplicate values: {indexes.GroupBy(i => i).Count(g => g.Count() > 1)}");
}
}
这可以通过以下实现来解决:
public static long GetNextIndex()
{
return Interlocked.Increment(ref lastUsedIndex);
}
然而,我不清楚,为什么第一次实施没有奏效。有人能帮我描述一下那个案子发生了什么吗?
如果它在您的原始示例中起作用,您也可以说它适用于的一般情况
Interlocked.Increment(ref someValue);
// Any number of operations
return someValue;
要做到这一点,您必须消除Increment
和返回之间的所有并发性(包括并行性、重复性、预执行代码…)。更糟糕的是,您需要确保即使在返回和Increment
之间使用someValue
,它也不会以任何方式影响返回。换句话说,someValue
必须不可能在两个语句之间更改(不可变)。
你可以清楚地看到,如果是这样的话,你一开始就不需要Interlocked.Increment
——你只需要做someValue++
。Interlocked
和其他原子操作的全部目的是确保操作要么立即(原子地)发生,要么根本不发生。特别是,它可以防止任何类型的指令重新排序(通过CPU优化,或通过在两个逻辑CPU上并行运行的多个线程,或在单个CPU上预处理)。但仅限于原子操作。someValue
的后续读取是而不是同一原子操作的一部分(它本身是原子操作,但两个原子操作不会使总和也成为原子操作)。
但你并没有试图做"任何数量的操作",是吗?事实上,你是。因为还有其他线程相对于您的线程异步运行——您的线程可能被其中一个线程预处理,或者这些线程可能真的在多个逻辑CPU上并行运行。
在实际环境中,您的示例提供了一个不断增加的字段(因此它比someValue++
好一点),但它没有为您提供唯一的id,因为在某个不确定的时刻,您所读取的只是someValue
。如果两个线程尝试同时进行增量,则两者都将成功(Interlocked.Increment
是原子),但它们也将从someValue
读取相同的值。
这并不意味着你总是想使用Interlocked.Increment
的返回值——如果你对增量本身更感兴趣,而不是对增量值更感兴趣的话。一个典型的例子可能是一个廉价的评测方法——每个方法调用可能会增加一个共享字段,然后每隔一段时间读取一次值,例如平均每秒调用数。
根据评论,正在发生以下情况。
假设我们有lastUsedIndex == 5
和2个并行线程。
第一个线程将执行Interlocked.Increment(ref lastUsedIndex);
,而lastUsedIndex
将变为6
。然后第二个线程将执行Interlocked.Increment(ref lastUsedIndex);
,并且lastUsedIndex
将变为7
。
然后两个线程都将返回lastUsedIndex
的值(记住它们是并行的)。这个值现在是7
。
在第二个实现中,两个线程都将返回Interlocked.Increment()
函数的结果。这将在每个线程(6
和7
)中不同。换句话说,在第二个实现中,我们返回一个递增值的副本,并且该副本在其他线程中不受影响。