6

Let's consider following simple program:

class Program
{
    class TestClass
    {
        ~TestClass()
        {
            Console.WriteLine("~TestClass()");
        }
    }

    static void Main(string[] args)
    {
        WeakReference weakRef;
        {
            var obj = new TestClass();
            weakRef = new WeakReference(obj);
            Console.WriteLine("Leaving the block");
        }

        Console.WriteLine("GC.Collect()");
        GC.Collect();
        System.Threading.Thread.Sleep(1000);
        Console.WriteLine("weakRef.IsAlive == {0}", weakRef.IsAlive);

        Console.WriteLine("Leaving the program");
    }
}

When built in Release mode, it predictably prints:

Leaving the block
GC.Collect()
~TestClass()
weakRef.IsAlive == False
Leaving the program

When Debug version is launched (not under the Debugger, usual launch from Windows Explorer), the output differs:

Leaving the block
GC.Collect()
weakRef.IsAlive == True
Leaving the program
~TestClass()

Running under the debugger for both versions doesn't change the output.

I've discovered this strange difference during debugging of my custom collection that keeps weak references to objects.

Why garbage collector in debug executables does not collect objects that clearly are not referenced?

UPDATE:

Situation differs if object creation is performed in other method:

class Program
{
    class TestClass
    {
        ~TestClass()
        {
            Console.WriteLine("~TestClass()");
        }
    }

    static WeakReference TestFunc()
    {
        var obj = new TestClass();
        WeakReference weakRef = new WeakReference(obj);
        Console.WriteLine("Leaving the block");

        return weakRef;
    }

    static void Main(string[] args)
    {
        var weakRef = TestFunc();

        Console.WriteLine("GC.Collect()");
        GC.Collect();
        System.Threading.Thread.Sleep(1000);
        Console.WriteLine("weakRef.IsAlive == {0}", weakRef.IsAlive);

        Console.WriteLine("Leaving the program");
    }
}

It outputs the same output in Release and Debug versions:

