3

I read this fantastic explanation from Eric Lippert concerning when an object, having a reference to another one via an event, is garbage-collected.

To prove what Eric said, I tried out this code:

using System;
                
public class Program
{
    public static void Main()
    {
        {
            var myClass = new GCClass();
            LongLivingClass.MyEvent += myClass.HandleEvent;
        } // LongLivingClass holds a reference to myClass
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        // expect: finalizer of GCLass not run

        {
            var myClass = new GCClass();
            myClass.MyEvent += LongLivingClass.HandleEvent;
        } // myClass can easily be GCed here
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        // expect: finalizer should run

        Console.WriteLine("Finish");
    }
}


class GCClass
{
    public event EventHandler MyEvent;
    public void HandleEvent(object o, EventArgs e) { }
    ~GCClass() { Console.WriteLine("Finalizer hit"); }
}    
public class LongLivingClass
{
    public static event EventHandler<EventArgs> MyEvent;
    public static void HandleEvent(object o, EventArgs e) { }
}

As I expected the first GC.Collect-block doesn't finalize anything, as the object simply is referenced by LongLvongClass and thus survives a collection.

The second block however also does not call the finalizer, although myClass is eligible for collection and we're even waiting for the finalizer to happen. However, my finalizer wasn't hit. As from GC.Collect() and Finalize I'd expect the finalizer to be hit here. I put a breakpoint into it, to show that.

Where did I go wrong here? I suppose myClass is not collected in the second code-block, but I don't know why.

Michael Haddad
  • 4,085
  • 7
  • 42
  • 82
