Есть ли стоимость входа и выхода из контрольного блока С#?
Рассмотрим такой цикл:
for (int i = 0; i < end; ++i)
// do something
Если я знаю, что я не буду переполняться, но я хочу проверить против переполнения, усечения и т.д. в части "сделать что-то", мне лучше с блоком checked
внутри или вне цикла?
for (int i = 0; i < end; ++i)
checked {
// do something
}
или
checked {
for (int i = 0; i < end; ++i)
// do something
}
В более общем плане, существует ли стоимость переключения между проверенным и непроверенным режимами?
Ответы
Ответ 1
Если вы действительно хотите увидеть разницу, проверьте некоторые сгенерированные ИЛ. Возьмем очень простой пример:
using System;
public class Program
{
public static void Main()
{
for(int i = 0; i < 10; i++)
{
var b = int.MaxValue + i;
}
}
}
И получим:
.maxstack 2
.locals init (int32 V_0,
int32 V_1,
bool V_2)
IL_0000: nop
IL_0001: ldc.i4.0
IL_0002: stloc.0
IL_0003: br.s IL_0013
IL_0005: nop
IL_0006: ldc.i4 0x7fffffff
IL_000b: ldloc.0
IL_000c: add
IL_000d: stloc.1
IL_000e: nop
IL_000f: ldloc.0
IL_0010: ldc.i4.1
IL_0011: add
IL_0012: stloc.0
IL_0013: ldloc.0
IL_0014: ldc.i4.s 10
IL_0016: clt
IL_0018: stloc.2
IL_0019: ldloc.2
IL_001a: brtrue.s IL_0005
IL_001c: ret
Теперь убедитесь, что мы отмечены:
public class Program
{
public static void Main()
{
for(int i = 0; i < 10; i++)
{
checked
{
var b = int.MaxValue + i;
}
}
}
}
И теперь мы получаем следующий IL:
.maxstack 2
.locals init (int32 V_0,
int32 V_1,
bool V_2)
IL_0000: nop
IL_0001: ldc.i4.0
IL_0002: stloc.0
IL_0003: br.s IL_0015
IL_0005: nop
IL_0006: nop
IL_0007: ldc.i4 0x7fffffff
IL_000c: ldloc.0
IL_000d: add.ovf
IL_000e: stloc.1
IL_000f: nop
IL_0010: nop
IL_0011: ldloc.0
IL_0012: ldc.i4.1
IL_0013: add
IL_0014: stloc.0
IL_0015: ldloc.0
IL_0016: ldc.i4.s 10
IL_0018: clt
IL_001a: stloc.2
IL_001b: ldloc.2
IL_001c: brtrue.s IL_0005
IL_001e: ret
Как вы можете видеть, единственная разница (за исключением некоторых дополнительных nop
s) заключается в том, что наша операция добавления испускает add.ovf
, а не просто add
. Единственные накладные расходы, которые вы получите, - разница в этих операциях.
Теперь, что произойдет, если мы переместим блок checked
, чтобы включить весь цикл for
:
public class Program
{
public static void Main()
{
checked
{
for(int i = 0; i < 10; i++)
{
var b = int.MaxValue + i;
}
}
}
}
Получаем новый IL:
.maxstack 2
.locals init (int32 V_0,
int32 V_1,
bool V_2)
IL_0000: nop
IL_0001: nop
IL_0002: ldc.i4.0
IL_0003: stloc.0
IL_0004: br.s IL_0014
IL_0006: nop
IL_0007: ldc.i4 0x7fffffff
IL_000c: ldloc.0
IL_000d: add.ovf
IL_000e: stloc.1
IL_000f: nop
IL_0010: ldloc.0
IL_0011: ldc.i4.1
IL_0012: add.ovf
IL_0013: stloc.0
IL_0014: ldloc.0
IL_0015: ldc.i4.s 10
IL_0017: clt
IL_0019: stloc.2
IL_001a: ldloc.2
IL_001b: brtrue.s IL_0006
IL_001d: nop
IL_001e: ret
Вы можете видеть, что обе операции add
были преобразованы в add.ovf
, а не только во внутреннюю операцию, поэтому вы получаете вдвое больше "накладных расходов". В любом случае, я предполагаю, что "накладные расходы" будут незначительными для большинства случаев использования.
Ответ 2
Блоки checked
и unchecked
не отображаются на уровне IL. Они используются только в исходном коде С#, чтобы сообщить компилятору, следует ли выбирать контрольные или неконтролируемые IL-команды при переопределении предпочтения по умолчанию конфигурации сборки (которое устанавливается через флаг компилятора).
Конечно, обычно будет разница в производительности из-за того, что для арифметических операций (но не из-за ввода или выхода из блока) выбраны разные коды операций. Ожидается, что проверенная арифметика будет иметь некоторые накладные расходы по соответствующей непроверенной арифметике.
Собственно, рассмотрим эту программу С#:
class Program
{
static void Main(string[] args)
{
var a = 1;
var b = 2;
int u1, c1, u2, c2;
Console.Write("unchecked add ");
unchecked
{
u1 = a + b;
}
Console.WriteLine(u1);
Console.Write("checked add ");
checked
{
c1 = a + b;
}
Console.WriteLine(c1);
Console.Write("unchecked call ");
unchecked
{
u2 = Add(a, b);
}
Console.WriteLine(u2);
Console.Write("checked call ");
checked
{
c2 = Add(a, b);
}
Console.WriteLine(c2);
}
static int Add(int a, int b)
{
return a + b;
}
}
Это сгенерированный ИЛ, с включенными оптимизациями и с непроверенной арифметикой по умолчанию:
.class private auto ansi beforefieldinit Checked.Program
extends [mscorlib]System.Object
{
.method private hidebysig static int32 Add (
int32 a,
int32 b
) cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: add
IL_0003: ret
}
.method private hidebysig static void Main (
string[] args
) cil managed
{
.entrypoint
.locals init (
[0] int32 b
)
IL_0000: ldc.i4.1
IL_0001: ldc.i4.2
IL_0002: stloc.0
IL_0003: ldstr "unchecked add "
IL_0008: call void [mscorlib]System.Console::Write(string)
IL_000d: dup
IL_000e: ldloc.0
IL_000f: add
IL_0010: call void [mscorlib]System.Console::WriteLine(int32)
IL_0015: ldstr "checked add "
IL_001a: call void [mscorlib]System.Console::Write(string)
IL_001f: dup
IL_0020: ldloc.0
IL_0021: add.ovf
IL_0022: call void [mscorlib]System.Console::WriteLine(int32)
IL_0027: ldstr "unchecked call "
IL_002c: call void [mscorlib]System.Console::Write(string)
IL_0031: dup
IL_0032: ldloc.0
IL_0033: call int32 Checked.Program::Add(int32, int32)
IL_0038: call void [mscorlib]System.Console::WriteLine(int32)
IL_003d: ldstr "checked call "
IL_0042: call void [mscorlib]System.Console::Write(string)
IL_0047: ldloc.0
IL_0048: call int32 Checked.Program::Add(int32, int32)
IL_004d: call void [mscorlib]System.Console::WriteLine(int32)
IL_0052: ret
}
}
Как вы можете видеть, блоки checked
и unchecked
- это просто концепция исходного кода - IL-излучение не испускается при переключении между тем, что было (в источнике) a checked
и unchecked
контекст. Какими изменениями являются коды операций, испускаемые для прямых арифметических операций (в данном случае add
и add.ovf
), которые были заключены в тексте в этих блоках. Спецификация описывает, какие операции затронуты:
Следующие операции зависят от контекста проверки переполнения, установленного проверенными и непроверенными операторами и операторами:
- Предопределенные ++ и - унарные операторы (§7.6.9 и §7.7.5), когда операнд имеет интегральный тип.
- Предопределенный - унарный оператор (§7.7.2), когда операнд имеет интегральный тип.
- Предопределенные +, -, * и/двоичные операторы (§7.8), когда оба операнда являются интегральными типами.
- Явные числовые преобразования (§6.2.1) от одного интегрального типа к другому интегральному типу или от float или double к интегральному типу.
И как вы можете видеть, метод, вызванный из блока checked
или unchecked
, сохранит свое тело и не получит никакой информации о том, из какого контекста он был вызван. Это также указано в спецификации:
Проверенные и непроверенные операторы влияют только на контекст проверки переполнения для тех операций, которые содержатся в текстовом виде в токенах ( "и" ). Операторы не влияют на члены функции, которые вызываются в результате оценки содержащегося выражения.
В примере
class Test
{
static int Multiply(int x, int y) {
return x * y;
}
static int F() {
return checked(Multiply(1000000, 1000000));
}
}
использование проверенного в F не влияет на оценку x * y в Multiply, поэтому x * y оценивается в контексте проверки переполнения по умолчанию.
Как отмечено, вышеуказанный IL был сгенерирован с оптимизацией компилятора С#. Те же выводы можно сделать из ИЛ, которые испускаются без этих оптимизаций.
Ответ 3
В дополнение к приведенным выше ответам я хочу уточнить, как выполняется проверка. Единственный метод, который я знаю, - это проверить флаги OF
и CF
. Флаг CF
задается беззнаковыми арифметическими инструкциями, тогда как OF
устанавливается с помощью арифметических инструкций.
Эти флаги могут быть прочитаны с помощью инструкций seto\setc
или (наиболее часто используемый способ), мы можем просто использовать инструкцию перехода jo\jc
, которая будет переходить на нужный адрес, если установлен флаг OF\CF
.
Но есть проблема. jo\jc
является "условным" прыжком, что является полной болью в *** для конвейера CPU. Поэтому я подумал, что может быть другой способ сделать это, например, установить специальный регистр для прерывания выполнения при обнаружении переполнения, поэтому я решил выяснить, как это делает Microsoft JIT.
Я уверен, что большинство из вас слышали, что Microsoft открыла подмножество .NET, которое называется .NET Core. Исходный код .NET Core включает CoreCLR, поэтому я в него вникнул. Код обнаружения переполнения генерируется в методе CodeGen::genCheckOverflow(GenTreePtr tree)
(строка 2484). Можно четко видеть, что команда jo
используется для проверки переполнения подписей и jb
(сюрприз!) Для неподписанного переполнения. Я не запрограммирован в сборке в течение длительного времени, но это выглядит так: jb
и jc
являются теми же инструкциями (оба они проверяют только флаг переноса). Я не знаю, почему разработчики JIT решили использовать jb
вместо jc
, потому что если бы я был разработчиком процессора, я бы сделал предсказатель ветвления, чтобы предположить, что jo\jc
скачки будут очень маловероятными.
Подводя итог, нет никаких дополнительных команд для переключения между проверенным и непроверенным режимами, но арифметические операции в блоке checked
должны быть заметно медленнее, если проверка выполняется после каждой арифметической команды. Тем не менее, я уверен, что современные процессоры могут справиться с этим.
Надеюсь, это поможет.
Ответ 4
"В более общем плане, существует ли стоимость переключения между проверенным и непроверенным режимом?"
Нет, не в вашем примере. Единственные накладные расходы - ++i
.
В обоих случаях компилятор С# будет генерировать add.ovf
, sub.ovf
, mul.ovf
или conv.ovf
.
Но когда цикл находится внутри проверочного блока, будет добавлен add.ovf
для ++i