25

An article in MSDN Magazine discusses the notion of Read Introduction and gives a code sample which can be broken by it.

public class ReadIntro {
  private Object _obj = new Object();
  void PrintObj() {
    Object obj = _obj;
    if (obj != null) {
      Console.WriteLine(obj.ToString()); // May throw a NullReferenceException
    }
  }
  void Uninitialize() {
    _obj = null;
  }
}

Notice this "May throw a NullReferenceException" comment - I never knew this was possible.

So my question is: how can I protect against read introduction?

I would also be really grateful for an explanation exactly when the compiler decides to introduce reads, because the article doesn't include it.

Gebb
  • 6,371
  • 3
  • 44
  • 56
  • 6
    A JIT compiler changing code semantics, worries me. Perhaps it was a JIT bug before .NET 4.5. – leppie Feb 10 '13 at 16:38
  • 2
    Yup, this looks definitely like a JIT bug. In fact, I’d classify it as a bug no matter what. The article is a bit useless – it mentions the concept but doesn’t really say how it occurs and how to guard against it. – Konrad Rudolph Feb 10 '13 at 16:41
  • The read introduction part doesn't mention multi-threading explicitely but the intro says "the compiler and the hardware may subtly transform the memory operations of a program in ways that don’t affect single-threaded behavior, but can impact multi-threaded behavior.". Use a lock would be the answer to your question in that case. – rene Feb 10 '13 at 16:45
  • @leppie I believe JIT is free to change the semantics in way that won't modify the behavior of a single-threaded program, but may affect multi-threaded one. – svick Feb 10 '13 at 16:47
  • 1
    @svick: Even with multithreading, this should never happen. No other threads can affect a local variable. – leppie Feb 10 '13 at 16:50
  • I don't have a devenv here right now, but I think you can make a constructor volatile. Volatile means it will be compiled as is. – Robert Feb 10 '13 at 17:27
  • 1
    Just another reminder that you should *never* write multi-threaded code without protecting shared variables with a lock. – Hans Passant Feb 10 '13 at 17:40
  • @HansPassant: I would like to see someone actually simulate a `NullReferenceException` here. Any way they like. – leppie Feb 10 '13 at 17:41
  • @leppie From the linked article: “Note that you won’t be able to reproduce the NullReferenceException using this code sample in the .NET Framework 4.5 on x86-x64. Read introduction is very difficult to reproduce in the .NET Framework 4.5, but it does nevertheless occur in certain special circumstances.” – svick Feb 10 '13 at 17:42
  • @svick: Can you repro it in any way you like using .NET 2? (I am interested in this) – leppie Feb 10 '13 at 17:43
  • Offer: US$10 via Paypal if anyone can repro this. – leppie Feb 10 '13 at 17:46
  • @Hans: There is no shared variable. The code uses a local variable. Code equivalent to this has been used since the first version of .NET to avoid race conditions when raising events, throughout the whole .NET code base, the MSDN and knowledge base articles. All this code would be broken. – Konrad Rudolph Feb 10 '13 at 17:52
  • 3
    @Konrad - The _obj variable is a field, not a local variable, that's the one getting whacked. Access to the backing field of an event is protected with a lock in .NET < 4, Interlocked in .NET 4+. – Hans Passant Feb 10 '13 at 18:04
  • 2
    @Hans No code I’ve ever seen published on MSDN etc. has used locked access to an event backing field. The idiom for this is exactly the code used by OP: make a copy of the field to a local variable. – Konrad Rudolph Feb 10 '13 at 18:11
  • @Konrad - MSDN rarely focuses on thread-safety. Decompile the auto-generated add and remove accessor methods for insight. – Hans Passant Feb 10 '13 at 18:36
  • 1
    @Hans I wasn’t talking about auto-generated `add` and `remove` but about *raising* events in the `OnXZY` base class methods. – Konrad Rudolph Feb 10 '13 at 18:40
  • 2
    @KonradRudolph `Specifically, all of Microsoft’s JIT compilers respect the invariant of not introducing new reads to heap memory and therefore, caching a reference in a local variable ensures that the heap reference is accessed only once. This is not documented and, in theory, it could change, which is why you should use the fourth version. But in reality, Microsoft’s JIT compiler would never embrace a change that would break this pattern because too many applications would break. This was actually told to me by a member of Microsoft’s JIT compiler team.` CLR via C# Third edition page 265. – Hamlet Hakobyan Feb 10 '13 at 18:58

3 Answers3

19

Let me try to clarify this complicated question by breaking it down.

What is "read introduction"?

"Read introduction" is an optimization whereby the code:

public static Foo foo; // I can be changed on another thread!
void DoBar() {
  Foo fooLocal = foo;
  if (fooLocal != null) fooLocal.Bar();
}

is optimized by eliminating the local variable. The compiler can reason that if there is only one thread then foo and fooLocal are the same thing. The compiler is explicitly permitted to make any optimization that would be invisible on a single thread, even if it becomes visible in a multithreaded scenario. The compiler is therefore permitted to rewrite this as:

void DoBar() {
  if (foo != null) foo.Bar();
}

And now there is a race condition. If foo turns from non-null to null after the check then it is possible that foo is read a second time, and the second time it could be null, which would then crash. From the perspective of the person diagnosing the crash dump this would be completely mysterious.

Can this actually happen?

As the article you linked to called out:

Note that you won’t be able to reproduce the NullReferenceException using this code sample in the .NET Framework 4.5 on x86-x64. Read introduction is very difficult to reproduce in the .NET Framework 4.5, but it does nevertheless occur in certain special circumstances.

x86/x64 chips have a "strong" memory model and the jit compilers are not aggressive in this area; they will not do this optimization.

If you happen to be running your code on a weak memory model processor, like an ARM chip, then all bets are off.

When you say "the compiler" which compiler do you mean?

I mean the jit compiler. The C# compiler never introduces reads in this manner. (It is permitted to, but in practice it never does.)

Isn't it a bad practice to be sharing memory between threads without memory barriers?

Yes. Something should be done here to introduce a memory barrier because the value of foo could already be a stale cached value in the processor cache. My preference for introducing a memory barrier is to use a lock. You could also make the field volatile, or use VolatileRead, or use one of the Interlocked methods. All of those introduce a memory barrier. (volatile introduces only a "half fence" FYI.)

Just because there's a memory barrier does not necessarily mean that read introduction optimizations are not performed. However, the jitter is far less aggressive about pursuing optimizations that affect code that contains a memory barrier.

Are there other dangers to this pattern?

Sure! Let's suppose there are no read introductions. You still have a race condition. What if another thread sets foo to null after the check, and also modifies global state that Bar is going to consume? Now you have two threads, one of which believes that foo is not null and the global state is OK for a call to Bar, and another thread which believes the opposite, and you're running Bar. This is a recipe for disaster.

So what's the best practice here?

First, do not share memory across threads. This whole idea that there are two threads of control inside the main line of your program is just crazy to begin with. It never should have been a thing in the first place. Use threads as lightweight processes; give them an independent task to perform that does not interact with the memory of the main line of the program at all, and just use them to farm out computationally intensive work.

Second, if you are going to share memory across threads then use locks to serialize access to that memory. Locks are cheap if they are not contended, and if you have contention, then fix that problem. Low-lock and no-lock solutions are notoriously difficult to get right.

Third, if you are going to share memory across threads then every single method you call that involves that shared memory must either be robust in the face of race conditions, or the races must be eliminated. That is a heavy burden to bear, and that is why you shouldn't go there in the first place.

My point is: read introductions are scary but frankly they are the least of your worries if you are writing code that blithely shares memory across threads. There are a thousand and one other things to worry about first.

Eric Lippert
  • 647,829
  • 179
  • 1,238
  • 2,067
  • So, today, this can only happen on ARM? – leppie Feb 11 '13 at 16:41
  • 1
    Thank you for this great answer! I would still argue that there are situations where read introduction is the only concern. See for example the method `WaitWhilePausedAsync` in Stephen Toub's [implementation](http://blogs.msdn.com/b/pfxteam/archive/2013/01/13/cooperatively-pausing-async-methods.aspx) of cooperative pausing. – Gebb Feb 11 '13 at 17:14
  • 1
    @Gebb: Your point is well taken. This illustrates that it is a good reason to leave the production of threading primitives to people like Stephen Toub and Joe Duffy! Don't try to write your own primitives; raise the level of abstraction by using types like `Lazy` or `Task` that were written by people who understand this stuff. – Eric Lippert Feb 11 '13 at 17:42
  • 3
    *"My point is: read introductions are scary but frankly they are the least of your worries if you are writing code that blithely shares memory across threads."* -- Allow me to dig this topic and disagree. Read introduction on fields is not a sane optimization, it can break the semantics of a single thread's execution. If you assign the value of a field to a local variable, the thread **must not** observe changes in subsequent reads to the variable, up to the next write to it, if any. The C# compiler cannot do it, the JIT compiler should not do it with the current (N)UMA architectures. – acelent Dec 16 '16 at 12:39
  • I wish I could upvote acelent's comment 1000 times. I can understand the compiler eliminating non-aliased local variables in favor of other non-aliased local variables (which may be enregistered or on the stack, i.e. in memory) but eliminating a local that is initialized with a read from other memory completely breaks expectations about locals. Shouldn't locals have the same value between assignments, regardless of what happens anywhere else - including other threads? With read introduction, this, too, can technically throw: `var foo = m_foo ; if (foo != foo) throw new Oops () ;` – Anton Tykhyy Mar 28 '19 at 11:38
  • 1
    @acelent I do not see why introduced reads would break single-threaded code. The only way a thread could observe changes to the variable is if another thread introduced those changes. This is, by definition, not single-threaded code anymore. It's a sane optimization, assuming you are given the tools to fight it, like Volatile.Read. This answer puts those tools in question, though. *That's* insane IMO. – relatively_random Oct 26 '20 at 07:49
  • @relatively_random, there's a guarantee that volatile reads are fresh, but there's no guarantee that non-volatile reads are stale. As such, introduced reads to shared data (i.e. in the heap or accessible from another thread in any way) *may* fetch different values, which is not sane. For instance, if you replace accesses to a local variable that was initialized with a field's value (`var v = field; if (v != null) v.method();`) with accesses to that field (`if (field != null) field.method();`), then the first read may yield non-null and the next may yield null. That's insane. – acelent Oct 28 '20 at 22:45
  • For further information on the subject, you may search for data-race-free (DRF) guarantee (or model), and you'll find a number of papers by the best in the field on how some language specifications, among them C++, C# and old versions of Java, allow compilers to generate surprising data races if taken by the word, due to allowed introduced reads, or redundant reads. – acelent Oct 28 '20 at 23:11
  • @acelent I understand what introduced reads are. That's not my point. My point is that they only cause problems in multithreaded scenarios, not in singlethreaded code. And it may be surprising, but it's not fundamentally broken as long as there are ways to write safe multithreaded code. In fact, they are only a problem if you write low-lock multithreaded code, and the rule is you should not dabble in that anyway -- unless you are confident you understand all the subtle guarantees and non-guarantees of your memory model. – relatively_random Oct 29 '20 at 09:41
  • @acelent Regarding your example, I'd agree it would be insane if the compiler was allowed to make the transformation if you used `var v = Volatile.Read (ref field);`. If you just say `var v = field;`, the compiler should be free to assume that the field is not shared between threads and remove the local. Introducing extra reads in that case is perfectly fine. – relatively_random Oct 29 '20 at 12:28
  • 1
    @relatively_random, the thing is, unless that field lives in a register, or the stack **and** is not shared nor it has the chance to be shared (i.e. the object is never an argument), then the compiler can't assume it's not shared. For instance, anything can touch the heap: a GC thread, a debugger, a profiler, a CLR runtime host, a low-level heap iterator, etc. I also understand what you're saying, but taking the single-threaded paradigm of optimization disregarding potential data races is never what you want, especially so when repeated reads are rarely, if ever, faster than anything else. – acelent Oct 29 '20 at 17:41
