Странное поведение C# GC для слабых ссылок и нуль-условного оператора

При создании модульного теста для моего кода C#, который работает с WeakReferencesЯ столкнулся с некоторым странным поведением GC - странным, потому что я не смог придумать объяснение этому.

Проблема связана с использованием ?. нулевой условный оператор на объекте, который был получен из моей слабой ссылки после того, как GC собирал его.

Вот минимальный код, который копирует это:

    public class XYZClass
    {
        public string Name { get; set; }
    }

    public class Tests
    {
        public void NormalBehavior()
        {
            var @ref = new WeakReference<XYZClass>(new XYZClass { Name = "bleh" });

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

            XYZClass t;
            @ref.TryGetTarget(out t);

            Console.WriteLine(t == null); //outputs true
        }

        public void WeirdBehavior()
        {
            var @ref = new WeakReference<XYZClass>(new XYZClass { Name = "bleh" });

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

            XYZClass t;
            @ref.TryGetTarget(out t);

            Console.WriteLine(t == null); //outputs false
            Console.WriteLine(t?.Name == null); //outputs false
        }
    }

Поведение не проявлялось, когда этот код запускался с использованием linqpad. Я также проверил скомпилированный код IL (используя linqpad) и все еще не мог распознать ничего плохого.

1 ответ

Решение

Это не имеет ничего общего с нулевым условным оператором. Вы можете легко увидеть это, заменив его обычным доступом членов:

Console.WriteLine(t == null); //outputs false
Console.WriteLine(t.Name == null); //outputs false

Оригинальная ссылка на новый XYZClass объект никогда не выходит "из области видимости" в сборке отладки (и работает под отладчиком). Отключите оптимизацию в LINQPad, и вы также увидите, что t не является нулевым Но обратите внимание, что все это - деталь реализации - в зависимости от особенностей вашей системы, вы можете получить любой результат (например, я получаю то, что вы получаете в 32-битных сборках Debug, но не в 64-битных сборках Debug).

Единственная гарантия того, что вы получите время жизни управляемого объекта в.NET, состоит в том, что сильная ссылка вне финализатора предотвратит сбор объекта. Забудьте все детерминистическое управление памятью - его просто нет. Реализация.NET, которая вообще не имеет сборщика мусора, будет вполне допустимой.

Итак, давайте посмотрим на код, сгенерированный на моей машине, в частности. В 64-битной сборке t.Name == null а также t?.Name == null имеют точно такие же результаты (хотя, конечно, t.Name == null вызовет NullReferenceException вместо возвращения истины). А как насчет 32-битной сборки?

t.Name == null часть существенно короче:

00533111  mov         ecx,dword ptr [ebp-44h]   ; t
00533114  cmp         dword ptr [ecx],ecx  ; null check
00533116  call        00530D28  ; t.get_Name
0053311B  mov         dword ptr [ebp-54h],eax  ; Name string
0053311E  cmp         dword ptr [ebp-54h],0  ; is null?
00533122  sete        cl  
00533125  movzx       ecx,cl  
00533128  call        708B09F4  

Вы можете видеть, что мы используем два регистра (ecx и eax) и два слота стека (-44h и -54h). Что насчет t?.Name == null один?

001F3111  cmp         dword ptr [ebp-44h],0   ; is t null?
001F3115  jne         001F311F  
001F3117  nop  
001F3118  xor         edx,edx  
001F311A  mov         dword ptr [ebp-54h],edx  ; result is false
001F311D  jmp         001F312A  
001F311F  mov         ecx,dword ptr [ebp-44h]  ; t
001F3122  call        001F0D28                 ; t.get_Name
001F3127  mov         dword ptr [ebp-54h],eax  
001F312A  cmp         dword ptr [ebp-54h],0    ; is name null?
001F312E  sete        cl  
001F3131  movzx       ecx,cl  
001F3134  call        708B09F4  
001F3139  nop  

Мы все еще используем те же два слота стека, но требуется еще один регистр - edx. Может ли это быть тем, что мы ищем? Еще бы! Если мы посмотрим, как изначально был создан объект:

001F30A0  mov         ecx,2C0814h  
001F30A5  call        001330F4  ; new XYZClass
001F30AA  mov         dword ptr [ebp-48h],eax  ; tmp
001F30AD  mov         ecx,dword ptr [ebp-48h]  
001F30B0  call        001F0D38  ; tmp.XYZClass()
001F30B5  mov         edx,dword ptr ds:[36B230Ch]  ; "bleh"
001F30BB  mov         ecx,dword ptr [ebp-48h]  
001F30BE  cmp         dword ptr [ecx],ecx  
001F30C0  call        001F0D30  ; tmp.set_Name("bleh")
001F30C5  nop  
001F30C6  mov         ecx,2C0858h  
001F30CB  call        710F9ECF  ; new WeakReference
001F30D0  mov         dword ptr [ebp-4Ch],eax  
001F30D3  mov         ecx,dword ptr [ebp-4Ch]  
001F30D6  mov         edx,dword ptr [ebp-48h]  ; EDX references tmp!
001F30D9  call        709090B0  
001F30DE  mov         eax,dword ptr [ebp-4Ch]  
001F30E1  mov         dword ptr [ebp-40h],eax  

Вы можете видеть, что случается так, что в условно-нулевой версии используется тот же регистр, который использовался для хранения временной ссылки на XYZClass, И именно в этом заключается разница - среда выполнения не может исключать, что edx доступ - это использование временной ссылки, поэтому он защищает ее и сохраняет объект в корне, что предотвращает его сбор.

64-битная версия (и запущенная без отладчика) не видит разницы, потому что она использует другой регистр - на моей конкретной машине 64-битная версия повторно использует rcx (который содержит ссылку на WeakReferenceне XYZClass), и 32-разрядная версия без отладчика повторно используется eax (который содержит ссылку на "bleh"). поскольку edx (а также rdx) никогда не используются в методе, временная ссылка больше не укоренена и может быть бесплатно собрана.

Почему используется версия отладчика edx особенно? Скорее всего, он пытается быть полезным. В середине нулевого условного оператора вы хотите увидеть значение обоих t а также t?.Name, поэтому они должны быть доступны (вы можете увидеть это в Locals как "XYZClass.Name.get вернул"bleh" string").

Опять же, обратите внимание, что это полностью зависит от реализации. В контракте указывается только, когда объект не должен быть возвращен - в нем не указано, когда он будет возвращен.

Другие вопросы по тегам