Inspecting Local Root Lifetime
The .NET GC determines which objects are reachable by constructing a graph of references, starting from a set of roots. One type of roots is local references that are stored on thread stacks. The GC relies on the JIT to supply it with information about the local variables in each active method, and uses that information to determine whether a root should be traversed.
When all optimizations are enabled, the JIT and GC cooperate so that local roots are relevant only as long as the local variable is still going to be used. This is a source of strange bugs—two of them are the Timer bug and the finalization race condition.
Why am I telling you all this? Well, it turns out there’s a cool SOS command called !GCInfo that can give you information on local root relevance within a method. In other words, it can tell you when (in terms of instructions) a local variable ceases to be an active local root.
For the sake of simplicity, let’s consider Debug builds only and the following method:
static void Main(string[] args)
{
string s = "Hello World";
Console.ReadLine();
}
Because of the Debug build, the local variable s is not going to be optimized away. Furthermore, its lifetime should extend until the end of the method, and not just the first line. Let’s verify this:
0:000> !CLRStack
OS Thread Id: 0x1f04 (0)
ESP EIP
[…edited for brevity…]
003ced00 704c9fbb System.IO.StreamReader.ReadBuffer()
003ced14 704c9e0c System.IO.StreamReader.ReadLine()
003ced34 70a5dd3d System.IO.TextReader+SyncTextReader.ReadLine()
003ced40 709a31c7 System.Console.ReadLine()
003ced48 005c009b FreakishTimerBug.Program.Main()
0:000> !gcinfo 005c009b
entry point 005c0070
Normal JIT generated code
GC info 001b17e0
Method info block:
method size = 0031
prolog size = 9
epilog size = 4
epilog count = 1
epilog end = yes
callee-saved regs = EBP
ebp frame = yes
fully interruptible= yes
double align = no
arguments size = 0 DWORDs
stack frame size = 2 DWORDs
untracked count = 1
var ptr tab count = 1
epilog at 002D
argTabOffset = 4
31 D2 B8 9A 40 |
01 04 |
Pointer table:
04 | [EBP-04H] an untracked local
08 1C 15 | 001C..0031 [EBP-08H] a pointer
F3 43 | 0023 reg EAX becoming live
F0 00 | 002B reg EAX becoming dead
FF |
0:000> !u 005c009b
Normal JIT generated code
FreakishTimerBug.Program.Main(System.String[])
Begin 005c0070, size 31
[…edited for brevity…]
005c0070 push ebp
005c0071 mov ebp,esp
005c0073 sub esp,8
005c0076 mov dword ptr [ebp-4],ecx
005c0079 cmp dword ptr ds:[1B30E4h],0
005c0080 je 005c0087
005c0082 call mscorwks!JIT_DbgIsJustMyCode (710cb329)
005c0087 xor edx,edx
005c0089 mov dword ptr [ebp-8],edx
005c008c nop
005c008d mov eax,dword ptr ds:[3222030h] ("Hello World")
005c0093 mov dword ptr [ebp-8],eax
005c0096 call mscorlib_ni+0x6d31b8 (709a31b8) (System.Console.ReadLine(), mdToken: 060007bb)
>>> 005c009b nop
005c009c nop
005c009d mov esp,ebp
005c009f pop ebp
005c00a0 ret
The local variable s is stored on the stack, in EBP-8. It becomes relevant at offset 1C from the beginning of the method, which is the NOP instruction at 005c008C, and it remains relevant throughout the entire method.
If we modify the code slightly, we might expect a different outcome:
static void Main(string[] args)
{
string s = "Hello World";
s = null;
Console.ReadLine();
}
In this version, the local variable is explicitly cleared before the Console.ReadLine call, so it might have an effect on the JIT’s table:
0:000> !gcinfo 004000a0
entry point 00400070
Normal JIT generated code
GC info 001917e0
Method info block:
method size = 0036
prolog size = 9
epilog size = 4
epilog count = 1
epilog end = yes
callee-saved regs = EBP
ebp frame = yes
fully interruptible= yes
double align = no
arguments size = 0 DWORDs
stack frame size = 2 DWORDs
untracked count = 1
var ptr tab count = 1
epilog at 0032
argTabOffset = 4
36 D2 B8 9A 40 |
01 04 |
Pointer table:
04 | [EBP-04H] an untracked local
08 1C 1A | 001C..0036 [EBP-08H] a pointer
F3 43 | 0023 reg EAX becoming live
F0 05 | 0030 reg EAX becoming dead
FF |
0:000> !u 004000a0
Normal JIT generated code
FreakishTimerBug.Program.Main(System.String[])
Begin 00400070, size 36
[…edited for brevity…]
00400070 push ebp
00400071 mov ebp,esp
00400073 sub esp,8
00400076 mov dword ptr [ebp-4],ecx
00400079 cmp dword ptr ds:[1930E4h],0
00400080 je 00400087
00400082 call mscorwks!JIT_DbgIsJustMyCode (710cb329)
00400087 xor edx,edx
00400089 mov dword ptr [ebp-8],edx
0040008c nop
0040008d mov eax,dword ptr ds:[35E2030h] ("Hello World")
00400093 mov dword ptr [ebp-8],eax
00400096 xor edx,edx
00400098 mov dword ptr [ebp-8],edx
0040009b call mscorlib_ni+0x6d31b8 (709a31b8) (System.Console.ReadLine(), mdToken: 060007bb)
>>> 004000a0 nop
004000a1 nop
004000a2 mov esp,ebp
004000a4 pop ebp
004000a5 ret
There’s no change—the EBP-8 local variable remains active throughout the end of the method. You can even see the instructions that assign 0 to it—at 00400096 and 00400098, but it doesn’t change the relevance lifetime of the local variable. And it makes sense to some extent—the local variable’s eligibility to be considered a local root isn’t affected by whether the code stores a null in it.
This whole discussion, of course, is irrelevant in the Release build. In fact, the local variable wouldn’t even be there. In order to have something to show you, I modified the code slightly:
static void Main(string[] args)
{
int k = int.Parse(Console.ReadLine());
float j = float.Parse(Console.ReadLine());
string s = Console.ReadLine();
Console.WriteLine(s + j + k);
}
…and then compiled it in the Release build, and:
0:000> !gcinfo 002c0085
entry point 002c0070
Normal JIT generated code
GC info 001e17fc
Method info block:
[…edited for brevity…]
Pointer table:
14 5D 27 | 005D..0084 [EBP-14H] a pointer
9C 40 | 001C call [ ESI ] argMask=00
A2 40 | 003E call [ ESI ] argMask=00
B5 40 | 0073 call [ ESI ] argMask=00
9D 40 | 0090 call [ ESI ] argMask=00
FF |
0:000> !u 002c0085
Normal JIT generated code
FreakishTimerBug.Program.Main(System.String[])
Begin 002c0070, size a4
[…edited for brevity…]
002c00be call mscorlib_ni+0x6d1e38 (709a1e38) (System.Console.get_In(), mdToken: 06000772)
002c00c3 mov ecx,eax
002c00c5 mov eax,dword ptr [ecx]
002c00c7 call dword ptr [eax+64h]
002c00ca mov dword ptr [ebp-14h],eax
002c00cd mov ecx,offset mscorlib_ni+0x26a32c (7053a32c) (MT: System.Single)
002c00d2 call 001d201c (JitHelp: CORINFO_HELP_NEWSFAST)
002c00d7 mov esi,eax
002c00d9 mov ecx,offset mscorlib_ni+0x272d34 (70542d34) (MT: System.Int32)
002c00de call 001d201c (JitHelp: CORINFO_HELP_NEWSFAST)
002c00e3 mov edi,eax
002c00e5 fld dword ptr [ebp-10h]
002c00e8 fstp dword ptr [esi+4]
002c00eb mov edx,esi
002c00ed mov dword ptr [edi+4],ebx
002c00f0 push edi
002c00f1 mov ecx,dword ptr [ebp-14h]
002c00f4 call mscorlib_ni+0x20cc70 (704dcc70) (System.String.Concat(System.Object, System.Object, System.Object), mdToken: 060001c7)
002c00f9 mov esi,eax
002c00fb call mscorlib_ni+0x22d380 (704fd380) (System.Console.get_Out(), mdToken: 06000773)
[…edited for brevity…]
Indeed, in the new method, the s variable is stored on the stack, and it becomes alive at 002C00C, immediately after the return value (from EAX) of Console.ReadLine is assigned to it. It remains alive all the way through 002C00F4, where it stored in ECX and passed to String.Concat.