12

EDIT: I am asking what happens when two threads concurrently access the same data without proper synchronization (before this edit, that point was not expressed clearly).

I have a question about the optimizations that are performed by the C# compiler and by the JIT compiler.

Consider the following simplified example:

class Example {
    private Action _action;

    private void InvokeAction() {
        var local = this._action;
        if (local != null) {
            local();
        }
    }
}

Please ignore in the example that reading _action might yield a cached and outdated value as there is no volatile specifier nor any other sychronization. That is not the point :)

Is the compiler (or actually the jitter at runtime) allowed to optimize the assignment to the local variable out and instead reading _action from memory twice:

class Example {
    private Action _action;

    private void InvokeAction() {
        if (this._action != null) {
            this._action(); // might be set to null by an other thread.
        }
    }
}

which might throw a NullReferenceException when the field _action is set to null by a concurrent assignment.

Of course in this example such an "optimization" would not make any sense because it would be faster to store the value in a register and thus using the local variable. But in more complex cases, is there a guarantee that this works as expected without re-reading the value from memory?

thaller
  • 1,258
  • 13
  • 21
  • 2
    The optimizer is not allowed to change the semantics of a statement. – Hans Passant Oct 05 '11 at 16:28
  • 5
    I think that the second example wouldn't change the semantics of the statement as seen from one single thread(!). Furthermore assuming that the memory does not change concurrently (which in this case is a valid assumption the optimizer could make IMHO). – thaller Oct 06 '11 at 07:20
  • I recommend adding the [lock-free] and [language-lawyer] tags. – jrh Jan 22 '18 at 14:28

3 Answers3

8

I will say (partially) the opposite of mgronber :-) Aaaah... In the end I'm saying the same things... Only that I'm quoting an article :-( I'll give him a +1.

It's a LEGAL optimization under the ECMA specifications, but it's an illegal optimization under the .NET >= 2.0 "specifications".

From the Understand the Impact of Low-Lock Techniques in Multithreaded Apps

Read here Strong Model 2: .NET Framework 2.0

Point 2:

Reads and writes cannot be introduced.

The explanation below:

The model does not allow reads to be introduced, however, because this would imply refetching a value from memory, and in low-lock code memory could be changing.

BUT note under in the same page, under Technique 1: Avoiding Locks on Some Reads

In systems using the ECMA model, there is an additional subtlety. Even if only one memory location is fetched into a local variable and that local is used multiple times, each use might have a different value! This is because the ECMA model lets the compiler eliminate the local variable and re-fetch the location on each use. If updates are happening concurrently, each fetch could have a different value. This behavior can be suppressed with volatile declarations, but the issue is easy to miss.

If you are writing under Mono, you should be advised that at least until 2008 it was working on the ECMA memory model (or so they wrote in their mailing list)

xanatos
  • 109,618
  • 12
  • 197
  • 280
  • I think mgronber is talking about the ECMA model, you're talking about the .net model. – CodesInChaos Oct 07 '11 at 13:41
  • @CodeInChaos Aaaaaah... If it's true what I've read around, Mono is still using ECMA memory model, or more probably it's using a "what happens happens" memory model! :-( – xanatos Oct 07 '11 at 14:04
  • @xanatos, thank you. Now this issue is a lot clearer to me. Expecially the article you cite is very informative. It shows again how these low-lock patterns are easy to get wrong. – thaller Oct 09 '11 at 17:31
2

It is legal optimization according to the memory model defined in the ECMA specification. If the _action were volatile, memory model would guarantee that the value is read only once and so this optimization could not happen.

However, I think that current Microsoft's CLR implementations do not optimize local variables away.

mgronber
  • 3,399
  • 16
  • 20
  • 3
    CLR via C#, page 264-265, backs this up, both with different code examples and discussions about them. It also states the "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". – sisve Oct 05 '11 at 17:16
  • @mgronber This isn't a very satisfying answer :-) but I guess you are right. Thank you – thaller Oct 06 '11 at 07:23
  • @Simon I looked it up in CLRviaC# and you are right. Richter talks exactly about this problem. Originally I was not worried about that simple example with the `_action` above, but rather the Interlocked Anything Pattern (as Richter calls it). There you also read a value once without volatile/Interlocked, and use it to initialize `currentVal` and at the same time to determine `desiredVal`. I worry that the same problem applies there, because you read the memory once (assigning it to a local variable). However, it the optimizer instead would read `target` twice it could fail. Thank you. – thaller Oct 06 '11 at 07:27
  • @thaller, I am pretty sure that there should be a memory barrier after the `currentVal` is read from `target`. Otherwise the memory model permits the value to be read twice. – mgronber Oct 06 '11 at 10:31
  • @mgronber, I agree and that is what I use now. I guess CLR via C# shows the version without memory barrier, because Jeffrey Richter has it on good authority that the JIT-guys at Microsoft would not introduce such an optimization (as he says). Nonetheless I just prefer to be as safe as humanly possible. – thaller Oct 06 '11 at 14:23
  • I don't believe this is *actually* valid according to the ECMA specification. I know people have claimed it to be - in particular, a widely-quoted MSDN article - but I don't believe it's actually the case. Unfortunately the ECMA spec isn't as clear as it might be on this front :( – Jon Skeet Aug 05 '15 at 12:52
0

With C# 7, you should write the example as follows, and in fact the IDE will suggest it as a 'simplification' for you. The compiler will generate code that uses a temporary local to only read the location of _action from main memory a single time (regardless of it being null or not), and this helps prevent the common race shown the OP's second example, i.e., where _action is accessed twice, and can be set to null by another thread inbetween.

class Example
{
    private Action _action;

    private void InvokeAction()
    {
        this._action?.Invoke();
    }
}
Glenn Slayden
  • 17,543
  • 3
  • 114
  • 108
  • Define "threadsafe". I can think of numerous threading problems which are not protected by this pattern. (1) If the variable is not volatile then no barrier is introduced; the value could be stale. (2) If the variable is a nullable value type rather than an action then the read can be torn. (3) Suppose the current value of action depends on global state. The current value is read before the invoke, and then on another thread action is mutated and the global state is destroyed. The invocation then crashes. No synchronization primitive ensures that this cannot happen. – Eric Lippert Nov 28 '17 at 06:43
  • 1
    The conditional access operator isn't magical; the code generated is just the same as though you'd used an explicit local. It's perfectly reasonable to use the operator if you're worried that the value might be null, but don't be fooled into thinking that it magically makes your program any more "threadsafe" than it was before. It's just a cheap little sugar. – Eric Lippert Nov 28 '17 at 06:45
  • Thanks for the comment; I didn't mean to be exhaustive on concurrency here; I've dialed back my claims. On the other hand, you weaken your note a bit with point (3). If as you say, it *can't* be fixed; it seems ok not to mention problems which can never be solved. Anyway, I was simplifying to evangelize for any beginners who don't automatically recognize the most common races, since the OP question befit that level. I've updated my post, but I disagree on your last point. Sufficient sugar makes substance, and the memory model implication I mentioned about it tangibly accrues to correctness. – Glenn Slayden Nov 28 '17 at 09:20