Leaving the block
GC.Collect()
~TestClass()
weakRef.IsAlive == False
Leaving the program
CodeFuller
  • 30,317
  • 3
  • 63
  • 79
  • 1
    that's for debugging purposes – Ehsan Sajjad May 26 '16 at 13:33
  • You want to say that there is no garbage collection in debug version? Is it documented somewhere? – CodeFuller May 26 '16 at 13:34
  • 5
    Take a look pls: http://stackoverflow.com/questions/7165353/does-garbage-collection-run-during-debug – Leo Chapiro May 26 '16 at 13:36
  • He didn't say there was no debugger, just that it acts differenly. – Servy May 26 '16 at 13:36
  • 7
    You imply that the gc is required to collect dead things when you want it to. It is not required to do that. – Eric Lippert May 26 '16 at 13:37
  • Your obj variable reference is still on the stack. In release mode GC can detect it's not longer used and collect it. In debug mode it won't (mostly because you may want to inspect it's value in debugger). When you move that all in another method (like in your update) - that is no longer the case. If you assign it's value to null - it will also work even in debug mode. – Evk May 26 '16 at 13:40
  • Evk, your point seems to be correct. If object is created in separate method, garbage collector is executed in both versions. – CodeFuller May 26 '16 at 13:42
  • Garbage collector is still running, it is just not considering local variables in still running methods when you have compiled for DEBUG or hooked up a debugger. Or rather, it is considering them as roots. – Lasse V. Karlsen May 26 '16 at 13:49

2 Answers2

15

Theodoros Chatzigiannakis has an excellent answer, but I thought I might clarify a couple points.

First off, indeed, the C# compiler generates different code depending on whether optimizations are turned on or off. With optimizations off, locals are generated explicitly in the IL. With optimizations on, some locals can be made "ephemeral"; that is, the compiler can determine that the value of the local can be produced and consumed on the evaluation stack alone, without having to actually reserve a numbered slot for the local variable.

The effect of this on the jitter is that local variables which are generated as numbered slots can be jitted as specific addresses on a stack frame; those variables are considered to be roots of the garbage collector, and they are typically not zeroed out when the C# compiler considers them to have passed out of scope. Therefore they remain roots for the entire activation of the method, and the GC does not collect anything referred to by that root.

Values which merely go onto the evaluation stack are much more likely to be either (1) short-term values that are pushed onto and popped off of the thread's stack, or (2) enregistered, and quickly overwritten. Either way, even if the stack slot or register is a root, the value of the reference will quickly be overwritten, and therefore will no longer be considered reachable by the collector.

Now, an important point is implied by this description of the jitter behaviour: the C# compiler and jitter can work together to lengthen or shorten the lifetime of a local variable at any time at their whim. Moreover, this fact is clearly stated in the C# specification. You absolutely cannot rely on the garbage collector having any particular behaviour whatsoever with respect to the lifetime of a local.

The only exception to this rule -- the rule that you can make no predictions about the lifetime of a local -- is that a GC keepalive will, as the name implies, keep a local alive. The keepalive mechanism was invented for those rare cases where you must keep a local alive for a particular span of time in order to maintain program correctness. This typically only comes into play in unmanaged code interop scenarios.

Again, let me be absolutely clear: the behaviour of the debug and release versions is different, and the conclusion you should reach is NOT "debug version has predictable GC behaviour, release version does not". The conclusion you should reach is "GC behaviour is unspecified; lifetimes of variables may be changed arbitrarily; I cannot rely on any particular GC behaviour under any circumstances". (Except as mentioned before, a keepalive keeps things alive.)

Eric Lippert
  • 647,829
  • 179
  • 1,238
  • 2,067
12

The short answer is that the GC isn't required to do anything like what you're describing. The long answer is that it's not uncommon for something to work more pessimistically under debug configuration, in order to allow you to debug more easily.

For example, in this case, because you declared obj as a local variable somewhere inside the method, the C# compiler can reasonably choose to retain references of that instance, so that utilities like the Locals window or the Watch windows in Visual Studio can function predictably.

Indeed, this is the IL of your code generated using the Debug configuration:

.method private hidebysig static void Main (
        string[] args
    ) cil managed 
{
    .entrypoint
    .locals init (
        [0] class [mscorlib]System.WeakReference weakRef,
        [1] class _GC.Program/TestClass obj
    )

    IL_0000: nop
    IL_0001: nop
    IL_0002: newobj instance void _GC.Program/TestClass::.ctor()
    IL_0007: stloc.1
    IL_0008: ldloc.1
    IL_0009: newobj instance void [mscorlib]System.WeakReference::.ctor(object)
    IL_000e: stloc.0
    IL_000f: ldstr "Leaving the block"
    IL_0014: call void [mscorlib]System.Console::WriteLine(string)
    IL_0019: nop
    IL_001a: nop
    IL_001b: ldstr "GC.Collect()"
    IL_0020: call void [mscorlib]System.Console::WriteLine(string)
    IL_0025: nop
    IL_0026: call void [mscorlib]System.GC::Collect()
    IL_002b: nop
    IL_002c: ldc.i4 1000
    IL_0031: call void [mscorlib]System.Threading.Thread::Sleep(int32)
    IL_0036: nop
    IL_0037: ldstr "weakRef.IsAlive == {0}"
    IL_003c: ldloc.0
    IL_003d: callvirt instance bool [mscorlib]System.WeakReference::get_IsAlive()
    IL_0042: box [mscorlib]System.Boolean
    IL_0047: call void [mscorlib]System.Console::WriteLine(string,  object)
    IL_004c: nop
    IL_004d: ldstr "Leaving the program"
    IL_0052: call void [mscorlib]System.Console::WriteLine(string)
    IL_0057: nop
    IL_0058: ret
}

And this is the IL generated using the Release configuration:

.method private hidebysig static void Main (
        string[] args
    ) cil managed 
{
    .entrypoint
    .locals init (
        [0] class [mscorlib]System.WeakReference weakRef
    )

    IL_0000: newobj instance void _GC.Program/TestClass::.ctor()
    IL_0005: newobj instance void [mscorlib]System.WeakReference::.ctor(object)
    IL_000a: stloc.0
    IL_000b: ldstr "Leaving the block"
    IL_0010: call void [mscorlib]System.Console::WriteLine(string)
    IL_0015: ldstr "GC.Collect()"
    IL_001a: call void [mscorlib]System.Console::WriteLine(string)
    IL_001f: call void [mscorlib]System.GC::Collect()
    IL_0024: ldc.i4 1000
    IL_0029: call void [mscorlib]System.Threading.Thread::Sleep(int32)
    IL_002e: ldstr "weakRef.IsAlive == {0}"
    IL_0033: ldloc.0
    IL_0034: callvirt instance bool [mscorlib]System.WeakReference::get_IsAlive()
    IL_0039: box [mscorlib]System.Boolean
    IL_003e: call void [mscorlib]System.Console::WriteLine(string,  object)
    IL_0043: ldstr "Leaving the program"
    IL_0048: call void [mscorlib]System.Console::WriteLine(string)
    IL_004d: ret
}

Notice how in the Debug build, the TestClass instance is retained as a local throughout the entire method:

    .entrypoint
    .locals init (
        [0] class [mscorlib]System.WeakReference weakRef,
        [1] class _GC.Program/TestClass obj
    )

The fact that you declared that variable in a nested scope in the C# code is irrelevant, because the IL code doesn't have an equivalent notion of nested scopes. So, the variable is declared as a local of the entire method either way.

Also notice how if you manually perform this change in your C# code (local variable inlining):

        WeakReference weakRef;
        {
            weakRef = new WeakReference(new TestClass());
            Console.WriteLine("Leaving the block");
        }

Then the IL of the Debug build skips the local declaration as well, matching the Release configuration:

.method private hidebysig static void Main (
        string[] args
    ) cil managed 
{
    .entrypoint
    .locals init (
        [0] class [mscorlib]System.WeakReference weakRef
    )

And similarly, the Debug configuration output matches the output of the Release configuration as well:

Leaving the block
GC.Collect()
~TestClass()
weakRef.IsAlive == False
Leaving the program

Obviously, the reason for this is that part of the optimizations that the C# compiler performs when building using the Release configuration is to automatically inline local variables wherever possible. And that's where the different behavior kicks in.

Theodoros Chatzigiannakis
  • 28,773
  • 8
  • 68
  • 104
  • It's not actually correct that GC is not required to collect objects without references when called. It should. And it does so in Release version. The question relates to differences in Debug and Release configurations. Evk gave correct answer. – CodeFuller May 26 '16 at 13:54
  • @CodeFuller Okay, where (in the C# or the CLR spec) does it say that it should? – Theodoros Chatzigiannakis May 26 '16 at 13:54
  • https://msdn.microsoft.com/en-us/library/xe0c2357(v=vs.110).aspx Use this method to try to reclaim all memory that is inaccessible. All objects, regardless of how long they have been in memory, are considered for collection. – CodeFuller May 26 '16 at 14:01
  • @CodeFuller Well, nobody says that the object wasn't *considered* for collection in your Debug build. – Theodoros Chatzigiannakis May 26 '16 at 14:04
  • 5
    @CodeFuller: The specification says that the collection will *try* to reclaim memory. It does not say that it *will* reclaim *all possible memory that can be collected*. Moreover, the GC does not make *any* promises about the lifetime of a local variable or how long that variable will be considered a root of the collection. The GC also does not make any promises about when finalizable objects are collected. The GC is a nondeterministic mechanism and you cannot rely on it having deterministic behaviour. **It simply does not have that behaviour.** – Eric Lippert May 26 '16 at 15:34
  • This is not working then the target platform = netcoreapp2.0. The @Eric Lippert's answer is more clear. – CMaker May 25 '18 at 03:51