Странная проверка для this
Разбираясь с этой проблемой, я заметил одну деталь в коде, который генерировал JIT для "модифицированного" метода. Рассмотрим упрощённый вариант.
Пусть есть класс:
public class ThisCheck
{
long state = 0L;
[MethodImpl(MethodImplOptions.NoInlining)]
public void Foo() => Bar(ref this.state);
//[MethodImpl(MethodImplOptions.NoInlining)]
static void Bar(ref long value) => value++;
}
Далее для экземпляра класса вызывается метод Foo:
var inst = new ThisCheck();
inst.Foo();
В режиме Release компилятор делает подстановку Bar в Foo, соответственно для Foo генерируется вот такой код:
; ThisCheck.Foo()
add rcx, 8 // rcx <- ref this.state
inc qword ptr [rcx] // this.state++
ret
Если запретить подстановку (раскомментировав строку), то код для Foo и Bar выглядит так:
; ThisCheck.Foo()
sub rsp, 28h
cmp dword ptr [rcx], ecx
add rcx, 8 // rcx <- ref this.state
call ThisCheck:Bar(byref) // Bar(ref this.state)
nop
add rsp, 28h
ret
; ThisCheck.Bar()
inc qword ptr [rcx] // value++
ret
По сути то же самое, но перед вызовом Bar внутри Foo появляется инструкция:
cmp dword ptr [rcx], ecx
При входе в метод в регистре ecx стандартно размещается this. Соответственно в [rcx] лежит ссылка на таблицу методов класса. Какой смысл сравнивать ссылку на экземпляр класса со ссылкой на таблицу методов класса? Причём результат сравнения далее по коду не используется.
Что это за странная проверка?
Ответы (1 шт):
Какой смысл сравнивать ссылку на экземпляр класса со ссылкой на таблицу методов класса? Причём результат сравнения далее по коду не используется. Что это за странная проверка?
В .NET Runtime таким образом реализована проверка ссылки на null. Вот как это работает:
- адрес экземпляра класса и адрес таблицы методов класса для любой действительной ссылки на экземпляр естественно никогда не совпадают; сравнение выполняется, выставляя соответствующие флаги в регистре флагов, и исполнение инструкций благополучно продолжается; использование результата сравнения не требуется
- если же ссылка содержит null, то происходит попытка обращения к памяти по нулевому адресу; это вызывает исключение, которое среда выполнения соответствующим образом обрабатывает, генерируя NullReferenceException
Таким образом (хотя в это и трудно поверить), компилятор в данном случае добавляет проверку this на null перед вызовом Bar (точнее - перед обращением к this.state). Приблизительно как если бы в C# мы написали:
public void Foo()
{
if (this == null)
throw new NullReferenceException();
Bar(ref this.state);
}
То есть проверка сама по себе не странная, но видеть её для this действительно странно.
Появление этой проверки можно проследить в дампе компиляции.
Вот так выглядит HIR (High-level Internal Representation - высокоуровневое внутреннее представление) для вызова Bar внутри Foo после импорта IL-кода:
[000003] --CXG------- ▌ CALL void ThisCheck.Bar
[000002] ---XG------- arg0 └──▌ ADDR byref
[000001] ---XG--N---- └──▌ FIELD long state
[000000] ------------ └──▌ LCL_VAR ref V00 this
И вот этот же фрагмент после морфинга:
[000003] --CXG+------ ▌ CALL void ThisCheck.Bar
[000010] ---XG+-N---- arg0 in rcx └──▌ COMMA byref
[000006] ---X-+-N---- ├──▌ NULLCHECK byte
[000005] -----+------ │ └──▌ LCL_VAR ref V00 this
[000009] -----+------ └──▌ ADD byref
[000007] -----+------ ├──▌ LCL_VAR ref V00 this
[000008] -----+------ └──▌ CNS_INT long 8 field offset Fseq[state]
Видно как видоизменяется передаваемое в Bar в качестве аргумента выражение. Вместо ref V00.state появляется узел COMMA ([000010]) с двумя дочерними узлами (NULLCHECK V00 и V00+8). Узлы этого типа используются, чтобы, когда это возможно, с меньшими затратами вставить код в середину оператора (не разбивая его на несколько). Для COMMA результат первого дочернего узла будет отброшен после выполнения, а результат выполнения второго вернётся в качестве результата выражения.
Узел [000006] во время морфинга появляется вот с таким пояснением:
Morphing args for 3.CALL:
...
GenTreeNode creates assertion:
[000006] ---X---N---- ▌ NULLCHECK byte
In BB01 New Local Constant Assertion: V00 != null, index = #01
...
т.е. как контроль значения переменной V00 (фактически this != null).
Далее, после преобразования HIR в LIR (Low-level Internal Representation - низкоуровневое внутреннее представление) и некоторых других преобразований, в фазе генерации машинного кода получаем:
Generating: N005 [000005] ------------ t5 = LCL_VAR ref V00 this rcx REG rcx
┌──▌ t5 ref
Generating: N007 [000006] ---X---N---- ▌ NULLCHECK int REG NA
IN0001: cmp dword ptr [rcx], ecx
Generating: N009 [000007] ------------ t7 = LCL_VAR ref V00 this rcx (last use) REG rcx
Generating: N011 [000008] -c---------- t8 = CNS_INT long 8 field offset Fseq[state] REG NA
┌──▌ t7 ref
├──▌ t8 long
Generating: N013 [000009] ------------ t9 = ▌ ADD byref REG rcx
IN0002: add rcx, 8
┌──▌ t9 byref
Generating: N015 [000015] ------------ t15 = ▌ PUTARG_REG byref REG rcx
┌──▌ t15 byref arg0 in rcx
Generating: N017 [000003] --CXG------- ▌ CALL void ThisCheck.Bar REG NA
IN0003: call ThisCheck:Bar(byref)
Таким образом, инструкция cmp появляется именно как реализация NULLCHECK контроля для this.