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 与互锁:volatile 只是一个普通的内存操作,但没有优化掉,也没有针对其他内存操作重新排序。这个特定问题不涉及这些特定属性中的任何一个,所以我们实际上是在谈论非互锁与互锁互操作性。
.NET 内存模型保证基本整数类型的读写(最多为机器的本机字长),并且引用是原子的。 Interlocked 方法也是原子的。因为 .NET 只有一种“原子”定义,所以它们不需要明确的特殊情况说明它们相互兼容。
Volatile.Read 保证的一件事不是可见性:你总是会得到一个加载指令,但 CPU 可能会从其本地缓存中读取旧值,而不是刚刚放入的新值内存由不同的CPU。 x86 在大多数情况下不需要担心这一点(像 MOVNTPS 这样的特殊指令是例外),但对于其他架构来说这是非常可能的事情。
总而言之,这描述了可能影响Volatile.Read 的两个问题:首先,我们可能在 16 位 CPU 上运行,在这种情况下读取 int 不会是原子的,我们读取的内容可能不是别人正在写的价值。其次,即使它是原子的,由于可见性,我们可能正在读取旧值。
但影响Volatile.Read 并不意味着它们会影响整个算法,从这些方面来说这是完全安全的。
如果您以非原子方式同时写到count,第一种情况只会对我们不利。这是因为最终可能发生的是(写 A[0];CAS A[0:1];写 A[1])。因为我们对count 的所有写入都发生在保证原子CAS 中,所以这不是问题。当我们只是在阅读时,如果我们读取了错误的值,它将被即将到来的 CAS 捕获。
如果你仔细想想,第二种情况实际上只是正常情况的一种特殊情况,即读取和写入之间的值发生变化——读取发生在我们请求它之前。在这种情况下,第一个 Interlocked.CompareExchange 调用将报告与 Volatile.Read 给出的值不同的值,并且您将开始循环直到它成功。
如果您愿意,可以将Volatile.Read 视为针对低争用情况的纯粹优化。我们可以用0 初始化oldval,它仍然可以正常工作。使用Volatile.Read 使其很有可能只执行一个 CAS(按照说明,这非常昂贵,尤其是在多 CPU 配置中)而不是两个。
但是,正如 Marc 所说,有时锁更简单!