0

Program:

class Program
{
    static void Main(string[] args)
    {
        var tr = new TimerRunner("T1");

        for (var i = 0; i < 15; i++)
        {
            System.Console.WriteLine($"[LOOP {i}]");
            Task.Delay(1000).Wait();
            if (i == 1)
                tr = new("T2");
            if (i == 2)
                tr = null;
            if (i == 10)
                GC.Collect();
        }
    }
}

class TimerRunner
{
    private readonly string name;
    private readonly System.Threading.Timer timer;

    public TimerRunner(string name)
    {
        timer = new(new TimerCallback(Write), name, 5000, 1000);
        this.name = name;
    }

    ~TimerRunner()
    {
        System.Console.WriteLine($"{name}: Bye!");
        timer.Dispose();
    }

    static void Write(object? name)
    {
        System.Console.WriteLine($"{(string)name!}: Hi from {Thread.CurrentThread.ManagedThreadId}");
    }
}

Example output from my machine (slightly different each time):

[LOOP 0]
[LOOP 1]
[LOOP 2]
[LOOP 3]
[LOOP 4]
T1: Hi from 5
[LOOP 5]
T1: Hi from 5
[LOOP 6]
T1: Hi from 7
[LOOP 7]
T2: Hi from 5
T1: Hi from 5
T2: Hi from 5
[LOOP 8]
T1: Hi from 5
T2: Hi from 5
[LOOP 9]
T1: Hi from 9
T2: Hi from 9
[LOOP 10]
T1: Hi from 9
T2: Hi from 9
[LOOP 11]
T1: Hi from 9
T2: Hi from 9
[LOOP 12]
T1: Hi from 5
T2: Hi from 5
[LOOP 13]
T1: Hi from 5
T2: Hi from 5
[LOOP 14]
T1: Hi from 5
T2: Hi from 5

Expected behavior was that in loop i==10 the GC would collect both T1 and T2 objects from memory and timers would have stopped.

  • `GC.WaitForPendingFinalizers()` was added after `GC.Collect()`, still the same behavior. – Anneliese79 May 26 '22 at 10:55
  • 1
    Are you running the program with the debugger attached, or built as a DEBUG build? I ask because in both these cases, the lifetime of local variables will be extended until the end of the scope they're declared in, to facilitate debugging needs. Build the program for RELEASE and run it without a debugger and you might see a difference. – Lasse V. Karlsen May 26 '22 at 14:28
  • Why are you using a finalizer at all? Finalizers should only be used to clean up _unmanaged_ resources. The garbage collector will take care of _managed_ resources (like your `Timer`). You cannot control when your finalizer is called. – D Stanley May 26 '22 at 14:48
  • @Lasse | Publishing with `dotnet publish -c Release -p:UseAppHost=false` and then running the produced DLL file resulted in the expected behavior. Thank you. – Anneliese79 May 26 '22 at 14:48
  • @DStanley | This is for me to understand the flow of the program. It's only for educational purposes. – Anneliese79 May 26 '22 at 14:50
  • @LasseV.Karlsen | If you provide an answer I can accept it. – Anneliese79 May 26 '22 at 14:57

1 Answers1

0

This is a common issue when debugging a program to diagnose finalization issues.

When a debugger is attached to the process, or the process was compiled without optimizations (usually through the DEBUG profile), the lifetime of variables is extended.

Let me give an example:

public void Test()
{
    var x = new SomeObject();
    x.DoSomething();

    // some other lengthy process not involving x
}

In this example, as long as the x variable is not even mentioned in the rest of the method, after the call to x.DoSomething(), the variable x is no longer considered as active and whatever it references can be eligible for collection.

This means that as soon as that method call has returned (I'm simplifying here, the exact lifetime is even more scary than this), the reference to SomeObject is no longer considered, and the object can be collected by the garbage collector.

This means that you could expect this code to finalize the object before it returns:

public void Test()
{
    var x = new SomeObject();
    x.DoSomething();

    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();    
}

And indeed, if you compile the project with optimizations on (usually through the RELEASE profile), and then run it outside of the debugger, you can observe that the object is finalized. You would obviously need some way of observing it, such as the output statements in your own code in the question.

But if you run the same program with the debugger attached, or compile it without optimizations, you can think of the code as having an artificial GC.KeepAlive at the end:

public void Test()
{
    var x = new SomeObject();
    x.DoSomething();

    GC.Collect();
    GC.WaitForPendingFinalizers();
    GC.Collect();    

    GC.KeepAlive(x);
}

(again, this is a simplification, it is the JITter that handles the variable lifetime analysis and metadata, that simply makes sure metadata reflects the variable being considered alive for the duration of its scope)

and thus x is indeed not collected. This is what you're observing with your example.


Note that the garbage collector can be thought of as really aggressive, here is an example.

Consider a program that has an IntPtr handle to something unmanaged, that it needs to clean up. You would do this using Dispose, and then also add a finalizer, let's take a look:

public class Test : IDisposable
{
    private IntPtr _Handle;

    public Test()
    {
        _Handle = <acquire handle to unmanaged resource>
    }

    ~Test()
    {
        <release unmanaged resource using _Handle>
    }

    public void Dispose()
    {
        <release unmanaged resource using _Handle>
        _Handle = IntPtr.Zero;
        GC.SuppressFinalize(this);
    }
}

This looks fine, but let's consider having a simple method that uses this handle:

public string GetName()
{
    IntPtr handle = _Handle;
    string result;
    <read from memory of unmanaged resource into result>
    return result;
}

And now we have this code:

var t = new Test();
string x = t.GetName();

and that is the last usage of the t variable.

The aggressiveness comes from the fact that even during the call to GetName, there is little use of the object instance. The instance is only used at the very start, reading _Handle, and then no more, so even during that call GC can kick in and finalize and collect the object while it is trying to read from unmanaged memory.

The correct way to write GetName is to make sure this does not happen:

public string GetName()
{
    IntPtr handle = _Handle;
    string result;
    <read from memory of unmanaged resource into result>
    GC.KeepAlive(this);
    return result;
}

The garbage collector is scary!

Monolith
  • 1,067
  • 1
  • 13
  • 29
Lasse V. Karlsen
  • 380,855
  • 102
  • 628
  • 825