Singleton double-check concurrency issue

我与影子孤独终老i 提交于 2019-12-17 14:03:10

问题


The fallowing clause is taken from jetbrains.net After reading this and some other articles on the web, I still don't understand how is it possible to return null, after the first thread go in to the lock. Some one that does understand it can please help me and explain it in more humanized way?

"Consider the following piece of code:

public class Foo
{
  private static Foo instance;
  private static readonly object padlock = new object();

  public static Foo Get()
  {
    if (instance == null)
    {
      lock (padlock)
      {
        if (instance == null)
        {
          instance = new Foo();
        }
      }
    }
    return instance;
  }
};

Given the above code, writes that initialize the Foo instance could be delayed until the write of the instance value, thus creating the possibility that the instance returns an object in an unitialized state.

In order to avoid this, the instance value must be made volatile. "


回答1:


Returning null is not the issue. The issue is that the new instance may be in a partially constructed state as perceived by another thread. Consider this declaration of Foo.

class Foo
{
  public int variable1;
  public int variable2;

  public Foo()
  {
    variable1 = 1;
    variable2 = 2;
  }
}

Here is how the code could get optimized by the C# compiler, JIT compiler, or hardware.1

if (instance == null)
{
  lock (padlock)
  {
    if (instance == null)
    {
      instance = alloc Foo;
      instance.variable1 = 1; // inlined ctor
      instance.variable2 = 2; // inlined ctor
    }
  }
}
return instance;

First, notice that the constructor is inlined (because it was simple). Now, hopefully it is easy to see that instance gets assigned the reference before its constituent fields get initialized inside the constructor. This is a valid strategy because reads and writes are free to float up and down as long as they do not pass the boundaries of the lock or alter the logical flow; which they do not. So another thread could see instance != null and attempt to use it before it is fully initialized.

volatile fixes this issue because it treats reads as an acquire fence and writes as a release fence.

  • acquire-fence: A memory barrier in which other reads & writes are not allowed to move before the fence.
  • release-fence: A memory barrier in which other reads & writes are not allowed to move after the fence.

So if we mark instance as volatile then the release-fence will prevent the above optimization. Here is how the code would look with the barrier annotations. I used an ↑ arrow to indicate a release-fence and a ↓ arrow to indicate an acquire-fence. Notice that nothing is allowed to float down past an ↑ arrow or up past an ↓ arrow. Think of the arrow head as pushing everything away.

var local = instance;
↓ // volatile read barrier
if (local == null)
{
  var lockread = padlock;
  ↑ // lock full barrier
  lock (lockread)
  ↓ // lock full barrier
  {
    local = instance;
    ↓ // volatile read barrier
    if (local == null)
    {
      var ref = alloc Foo;
      ref.variable1 = 1; // inlined ctor
      ref.variable2 = 2; // inlined ctor
      ↑ // volatile write barrier
      instance = ref;
    }
  ↑ // lock full barrier
  }
  ↓ // lock full barrier
}
local = instance;
↓ // volatile read barrier
return local;

The writes to the constituent variables of Foo could still be reordered, but notice that the memory barrier now prevents them from occurring after the assignment to instance. Using the arrows as a guide imagine various different optimization strategies that are allowed and disallowed. Remember that no reads or writes are allowed to float down past an ↑ arrow or up past an ↓ arrow.

Thread.VolatileWrite would have solved this problem as well and could be used in languages without a volatile keyword like VB.NET. If you take a look at how VolatileWrite is implemented you would see this.

public static void VolatileWrite(ref object address, object value)
{
  Thread.MemoryBarrier();
  address = value;
}

Now this may seem counter intuitive at first. Afterall, the memory barrier is placed before the assignment. What about getting the assignment committed to main memory you ask? Would it not be more correct to place the barrier after the assignment? If that is what your intuition is telling you then it is wrong. You see memory barriers are not strictly about getting a "fresh read" or a "committed write". It is all about instruction ordering. This is by far the biggest source of confusion I see.

It might also be important to mention that Thread.MemoryBarrier actually generates a full-fence barrier. So if I were to use my notation above with the arrows then it would look like this.

public static void VolatileWrite(ref object address, object value)
{
  ↑ // full barrier
  ↓ // full barrier
  address = value;
}

So technically calling VolatileWrite does more than what a write to a volatile field would do. Remember that volatile is not allowed in VB.NET for example, but VolatileWrite is apart of the BCL so it can be used in other languages.


1This optimization is mostly theoretical. The ECMA specification does technically allow for it, but the Microsoft CLI implementation of the ECMA specification treats all writes as if they had release fence semantics already. It is possible that another implementation of the CLI could still perform this optimization though.




回答2:


Bill Pugh has written several articles on the subject, and is a reference on the topic.

A notable reference is, The "Double-Checked Locking is Broken" Declaration.

Roughly speaking, here is the problem:

In mutlicore VM, writes by a thread might not be visible to other thread until a synchronization barrier (or memory fences) is reached. You can read "Memory Barriers: a Hardware View for Software Hackers" it's a really good article on the matter.

So, if a thread initializes an object A with one field a, and stores the reference of the object in the field ref of another object B, we have two "cells" in memory: a, and ref. Changes to both memory locations might not become visible to other threads at the same time unless the threads forces the visiblity of the changes with a memory fence.

In java, synchronization can be forced with synchronized. This is expensive, and an alternative it to declare a field as volatile in which case the change to this cell is always visible to all threads.

BUT, the semantics of volatile change between Java 4 and 5. In Java 4, you need to define both a, and ref as volatile, for the doulbe check to work in the example I described.

It was not intuitive, and most people would only set ref as volatile. So they change this and in Java 5+, if a volatile field is modified (ref) it triggers the synchronization of other fields modified (a).

EDIT: I only see now that you ask for C#, not Java... I leave my answer because maybe it's useful nevertheless.



来源:https://stackoverflow.com/questions/10281044/singleton-double-check-concurrency-issue

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!