我在相当流行的LazyCache中发现了一些多线程代码库,使用 int[]
字段作为粒度锁定机制,旨在防止并发调用具有相同key
的方法。作为论证。我对这段代码的正确性非常怀疑,因为没有Interlocked
或Volatile
退出保护区时使用的操作。这是代码的重要部分:
private readonly int[] keyLocks;
public virtual T GetOrAdd<T>(string key, Func<ICacheEntry, T> addItemFactory,
MemoryCacheEntryOptions policy)
{
/* Do stuff */
object cacheItem;
// acquire lock per key
uint hash = (uint)key.GetHashCode() % (uint)keyLocks.Length;
while (Interlocked.CompareExchange(ref keyLocks[hash], 1, 0) == 1) Thread.Yield();
try
{
cacheItem = CacheProvider.GetOrCreate<object>(key, CacheFactory);
}
finally
{
keyLocks[hash] = 0;
}
/* Do more stuff */
}
protected 方法调用是 CacheProvider.GetOrCreate<object>(key, CacheFactory)
。它应该一次由一个线程调用,同样的 key
。要进入保护区,有 while
使用 Interlocked.CompareExchange
的循环更改 keyLocks
的值来自 0
的数组至1
。到目前为止,一切都很好。我关心的部分是退出保护区的线:keyLocks[hash] = 0;
。由于那里没有障碍,我的理解是 C# 编译器和 .NET Jitter 可以自由地向任一方向移动指令,跨过这条线。所以 CacheProvider.GetOrCreate
内的指令方法可以移到 keyLocks[hash] = 0;
之后.
我的问题是:根据规范,上面的代码是否真的确保 CacheProvider.GetOrCreate
不会用同一个键同时调用吗?这段代码是否实现了互斥的 promise ?或者代码只是有错误?
上下文:相关代码已在此拉取请求中添加到库中:Optimize cache to lock per key .
最佳答案
我觉得有问题; keyLocks[hash] = 0;
不是release store Do stuff
的所以部分可以在临界区之外重新排序,只有在获得锁后才可能对另一个线程可见。
(可能会读取已修改的数据,或者更有可能让存储出现较晚并从下一个线程进入存储,或者不会被其负载看到。)
它很可能会在 x86 上编译以纠正 asm,where all asm stores have "release" semantics因此,只有编译时重新排序可能会破坏某些东西,但在 ARM/AArch64 或其他弱排序的主流 ISA 上则不会。因此,在 x86 上进行测试无法揭示此错误,除非您确实进行了编译时重新排序。 (它仍然损坏,该错误只是处于休眠状态。)
https://preshing.com/20121019/this-is-why-they-call-it-a-weakly-ordered-cpu/在 C++ 中演示了一个使用 relaxed
的自旋锁而不是acquire
/release
,并且它在 ARM 上的实践中失败了。这个例子和这个一模一样,只不过这里的 CAS 就像 C++ memory_order_seq_cst
所以临界区的顶部足够坚固。但这还不够;更强的获取锁定顺序并不能避免解锁太弱。
基本的自旋锁需要一个获取 RMW 来获得独占所有权,并需要一个释放存储来解锁,因此得名。这足以保留 Do stuff
包含在该方向的关键部分内。
In C#, a release store can be done与 Volatile.Write
,或通过分配给 volatile
目的。我的理解是那些相当于 C++ foo.store(val, std::memory_order_release)
.
相关x86 asm示例和自旋锁讨论:
- Spinlock with XCHG unlocking (解锁不需要是原子 RMW,也不需要像
Interlocked.Exchange
那样强大的屏障,但需要是release
) - Locks around memory manipulation via inline assembly - 手写的 x86 asm,其中讨论了尝试首先获取锁然后旋转只读与从针对竞争情况进行优化的只读访问开始的讨论。即使我们没有看到任何证据表明它可能可用,这里的朴素锁仍然会不断发送垃圾邮件原子 CAS 尝试。它使用
Thread.Yield()
而不是SpinWait.SpinOnce()
,如果您的线程多于核心,并且关键部分往往需要很长时间才能解锁,这可能会很好。
关于c# - 使用Interlocked.CompareExchange作为锁,只用于进入,不用于退出,是否安全?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/75728657/