Interlocked.CompareExchange不提供正确的内存排序。

huangapple go评论59阅读模式
英文:

Interlocked.CompareExchange doesn't provide correct memory ordering

问题

I understand that you want a translation of the provided code, excluding the code part. Here's the translation of your text:

我正在尝试在C#中实现无锁并发对象池。为了实现happens before行为,我正在使用CompareExchange,它在x86和ARM上至少作为完整内存屏障。以下是我的代码:

有两种方法:RentCommandReturnCommand。第一个方法从池中请求一个新的Command,第二个方法将其返回。我期望cmd.Data = null发生在cmd被返回到ReturnCommand内部的池之前。因此,在RentCommand内部,线程应该始终看到Data字段为null。但调试器会在RentCommand内部明确编码的断点处停止。在理论上,CompareExchange在生产者和消费者两侧都使用,因此它们可以并发运行(不同线程可以安全地调用两种方法)。以下是复现此情况的测试代码:

似乎CPU或JIT重新排序了cmd.Data = null并将其放在CompareExchange之后。但是,我尝试测试了这个假设,并在赋值之后但在CompareExchange之前明确放置了Interlocked.MemoryBarrier()。没有成功。

如果我在两个方法上都放置了[MethodImpl(MethodImplOptions.Synchronized)],测试就会按预期工作。但我不明白为什么?Monitor锁在其开头插入读取屏障,并在结尾插入写入屏障(不管底层的其他活动如等待锁时暂停线程)。

Is there anything specific you would like to know or discuss about this code?

英文:

I'm trying to implement lock-free concurrent pool of objects in C#. To implement happens before behavior, I'm using CompareExchange which acts as a full memory fence (at least on x86 and ARM). Here is my code:

private sealed class Command
{
  internal Command Next;
  internal string Data;
}

private static volatile Command pool;

private static Command RentCommand()
{
  Command current, next = pool;
  do
  {
    if (next is null)
    {
      current = new();
      break;
    }

    current = next;
  }
  while (!ReferenceEquals(next = Interlocked.CompareExchange(ref pool, current.Next, current), current));

  string data = current.Data;

  // catch unexpected behavior with debugger
  if (data is not null)
    System.Diagnostics.Debugger.Break();

  current.Next = null;
  return current;
}

private static void ReturnCommand(Command cmd)
{
  cmd.Data = null;
  Command currentValue = cmd.Next = pool;
  Interlocked.CompareExchange(ref pool, cmd, currentValue);
}

There are two methods: RentCommand and ReturnCommand. The first one requests a new Command from the pool, the second one returns it back. I'm expecting that cmd.Data = null happens before cmd returned back to the pool inside of ReturnCommand. As a result, inside of RentCommand the thread should see that Data field is always null. But the debugger stops at breakpoint which is coded explicitly inside of RentCommand. CompareExchange is used on both sides: consumer and producer. So they can run concurrently (both methods can be called safely by different threads) in theory. Here is the code for test that reproduces this situation:

public static async Task Test()
        {
            static void RunTask(string taskName)
            {
                for (var i = 0; i < 40_000; i++)
                {
                    var cmd1 = RentCommand();
                    cmd1.Data = taskName + "_1";
                    Thread.Yield();
                    var cmd2 = RentCommand();
                    cmd2.Data = taskName + "_2";
                    cmd2.Data.GetHashCode();
                    ReturnCommand(cmd1);
                    ReturnCommand(cmd2);
                }
            }

            await Task.WhenAll(
                Task.Run(static () => RunTask("Task1")),
                Task.Run(static () => RunTask("Task2")),
                Task.Run(static () => RunTask("Task3")),
                Task.Run(static () => RunTask("Task4")),
                Task.Run(static () => RunTask("Task5")),
                Task.Run(static () => RunTask("Task6")),
                Task.Run(static () => RunTask("Task7")),
                Task.Run(static () => RunTask("Task8")),
                Task.Run(static () => RunTask("Task9")),
                Task.Run(static () => RunTask("Task10")),
                Task.Run(static () => RunTask("Task11")),
                Task.Run(static () => RunTask("Task12")),
                Task.Run(static () => RunTask("Task13")),
                Task.Run(static () => RunTask("Task14")),
                Task.Run(static () => RunTask("Task15")),
                Task.Run(static () => RunTask("Task16")));
        }

It seems like CPU or JIT reorders cmd.Data = null and put after CompareExchange. However, I tried to test this hypothesis and put Interlocked.MemoryBarrier() explicitly after assignment but before CompareExchange. No luck.

The test works as expected if I put [MethodImpl(MethodImplOptions.Synchronized)] on both methods. But I don't understand why? Monitor lock inserts read barrier at its beginning and write barrier in the end (regardless of other activities under the hood such as pausing thread while waiting for lock).

答案1

得分: 2

我找到了一个根本原因。让我们想象我们有3个竞争的线程,以及池子里面的两个对象:B -> A(B引用A)。所有三个线程都在执行 RentCommand

  1. T1租用了B(通过CompareExchange),T2租用了A(通过CompareExchange),T3看到 current=B, next=A 但尚未通过CompareExchange
  2. T1归还了B,T3执行CompareExchange。这个操作成功了,因为它的 current 等于 pool。然而,它的 next 指向A。CompareExchange成功地将B交换为A。但是T2还没有归还A。结果,A在使用中时变为可供租用。

看起来这是一个典型的 ABA 问题,尝试使用无锁算法。

结论:无法正确实现使用单链表的线程安全对象池。

英文:

I found a root cause. Let's imagine that we have 3 competing threads, and two objects inside of the pool: B -> A (B references A). All three threads executing RentCommand:

  1. T1 rents B (passes through CompareExchange), T2 rents A (passes through CompareExchange), T3 sees current=B, next=A but didn't pass CompareExchange yet
  2. T1 returns B, T3 executes CompareExchange. This operation is successful because its current is equal to pool. However, its next points to A. CompareExchange swaps B to A successfully. However, T2 didn't return A yet. As a result, A becomes available for rent while in use.

Seems like it's typical ABA problem when trying to use lock-free algorithm.

Conclusion: thread-safe object pooling using singly linked-list cannot be implemented correctly.

huangapple
  • 本文由 发表于 2023年5月26日 00:52:33
  • 转载请务必保留本文链接:https://go.coder-hub.com/76334621.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定