Search code examples
c#garbage-collectionweak-references

WeakHandle .NET Core vs .NET Framework


While experimenting with WeakHandles I came across this peculiarity on .NET 6.

static void Main(string[] args) {
    var foo = new int[3];
    var fooWeakHandle = GCHandle.Alloc(foo, GCHandleType.Weak);
    GC.Collect();
    Console.WriteLine(fooWeakHandle.Target);
}

The result with .NET 6 (compiled in Release mode to avoid eager root collection) is surprisingly

System.Int32[]

Under .NET Framework 4.7.2 it's null/nothing in Release and System.Int32[] in Debug. I've got the same results with LinqPad (optimize+-) for Framework and .NET(Core).

Why isn't the array that is only referenced by the WeakHandle garbage collected?

EDIT:

I've tried as suggested to move the code in a different method - still the same results (even threw a bit of unnecessary MethodImpl options) IL is the same for .NET Framework 4.7.2 and .NET 6 (mscorlib vs System.Runtime only difference). Even added an empty class to try with new B() instead of new int[]...same results.

static void Main(string[] args) {
    DifferentFunction();
}

[MethodImpl(MethodImplOptions.NoOptimization | MethodImplOptions.NoInlining)]
static void DifferentFunction() {
    var foo = new int[3];
    var fooWeakHandle = GCHandle.Alloc(foo, GCHandleType.Weak);
    GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
    Console.WriteLine(fooWeakHandle.Target);
}

class B
{

}

.method private hidebysig static 
    void DifferentFunction () cil managed noinlining nooptimization 
{
    // Method begins at RVA 0x2058
    // Header size: 12
    // Code size: 35 (0x23)
    .maxstack 4
    .locals init (
        [0] valuetype [System.Runtime]System.Runtime.InteropServices.GCHandle fooWeakHandle
    )

    // GCHandle gCHandle = GCHandle.Alloc(new int[3], GCHandleType.Weak);
    IL_0000: ldc.i4.3
    IL_0001: newarr [System.Runtime]System.Int32
    IL_0006: ldc.i4.0
    IL_0007: call valuetype [System.Runtime]System.Runtime.InteropServices.GCHandle [System.Runtime]System.Runtime.InteropServices.GCHandle::Alloc(object, valuetype [System.Runtime]System.Runtime.InteropServices.GCHandleType)
    IL_000c: stloc.0
    // GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
    IL_000d: ldc.i4.2
    IL_000e: ldc.i4.1
    IL_000f: ldc.i4.1
    IL_0010: ldc.i4.1
    IL_0011: call void [System.Runtime]System.GC::Collect(int32, valuetype [System.Runtime]System.GCCollectionMode, bool, bool)
    // Console.WriteLine(gCHandle.Target);
    IL_0016: ldloca.s 0
    IL_0018: call instance object [System.Runtime]System.Runtime.InteropServices.GCHandle::get_Target()
    IL_001d: call void [System.Console]System.Console::WriteLine(object)
    // }
    IL_0022: ret
} // end of method Program::DifferentFunction

