Marc 发布的更高效(更少的总线锁定和更少的读取)和简化的实现:
static int InterlockedIncrementAndClamp(ref int count, int max)
{
int oldval = Volatile.Read(ref count), val = ~oldval;
while(oldval != max && oldval != val)
{
val = oldval;
oldval = Interlocked.CompareExchange(ref count, oldval + 1, oldval);
}
return oldval + 1;
}
如果您的争用非常高,我们可以通过将常见情况减少为单个原子增量指令来进一步提高可伸缩性:与 CompareExchange 的开销相同,但不会出现循环。
static int InterlockedIncrementAndClamp(ref int count, int max, int drift)
{
int v = Interlocked.Increment(ref count);
while(v > (max + drift))
{
// try to adjust value.
v = Interlocked.CompareExchange(ref count, max, v);
}
return Math.Min(v, max);
}
count
在这里,我们允许drift
超过max
. 但我们仍然只返回max
. 这允许我们在大多数情况下将整个操作折叠成单个原子增量,这将允许最大的可扩展性。如果我们的价值超过我们的drift
价值,我们只需要一个以上的操作,您可能可以将其做得足够大以使其非常稀有。
为了回应 Marc 对 Interlocked 和 non-Interlocked 内存访问一起工作的担忧:
关于具体volatile
vs Interlocked:volatile
只是一个普通的内存操作,但一个没有优化掉,一个没有相对于其他内存操作重新排序。这个特定问题并不围绕这些特定属性中的任何一个,所以我们实际上是在谈论非互锁与互锁互操作性。
.NET 内存模型保证基本整数类型的读取和写入(直到机器的本机字大小),并且引用是原子的。Interlocked 方法也是原子的。因为 .NET 只有一个“原子”定义,所以它们不需要明确的特殊情况说明它们相互兼容。
有一件事Volatile.Read
不能保证可见性:您将始终获得加载指令,但 CPU 可能会从其本地缓存中读取旧值,而不是由不同 CPU 放入内存中的新值。x86 在大多数情况下不需要担心这一点(特殊指令,例如MOVNTPS
例外),但对于其他架构来说这是非常可能的事情。
总而言之,这描述了两个可能影响 的问题Volatile.Read
:首先,我们可能在 16 位 CPU 上运行,在这种情况下读取 anint
不会是原子的,我们读取的可能不是其他人正在写入的值。其次,即使它是原子的,由于可见性,我们可能正在读取一个旧值。
但是影响Volatile.Read
并不意味着它们会影响整个算法,这是完全安全的。
如果您以非原子方式同时写入,第一种情况只会咬我们。count
这是因为最终可能发生的是(写 A[0];CAS A[0:1];写 A[1])。因为我们所有的写入都count
发生在保证原子的 CAS 中,所以这不是问题。当我们只是在阅读时,如果我们读取了错误的值,它将被即将到来的 CAS 捕获。
如果你仔细想想,第二种情况实际上只是普通情况的一种特殊情况,即读取和写入之间的值发生变化——读取发生在我们请求它之前。在这种情况下,第一次Interlocked.CompareExchange
调用将报告与给出的值不同的值Volatile.Read
,并且您将开始循环直到它成功。
如果您愿意,可以将Volatile.Read
视为针对低争用情况的纯粹优化。oldval
我们可以使用它进行初始化,0
它仍然可以正常工作。使用Volatile.Read
它很有可能只执行一个 CAS(按照说明,这非常昂贵,尤其是在多 CPU 配置中)而不是两个。
但是,是的,正如 Marc 所说——有时锁更简单!