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
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."