5

I tried to take a look at the disassembly of the code posted in this old question, and I found something odd.

Here's the source code, for the sake of clarity:

class ThreadTest
{
    static void Main(string[] args)
    {
        for (int i = 0; i < 10; i++)
            new Thread(() => Console.WriteLine(i)).Start();
    }
}

(Of course the behaviour of this program is unexpected, that is not the question here.)

Here's what I saw looking at the disassembly:

internal class ThreadTest
{
    private static void Main(string[] args)
    {
        int i;
        int j;
        for (i = 0; i < 10; i = j + 1)
        {
            new Thread(delegate
            {
                Console.WriteLine(i);
            }).Start();
            j = i;
        }
    }
}

What is j doing there? Here's the bytecode:

.method private hidebysig static 
    void Main (
        string[] args
    ) cil managed 
{
    // Method begins at RVA 0x2050
    // Code size 64 (0x40)
    .maxstack 2
    .entrypoint
    .locals init (
        [0] class ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0' 'CS$<>8__locals0',
        [1] int32
    )

    IL_0000: newobj instance void ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::.ctor()
    IL_0005: stloc.0
    IL_0006: ldloc.0
    IL_0007: ldc.i4.0
    IL_0008: stfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i
    IL_000d: br.s IL_0035
    // loop start (head: IL_0035)
        IL_000f: ldloc.0
        IL_0010: ldftn instance void ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::'<Main>b__0'()
        IL_0016: newobj instance void [mscorlib]System.Threading.ThreadStart::.ctor(object, native int)
        IL_001b: newobj instance void [mscorlib]System.Threading.Thread::.ctor(class [mscorlib]System.Threading.ThreadStart)
        IL_0020: call instance void [mscorlib]System.Threading.Thread::Start()
        IL_0025: ldloc.0
        IL_0026: ldfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i
        IL_002b: ldc.i4.1
        IL_002c: add
        IL_002d: stloc.1
        IL_002e: ldloc.0
        IL_002f: ldloc.1
        IL_0030: stfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i

        IL_0035: ldloc.0
        IL_0036: ldfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i
        IL_003b: ldc.i4.s 10
        IL_003d: blt.s IL_000f
    // end loop

    IL_003f: ret
} // end of method ThreadTest::Main

But here's the weirdest thing. If I change the original code like this, replacing i++ with i = i + 1:

class ThreadTest
{
    static void Main(string[] args)
    {
        for (int i = 0; i < 10; i = i + 1)
            new Thread(() => Console.WriteLine(i)).Start();
    }
}

I get this:

internal class ThreadTest
{
    private static void Main(string[] args)
    {
        int i;
        for (i = 0; i < 10; i++)
        {
            new Thread(delegate
            {
                Console.WriteLine(i);
            }).Start();
        }
    }
}

Which is exactly what I expected.

Here's the bytecode:

.method private hidebysig static 
    void Main (
        string[] args
    ) cil managed 
{
    // Method begins at RVA 0x2050
    // Code size 62 (0x3e)
    .maxstack 3
    .entrypoint
    .locals init (
        [0] class ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0' 'CS$<>8__locals0'
    )

    IL_0000: newobj instance void ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::.ctor()
    IL_0005: stloc.0
    IL_0006: ldloc.0
    IL_0007: ldc.i4.0
    IL_0008: stfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i
    IL_000d: br.s IL_0033
    // loop start (head: IL_0033)
        IL_000f: ldloc.0
        IL_0010: ldftn instance void ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::'<Main>b__0'()
        IL_0016: newobj instance void [mscorlib]System.Threading.ThreadStart::.ctor(object, native int)
        IL_001b: newobj instance void [mscorlib]System.Threading.Thread::.ctor(class [mscorlib]System.Threading.ThreadStart)
        IL_0020: call instance void [mscorlib]System.Threading.Thread::Start()
        IL_0025: ldloc.0
        IL_0026: ldloc.0
        IL_0027: ldfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i
        IL_002c: ldc.i4.1
        IL_002d: add
        IL_002e: stfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i

        IL_0033: ldloc.0
        IL_0034: ldfld int32 ConsoleApplication2.ThreadTest/'<>c__DisplayClass0_0'::i
        IL_0039: ldc.i4.s 10
        IL_003b: blt.s IL_000f
    // end loop

    IL_003d: ret
} // end of method ThreadTest::Main

Why did the compiler add j in the first scenario?

Note: I'm using VS 2015 Update 3, .NET Framework 4.5.2, compiling in Release mode.