MakePeaceGreatAgain
  • 35,491
  • 6
  • 60
  • 111
  • I just inserted your test code into net6 winforms app and I can confirm that finalizer is called one time, so all works as expected. – Serg Feb 10 '22 at 13:44
  • @Serg of course it is hit once the app-domain terminates. However I'd assume the `GC.WaitForPendingFinalizers` to block the current thread until the finalizer-thread has done its job. So when calling `WaitForPendingFinalizers`, the finalizer should run. – MakePeaceGreatAgain Feb 10 '22 at 14:01
  • 1
    The following applies to Java, but C# might be similar enough to behave similar: 1) `Main()` runs a single time and therefore, doesn’t get much optimized 2) In an unoptimized execution, the content of `var myClass` stays in the stackframe even after the variable went out of scope. Declare and assign a new variable after the the block which can take the same storage location in the stackframe. Alternatively, run the whole code in a loop to trigger the optimizer. – Holger Feb 10 '22 at 14:38
  • @Holger I know that `myClass` isn't collected just because it is out of scope. However it gets *eligable* for a collection. Anyway as we're calling `GC.Collect` and afterwards `GC.WaitForPendingFinalizers` that object should be finalized, in a predictive manner. – MakePeaceGreatAgain Feb 10 '22 at 14:49
  • Of course that's nothing for production-code, I just want to know where my mistake is. – MakePeaceGreatAgain Feb 10 '22 at 14:50
  • Just for clarification - I observed the finalizer not on the application closing. I executed the test code by the click on the button on the form. And the finalizer was called immediately, while the program continues to work (so, app domain was not terminated). – Serg Feb 10 '22 at 15:58
  • Additionally, I took your fixed code and run it in console app, but place `Main` inside a 100-iteration loop. And I observerd 99 finalizers calls (all for second instance of GCClass). Without a loop there is no finalizer called, and I do not know why. – Serg Feb 10 '22 at 16:05
  • 1
    Being (formally) eligible for collection doesn’t imply that it will get collected. As said, it’s a known, lowlevel technical issue with Java which might apply here as well. Just try to insert another variable like `var foo = new Object();` after the block before `GC.Collect();` to see whether this is the case. Serg’s statement that it does work in a loop suggests that it is the same issue. – Holger Feb 10 '22 at 16:07
  • 1
    I experimented a bit and it seems, with C# it’s more complicated. In fact, after experimenting a bit more, I have the feeling that C#’s garbage collector is rolling dice. – Holger Feb 10 '22 at 18:37
  • 1
    See [tio.run](https://tio.run/##nVNdT4MwFH3nV9zwxDJl8RlnMombD1uyZA977uAKNaU1bYeZC/51pAXkQ2KMPABt7zk995w2UreRisqzojyBw0VpzILy7XxiNIKIEaVgL0UiSeZcHaieZklpoqtPLmgMO0K5N7Ordc3wzzw5kZBdQsu3BI7vsAntyHNX7iwY1G4FT7Y0r/TYCn93ecqRa5gvWwr/mfCYoZ3usAUsFmMwpILFCghIfEGJPELQoqX5Rm5CPxSMYaS9npZq9kioXgu5Rx5XjGvKCaMfKFW/bIc6FfEI@H@6aXAouBIM/aOkGreUo@dWcKrS1rzCvgepNLoGufySw2OXQ2tzz/lxKBMJ9LjvxuRhp7NwHKc@WM3q@GBJcxI5yTDoz6NVYjes95bQyBuU2dZ74jxxeq28BHFTY1cyUYAzuDaONbBW6KHb3dTolCrfDEw/RlEN@mzLTc10NHWykFINLsxrwsB0b/DO4H6NrJ28Zz@7v/9u52HSiP5R@KMfRVl@AQ), `B` gets finalized before `Finish` only as long as `C` exists… – Holger Feb 11 '22 at 09:17
  • @Holger From my understaning when calling `GC.Collect` the object is either put into the finalizer-queue or onto the next generation. In my second block I'd expect the former, as there is no more reference to that object anywhere. However the finalizer does not run. Anyway I have no idea why putting the code into a method makes any difference towards putting it into braces. From your tio.run it seems so, at least. – MakePeaceGreatAgain Feb 11 '22 at 12:22
  • 1
    The effect of putting it into a method didn’t surprise me as much as the fact that it only works reliably as long as the line `var myClass1 = new GCClass("C");` is present. Without the line, it sometimes works and sometimes not. – Holger Feb 11 '22 at 12:46

1 Answers1

6

Where did I go wrong here?

Short version:

The important thing to realize here is that C# is not like C++ where } means "we must run destructors of locals now".

C# is allowed to increase the lifetime of any local variable at its discretion, and it does so all the time. Therefore you should never expect a local variable's contents to be collected just because control enters a region where the variable is out of scope.

Long version:

The rule that { } defines a nested local variable declaration space is a rule of the C# language; it is emphatically NOT a feature of the IL that C# is compiled to!

Those local variable declaration spaces are there so that you can organize your code better and so that the C# compiler can find bugs where you used a local variable when it was not in scope. But C# does NOT emit IL that says "the following local variables are now out of scope" at the bottom of your { } block.

The jitter is allowed to notice that your second myClass is never read from and therefore could be removed from the root set after the final write. But the fact that it is allowed to do so does not require it to do so, and typically it will not.

That "typically" is doing some heavy lifting there because, of course, the jitter is allowed to shorten the lifetime of a local. Consider this bad situation:

{
    ManagedHandle handle = NativeObject.Open();
    SomeNativeCode.CacheTheUnderlyingHandle(handle);
    // handle is still in scope but the jitter is allowed to
    // realize it is never read again at this point and remove the
    // local from the root set. If you have the misfortune that you
    // get a GC and finalize right now then the resource is deallocated
    // by the time we get to:
    SomeNativeCode.UseTheCachedHandle();
}        

If you're in this unfortunate situation, use KeepAlive to force a local to stay alive.

Eric Lippert
  • 647,829
  • 179
  • 1,238
  • 2,067
  • 1
    okay, so the key-fact for me is that `{ }` don't exist on IL-level and thus the objects are not eligable for GC in the second example until calling `GC.Collect`. However I'd assume calling `GC.Collect` would make it eligable. – MakePeaceGreatAgain Mar 09 '22 at 08:30
  • @MakePeaceGreatAgain: That assumption is wrong. The way the GC works is there are locations called "roots"; the roots are alive, and everything that a root refers to is alive, and everything they refer to is alive, and so on. Starting a collection just figures out what is alive from the given roots, it does not re-evaluate what roots are alive. – Eric Lippert Mar 09 '22 at 18:54
  • 2
    Local variables that are "alive" are roots, but the jitter has great discretion in deciding whether a local is alive -- a root -- or dead -- not a root. A local is required to stay alive if it can be read again, but the opposite is not true; a local is allowed to become dead if never read again, but not *required* to be dead if never read again. – Eric Lippert Mar 09 '22 at 18:56
  • How is this effected when the jitter inlines a method that have a local variable? – Ian Ringrose Jul 16 '23 at 18:00
  • @IanRingrose: I'm not quite understanding your question; maybe post a question and drop a link to it here. :) – Eric Lippert Jul 17 '23 at 19:44