8

You cant really "protect" against read introduction as it's a compiler optimization (excepting using Debug builds with no optimization of course). It's pretty well documented that the optimizer will maintain the single-threaded semantics of the function, which as the article notes can cause issues in multi-threaded situations.

That said, I'm confused by his example. In Jeffrey Richter's book CLR via C# (v3 in this case), in the Events section he covers this pattern, and notes that in the example snippet you have above, in THEORY it wouldn't work. But, it was a recommended pattern by Microsoft early in .Net's existence, and therefore the JIT compiler people he spoke to said that they would have to make sure that sort of snippet never breaks. (It's always possible they may decide that it's worth breaking for some reason though - I imagine Eric Lippert could shed light on that).

Finally, unlike the article, Jeffrey offers the "proper" way to handle this in multi-threaded situations (I've modified his example with your sample code):

Object temp = Interlocked.CompareExchange(ref _obj, null, null);
if(temp != null)
{
    Console.WriteLine(temp.ToString());
}
Gjeltema
  • 4,122
  • 2
  • 24
  • 31
  • Just to get this straight: the only reason for using `CompareExchange` here is to get a memory fence around the read of `_obj` to prevent the compiler from doing its shenanigans, right? – Konrad Rudolph Feb 10 '13 at 17:54
  • Correct. Ideally you would use Interlocked.VolatileRead, but it doesn't have a generic version, so CompareExchange is the better solution. It's also a better solution than declaring _obj as volatile, as you should avoid volatile for performance reasons unless you need to have it "safe" everywhere it's used, which is rare.. – Gjeltema Feb 10 '13 at 17:57
  • It feels excessive to implement the 'proper' way for this hypothetical case. If I ever run into a JIT performing this 'read introduction' and code breaks.. it's going to be A LOT more than my code and I'm going to dust my hands and walk away.. – user2864740 Oct 17 '18 at 05:26
  • Would Volatile.Read instead of Interlocked.CompareExchange work in 2022? – XanderMK Jan 02 '22 at 11:54
1

I only skimmed the article, but it seems that what the author is looking for is that you need to declare the _obj member as volatile.

erikkallen
  • 33,800
  • 13
  • 85
  • 120
  • My first thought was also that surely with `private volatile Object _obj` this could not fail. But maybe that intuition of mine is wrong. – Jeppe Stig Nielsen Feb 10 '13 at 20:44
  • If you're worrying about that, check out this question: http://stackoverflow.com/questions/394898/double-checked-locking-in-net – erikkallen Feb 10 '13 at 21:31
  • @erikkallen: Stephen Toub seems to confirm your hypothesis. At least I got this impression from this [conversation](http://blogs.msdn.com/b/pfxteam/archive/2013/01/13/cooperatively-pausing-async-methods.aspx) with him. – Gebb Feb 11 '13 at 16:17