runtime: GC does not collect all objects

Description

GC doesn’t collect objects, that are declared in the same method, but the reference to them is gone

Reproduction Steps

Run this code:

using System;

class A
{
	public static int InstanceCount;

	public A()
	{
		InstanceCount++;
	}

	~A()
	{
		InstanceCount--;
	}
}

public class Program
{
    public static void Main(string[] args)
    {
        var a = new A();
	a = null;
        GC.Collect();
	GC.WaitForPendingFinalizers();
	Console.WriteLine(A.InstanceCount);
    }
}

I Debug mode it might be fine to not collect a because we might want to check its value after GC.Collect() and GC.WaitForPendingFinalizers(). Because we nulled a before we are not able to track it anymore even in debug mode, right? However, it still output 1 even in Release mode for me

Expected behavior

GC.Gollect() should collect object a, because the reference to it is already gone from the stack. GC.WaitForPendingFinalizers() should wait for its finalizer to be called, so the output value of the InstanceCount field to the console must be 0

Actual behavior

Seems like GC.Gollect() doesn’t mark a as unused and doesn’t collect it. So we get 1 displayed in the console

Regression?

The behaviour is expected in sharplab in Release mode, I don’t know what version of runtime it is currently using: https://sharplab.io/#v2:C4LgTgrgdgPgAgJgIwFgBQ7EAICC6De6AkHAMxZxIBsWAllMFgJJQDOwAhlAMYCmAwgHtowANzpiZXAAoAlMUJoiRFuy58hIgNRbxSgL4SlAPxxyFxFW048BwhgFoHeoobRvM5bJQDsBdFiBFF7UFAAsWACyHPTSlAAMANoAulgcYADmrPJKikH5WABu6WlYALxYULwA7jKyegVBHOWVEAA2bQ2NWADi/AB0Qh283MByLkR9/QDqMcAAYoJgAAq8UAAm9Bnz9BxttABevGCs45aUAJzSOP2qNhr2wPXEbvpAA===

Known Workarounds

No response

Configuration

Tried on runtimes: net472 (reproduces only in Debug mode), net6.0 v6.0.9, net7.0 v7.0.0-rc.1.22427.1 Tried on OSes: Win 11 21H2 build 22000.1042 and Win 11 22H2 build 22621.521 Architecture: x64

Other information

No response

category:correctness theme:gc-info skill-level:intermediate cost:small impact:medium

About this issue

  • Original URL
  • State: open
  • Created 2 years ago
  • Comments: 17 (17 by maintainers)

Most upvoted comments

Just curious, wouldn’t the a = null be considered a dead write and got optimized away?

At the IL level the C# compiler generates:

        IL_0000: newobj instance void A::.ctor()
        IL_0005: pop

so there isn’t actually a local at all.

Right, we plan some improvements on codegen level for this, namely we want to be careful inlining methods with pinvokes/fcalls inside

@DoctorKrolic Anyways, regardless of changes to GC, if you’re wanting deterministic cleanup of something you should actually be implementing the Dispose() pattern, not relying on finalizers (which have multiple reasons they might not be cleaned up when you expect).

I feel like it happens because GC.Collect/WaitForPendingFinalizers are inlined and explode the caller with gc transition machinery because of pinvokes/fcalls (untrackable locals?). This works:

    public static void Main(string[] args)
    {
        var a = new A();
        a = null;
        Collect();
        Console.WriteLine(A.InstanceCount);
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void Collect()
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }

We might want to slap [MethodImpl(MethodImplOptions.NoInlining)] to such GC helpers