Is it possible to observe a partially-constructed

2019-01-13 18:29发布

问题:

I've often heard that in the .NET 2.0 memory model, writes always use release fences. Is this true? Does this mean that even without explicit memory-barriers or locks, it is impossible to observe a partially-constructed object (considering reference-types only) on a thread different from the one on which it is created? I'm obviously excluding cases where the constructor leaks the this reference.

For example, let's say we had the immutable reference type:

public class Person
{
    public string Name { get; private set; }
    public int Age { get; private set; }

    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

Would it be possible with the following code to observe any output other than "John 20" and "Jack 21", say "null 20" or "Jack 0" ?

// We could make this volatile to freshen the read, but I don't want
// to complicate the core of the question.
private Person person;

private void Thread1()
{
    while (true)
    {
        var personCopy = person;

        if (personCopy != null)
            Console.WriteLine(personCopy.Name + " " + personCopy.Age);
    }
}

private void Thread2()
{
    var random = new Random();

    while (true)
    {
        person = random.Next(2) == 0
            ? new Person("John", 20)
            : new Person("Jack", 21);
    }
}

Does this also mean that I can make all shared fields of deeply-immutable reference-types volatile and (in most cases) just get on with my work?

回答1:

I've often heard that in the .NET 2.0 memory model, writes always use release fences. Is this true?

It depends on what model you are referring to.

First, let us precisely define a release-fence barrier. Release semantics stipulate that no other read or write appearing before the barrier in the instruction sequence is allowed to move after that barrier.

  • The ECMA specification has a relaxed model in which writes do not provide this guarantee.
  • It has been cited somewhere that the CLR implementation provided by Microsoft strengthens the model by making writes have release-fence semantics.
  • The x86 and x64 architectures strengthen the model by making writes release-fence barriers and reads acquire-fence barriers.

So it is possible that another implementation of the CLI (such as Mono) running on an esoteric architecture (like ARM which Windows 8 will now target) would not provide release-fence semantics on writes. Notice that I said it is possible, but not certain. But, between all of the memory models in play, such as the different software and hardware layers, you have to code for the weakest model if you want your code to be truly portable. That means coding against the ECMA model and not making any assumptions.

We should make a list of the memory model layers in play just be explicit.

  • Compiler: The C# (or VB.NET or whatever) can move instructions.
  • Runtime: Obviously the CLI runtime via the JIT compiler can move instructions.
  • Hardware: And of course the CPU and memory architecture comes into play as well.

Does this mean that even without explicit memory-barriers or locks, it is impossible to observe a partially-constructed object (considering reference-types only) on a thread different from the one on which it is created?

Yes (qualified): If the environment in which the application is running is obscure enough then it might be possible for a partially constructed instance to be observed from another thread. This is one reason why double-checked locking pattern would be unsafe without using volatile. In reality, however, I doubt you would ever run into this mostly because Microsoft's implementation of the CLI will not reorder instructions in this manner.

Would it be possible with the following code to observe any output other than "John 20" and "Jack 21", say "null 20" or "Jack 0" ?

Again, that is qualified yes. But for the some reason as above I doubt you will ever observe such behavior.

Though, I should point out that because person is not marked as volatile it could be possible that nothing is printed at all because the reading thread may always see it as null. In reality, however, I bet that Console.WriteLine call will cause the C# and JIT compilers to avoid the lifting operation that might otherwise move the read of person outside the loop. I suspect you are already well aware of this nuance already.

Does this also mean that I can just make all shared fields of deeply-immutable reference-types volatile and (in most cases) get on with my work?

I do not know. That is a pretty loaded question. I am not comfortable answering either way without a better understanding of the context behind it. What I can say is that I typically avoid using volatile in favor of more explicit memory instructions such as the Interlocked operations, Thread.VolatileRead, Thread.VolatileWrite, and Thread.MemoryBarrier. Then again, I also try to avoid no-lock code altogether in favor of the higher level synchronization mechanisms such as lock.

Update:

One way I like visualize things is to assume that the C# compiler, JITer, etc. will optimize as aggressively as possible. That means that Person.ctor might be a candidate for inlining (since it is simple) which would yield the following pseudocode.

Person ref = allocate space for Person
ref.Name = name;
ref.Age = age;
person = instance;
DoSomething(person);

And because writes have no release-fence semantics in the ECMA specification then the other reads & writes could "float" down past the assignment to person yielding the following valid sequence of instructions.

Person ref = allocate space for Person
person = ref;
person.Name = name;
person.Age = age;
DoSomething(person);

So in this case you can see that person gets assigned before it is initialized. This is valid because from the perspective of the executing thread the logical sequence remains consistent with the physical sequence. There are no unintended side-effects. But, for reasons that should be obvious, this sequence would be disastrous to another thread.



回答2:

You have no hope. Replace your console write with an error check, set a dozen copies of Thread1() going, use a machine with 4 cores, and you are bound to find a few partially constructed Person instances. Use the guaranteed techniques mentioned in other answers and comments to keep your program safe.

Both the people who write compilers and the people who create CPU's are, in their quest for more speed, conspiring to make the situation worse. Without explicit instructions to do otherwise, the compiler people will reorder your code any way they can to save a nanosecond. The CPU people are doing the same. Last I read, a single core tends to run 4 instructions simultaneously, if it can. (And maybe even if it can't.)

Under normal circumstances, you will rarely have a problem with this. I have found, however, that a minor problem that only appears once every 6 months can be a truely major problem. And, interestingly, a one-time-in-a-billion problem can happen several times a minute--which is much preferable. I'm guessing your code will fall in the latter category.



回答3:

Well, at least at the IL level, the constructor is called directly on the stack, with the resulting reference not generated (and able to be stored) until after construction is complete. As such, it can't be reordered at the (IL) compiler level (for reference types.)

As for the jitter level, I'm not sure, though it would surprise me if it reordered a field assignment and a method call (which is what a constructor is.) Would the compiler really look at the method and all its possible execution paths to make sure the field is never used by the called method?

Likewise at the CPU level, I would be surprised if reordering would occur around a jump instruction, since the CPU has no way of knowing whether a branch is a 'subroutine call' and will therefore return to the next instruction. To execute out-of-order would allow for grossly incorrect behavior in the event of 'unconventional' jumps.