themiurge
  • 1,619
  • 17
  • 21
  • Do the same for `for (i = 0; i < 10; ++i)` – wake-0 Jul 13 '17 at 09:13
  • How did you `disassemble` the code? – mjwills Jul 13 '17 at 09:15
  • @mjwills: I used ILSpy. – themiurge Jul 13 '17 at 09:15
  • 1
    Try `++i` instead of `i++` and see what happens. `++i` is much like `i = i + 1`, where `i++` is defined to increment `i` but return the value it had _before_ incrementing. Of course since you're discarding the result of `i++` or `i = i + 1`, it makes no difference except for extra generated IL code (which will probably be removed by the JIT compiler), but if you were using the result of this expression they would be two different things. – Michael Geary Jul 13 '17 at 09:21
  • What makes you think `Why did the compiler add j in the first scenario?`? The compiler generates IL. How are you sure the compiler is making the 'error' and not ILSpy? – mjwills Jul 13 '17 at 09:27
  • @mjwills It's not an error, is it? The code is not incorrect, it just isn't optimized. It appears that the C# compiler is taking `i++` literally as the post-increment operator, so it generates code that goes to the effort of saving the value before the increment - even though that value will end up being discarded. It leaves it up to the JIT compiler to clean that up and optimize it. OTOH you could be right and ILSpy is doing something funny! Looking at the bytecode directly instead of decompiling it would reveal what is really happening. – Michael Geary Jul 13 '17 at 09:31
  • @MichaelGeary: I added the bytecode. `j` is there (local variable 1). – themiurge Jul 13 '17 at 10:07
  • @mjwills: Sorry, it got a little mixed up during the edit. Fixed now. – themiurge Jul 13 '17 at 10:15
  • Can you remind me what your question is? Are you asking why ILSpy decompiled to different code? Are you asking why your two code samples generated different IL? Or something else? – mjwills Jul 13 '17 at 10:19
  • @mjwills: I now know that I'm asking why the two code samples generate different IL, and thanks to you guys I now know the answer. I basically didn't know about JIT optimization, I thought the compiler did that. I couldn't phrase it this way when I asked it, I didn't have the necessary knowledge. Sorry! Thanks for your patience guys. – themiurge Jul 13 '17 at 10:30
  • Awesome - thanks @themiurge . This stuff is definitely hard to get your head around for sure! – mjwills Jul 13 '17 at 10:31

2 Answers2

4

Because semantically, when you write i++, the compiler is required to preserve the original value of i so it can be used as the resulting value of the expression.

The compiler implements this by introducing a new variable in which the new value can be kept until the old value from i is used, if necessary. Thus, the old value i is still available to be read, until the updated j value is copied into i. Of course, in this case that happens immediately after copying the result of the add instruction to j, since no code did in fact need that value. But, for a moment i's value remained the old one, there to be used if it had been needed.

You might argue:

But, I never use that value. Why does the compiler keep it? Why not just write the result of the add directly into i instead of storing it in j first?

The C# compiler is not in charge of optimizations. Its primary job is to translate C# code into IL. In fact, I would say that part of this job is to not work very hard to optimize things, but instead to follow common patterns of implementation, to make things easier on the JIT compiler, which is in charge of optimizations.

By not including logic to optimize this sort of degenerate scenario, it is easier to make sure that the C# compiler is generating correct IL, and doing so in a predictable, easier-to-optimize way.

Peter Duniho
  • 68,759
  • 7
  • 102
  • 136
  • "The C# compiler is not in charge of optimizations". Coming from a C++ background, that's exactly the part I was missing. Thanks. – themiurge Jul 14 '17 at 03:37
0

i++ is not exactly i = i + 1 because you can also do this:

Try this code:

int i = 1;
int x = 5 + i++;
Console.WriteLine("i:" + i + " x: " + x);
i = 1;
int y = 5 + ++i;
Console.WriteLine("i:" + i + " y: " + y);

Output:

i:2 x: 6
i:2 y: 7

This has to do with prefix and postfix increment/decrement (see How do Prefix (++x) and Postfix (x++) operations work?).

wake-0
  • 3,918
  • 5
  • 28
  • 45
  • I know how prefix/postfix increment works, that is clearly not the question here. – themiurge Jul 13 '17 at 09:19
  • 3
    Sure it is. It would appear that the C# compiler is generating "dumb" IL code assuming that the JIT compiler will clean it up. Both `i++` and `++i` do the same thing in this context since the result is discarded, but maybe the compiler isn't clever enough to detect this and just leaves it up to the JITter. – Michael Geary Jul 13 '17 at 09:24
  • @MichaelGeary: that's the clarification I needed, thanks. – themiurge Jul 13 '17 at 09:27
  • It is pointless speculating what the compiler is doing if you aren't looking at the output from the compiler. If I translate English to French (i.e 'compiled'), and then you translate my French to English (decompiled by 'ilspy') and then someone else looks at the final English then they can't make any meaningful comments about my French translation since they never saw it! – mjwills Jul 13 '17 at 10:05
  • @mjwills: I posted the bytecode in my question, take a look. – themiurge Jul 13 '17 at 10:09