Solution

  • EDIT: I've found another answer that captures what the difference between .NET Framework and .NET Core more accurately seems to be - Tiered Compilation

    This is enabled by default in .NET 5 (and .NET Core 3+), but is not available in .NET 4.8.

    In your case the result is your method is compiled with mentioned "quick" compilation and is not optimized enough for your code to work as you expect (that is lifetime of myObject variable extends until the end of the method). That is the case even if you compile in Release mode with optimizations enabled, and without any debugger attached.

    When I compile my method with

    [MethodImpl(MethodImplOptions.AggressiveOptimization)]
    

    expected things happen. However because the answer contained some caveats

    tiered compilation will (for some methods under some conditions) first compile crude, low optimized version of a method, and then later will prepare a better optimized version if necessary.

    I will leave my old answer which describes the fully and partially interruptible methods as possible reason for the difference and the main takeaway for me that

    while compiling in Debug mode will always extend the lifetimes of local variables to the end of the method, Release Mode CAN mark variables(roots) as dead (no longer reachable) if the JIT code heuristics decided that it's beneficial. But that's not always the case as evidenced.

    OLD Answer:

    While JonasH answer points in the right direction, I wanted to give the details I managed to dig up during my investigations which might be of interest.

    Seems like the key to all of this is that methods can be partially or fully interruptible (from this answer):

    However, information about the stack roots liveness is stored only for so-called safe points. There are two types of methods: partially interruptible - the only safe points are during calls to other methods. This makes a method less "suspendable" because the runtime needs to wait for such a safe point to suspend a method, but consumes less memory for the GC info. fully interruptible - every instruction of a method is treated as a safe point, which obviously makes a method very "suspendable" but requires significant storage (of quantity similar to the code itself)

    As Book Of The Runtime says: “The JIT chooses whether to emit fully- or partially interruptible code based on heuristics to find the best trade-off between code quality, size of the GC info, and GC suspension latency.”

    I've modified my initial example to test this (moved code to another method, no-inlining is artefact of testing and shouldn't matter). Also replaced the array with a dummy class instantiation just in case arrays were somehow more special.

    using System.Runtime.CompilerServices;
    using System.Runtime.InteropServices;
    
    namespace ConsoleApp2
    {
        internal class Program
        {
            static GCHandle fooWeakHandle;
    
    
            static void Main()
            {
                DifferentFunction();
                //GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
                Console.WriteLine(fooWeakHandle.Target);
            }
    
            [MethodImpl(MethodImplOptions.NoInlining)]
            static void DifferentFunction()
            {
                //var foo = new int[3];
                var foo = new B();
    
                // just having a loop switches to expected behavior
                //for (int i = 0; i < 1; i++) { }
    
                fooWeakHandle = GCHandle.Alloc(foo, GCHandleType.Weak);
    
                GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
            }
    
            class B
            {
    
            }
        }
    }
    

    Now if we uncomment //for (int i = 0; i < 1; i++) { } we will get a fully interruptible method as evidenced by this output from WinDbg+sos debugging command !u -gcinfo 00007FFECDF682C7

    **00000018 interruptible**
    00007ffe`1f538298 488d4d88        lea     rcx,[rbp-78h]
    00007ffe`1f53829c 498bd2          mov     rdx,r10
    00007ffe`1f53829f e82cf8b05f      call    coreclr!JIT_InitPInvokeFrame (00007ffe`7f047ad0)
    00007ffe`1f5382a4 488bf0          mov     rsi,rax
    00007ffe`1f5382a7 488bcc          mov     rcx,rsp
    00007ffe`1f5382aa 48894da8        mov     qword ptr [rbp-58h],rcx
    00007ffe`1f5382ae 488bcd          mov     rcx,rbp
    00007ffe`1f5382b1 48894db8        mov     qword ptr [rbp-48h],rcx
    00007ffe`1f5382b5 cc              int     3
    00007ffe`1f5382b6 b9b040651f      mov     ecx,1F6540B0h
    00007ffe`1f5382bb fe              ???
    00007ffe`1f5382bc 7f00            jg      00007ffe`1f5382be
    00007ffe`1f5382be 00e8            add     al,ch
    00007ffe`1f5382c0 3c33            cmp     al,33h
    00007ffe`1f5382c2 b95f488bc8      mov     ecx,0C88B485Fh
    
    D:\ConsoleApp2\ConsoleApp2\Program.cs @ 27:
    **00000044 +rax**
    **00000047 +rcx**
    00007ffe`1f5382c7 33d2            xor     edx,edx
    00007ffe`1f5382c9 e822dab55f      call    coreclr!MarshalNative::GCHandleInternalAlloc (00007ffe`7f095cf0)
    **0000004e -rcx -rax**
    00007ffe`1f5382ce 488bf8          mov     rdi,rax
    00007ffe`1f5382d1 48b928af5c1ffe7f0000 mov rcx,7FFE1F5CAF28h
    00007ffe`1f5382db ba01000000      mov     edx,1
    00007ffe`1f5382e0 e8fb34b95f      call    coreclr!JIT_GetSharedNonGCStaticBase_SingleAppDomain (00007ffe`7f0cb7e0)
    00007ffe`1f5382e5 48b9902e22ea2c020000 mov rcx,22CEA222E90h
    00007ffe`1f5382ef 488b09          mov     rcx,qword ptr [rcx]
    **00000072 +rcx**
    00007ffe`1f5382f2 48897908        mov     qword ptr [rcx+8],rdi
    
    D:\ConsoleApp2\ConsoleApp2\Program.cs @ 29:
    00007ffe`1f5382f6 b902000000      mov     ecx,2
    **0000007b -rcx**
    00007ffe`1f5382fb ba0a000000      mov     edx,0Ah
    **00007ffe`1f538300 48b848f35e1ffe7f0000 mov rax,7FFE1F5EF348h (MD: System.GC._Collect(Int32, Int32))**
    

    I've marked the interesting lines where the tracking might happen with **.

    If we comment the dummy loop we seem to be getting a partially interruptible method:

    00007ffe`cdf68298 488d4d88        lea     rcx,[rbp-78h]
    00007ffe`cdf6829c 498bd2          mov     rdx,r10
    00007ffe`cdf6829f e82cf8af5f      call    coreclr!JIT_InitPInvokeFrame (00007fff`2da67ad0)
    00007ffe`cdf682a4 488bf0          mov     rsi,rax
    00007ffe`cdf682a7 488bcc          mov     rcx,rsp
    00007ffe`cdf682aa 48894da8        mov     qword ptr [rbp-58h],rcx
    00007ffe`cdf682ae 488bcd          mov     rcx,rbp
    00007ffe`cdf682b1 48894db8        mov     qword ptr [rbp-48h],rcx
    00007ffe`cdf682b5 cc              int     3
    00007ffe`cdf682b6 b9b04008ce      mov     ecx,0CE0840B0h
    00007ffe`cdf682bb fe              ???
    00007ffe`cdf682bc 7f00            jg      00007ffe`cdf682be
    00007ffe`cdf682be 00e8            add     al,ch
    00007ffe`cdf682c0 3c33            cmp     al,33h
    00007ffe`cdf682c2 b85f488bc8      mov     eax,0C88B485Fh
    
    D:\ConsoleApp2\ConsoleApp2\Program.cs @ 27:
    **00000044 is a safepoint:** 
    00007ffe`cdf682c7 33d2            xor     edx,edx
    00007ffe`cdf682c9 e822dab45f      call    coreclr!MarshalNative::GCHandleInternalAlloc (00007fff`2dab5cf0)
    **0000004e is a safepoint:** 
    00007ffe`cdf682ce 488bf8          mov     rdi,rax
    00007ffe`cdf682d1 48b928afffcdfe7f0000 mov rcx,7FFECDFFAF28h
    00007ffe`cdf682db ba01000000      mov     edx,1
    00007ffe`cdf682e0 e8fb34b85f      call    coreclr!JIT_GetSharedNonGCStaticBase_SingleAppDomain (00007fff`2daeb7e0)
    **00000065 is a safepoint:** 
    00007ffe`cdf682e5 48b9902e8a4c6a020000 mov rcx,26A4C8A2E90h
    00007ffe`cdf682ef 488b09          mov     rcx,qword ptr [rcx]
    00007ffe`cdf682f2 48897908        mov     qword ptr [rcx+8],rdi
    
    D:\ConsoleApp2\ConsoleApp2\Program.cs @ 29:
    00007ffe`cdf682f6 b902000000      mov     ecx,2
    00007ffe`cdf682fb ba0a000000      mov     edx,0Ah
    00007ffe`cdf68300 48b848f301cefe7f0000 mov rax,7FFECE01F348h (MD: System.GC._Collect(Int32, Int32))
    

    I've used Windbg+Sos just a few times but based on this excellent video, I'd expect the safe-points to also include the +rax, -rax information for tracking what is live and what is not. But in my output that's not the case.

    So in my case that should normally NOT include any live roots. Maybe there is another mechanism on top of the safe-points that keeps the object live? Maybe somebody could comment.

    Main takeaway for me is that while compiling in Debug mode will always extend the lifetimes of local variables to the end of the method, Release Mode CAN mark variables(roots) as dead (no longer reachable) if the JIT code heuristics decided that it's beneficial. But that's not always the case as evidenced.

    For a very good reference to this exact question by GC main maintaner Maoni (from comments to my question):

    "This is because when JIT generates code for you, it's free to lengthen the lifetime till end of the method."