Некоторое время назад я долго сидел в подсистемах нашего продукта, связанных с чтением метаданных и MSIL-байткода. Совершенно случайно нарвался на определение такой MSIL-инструкции:

public static readonly Opcode No = new Opcode("no.",
  StackBehavior.Pop0, StackBehavior.Push0,
  OperandType.SByte, OpcodeType.Prefix, 2,
  OpcodeValue.No, FlowControl.Meta, false, 0);

Никогда такой опкод раньше не видел, из определения видно что это префикс, а не самостоятельный опкод (а из префиксов всомнились только tail. и constrained., хотя есть и другие). Что еще больше заинтересовало - в классе System.Reflection.Emit.Opcodes из стандартной библиотеки классов .NET Framework такого опкода вовсе не определено. Даже в подробной и вообще очень неплохой книге про MSIL - Expert .NET 2.0 IL assembler - нету ни единого упоминания. А вот в библиотеке Mono.Cecil этот префикс нашелся - значит разработчики метадата-reader’ов/writer’ов точно знали что-то, недоступное всем остальным. Интереса добавляло не только загадочное имя опкода no., но еще и sbyte-операнд (можно не только задать какой-то инструкции загадочный префикс no., но еще и контроллировать его каким-то образом).

Гугл с первой попытки тоже ничего дельного не подкинул, зато завесу тайны опкода 0xFE19, как ни странно, открыл стандарт CLI:

Теперь то все стало понятно - опкод должен позволять отключать все те проверки managed-мира (которые собственно и создают саму “управляемость” среды исполнения), с которыми живут и исполняются практически все CLR-приложения - автоматические проверки на null, проверки выхода за границы массивов и проверки типов (при даункастах и записях в крайне мерзкие ковариантные массивы, слизанные из JVM).

Конечно же, байткод с префиксом no. перестает быть “верифицируемым”, но кого это волнует? В тот момент реальный мир для меня перестал существовать и через 10 минут на базе Mono.Cecil собрался реврайтер в один цикл, вставляющий no.-префикс во всех местах, где это имеет смысл…

ExecutionEngineException вернул меня в реальность и развеял весь кураж. ILDasm отказался показывать опкод 0xFE19, хотя по потоку байтов должен был получиться корректный no. с операндом. Подумал, что Mono.Cecil что-нибудь неправильно emit’ит - попробовал написать no. руками и собрать ILAsm’ом - неизвестный токен, говорит.

Позже нагуглился тред из прошлого на MSDN-форумах, который еще раз подтвердил мысли - не смотря на присутствие в стандарте CLI, данный префикс просто никогда не был реализован ни в Rotor, ни в CLR…

Мне стало предельно грустно, но захотелось написать этот пост, помечтав на тему того, что бы можно было сделать с префиксом no.. Да он порождает неуправляемый код, но ведь им реально можно было бы срезать значительную часть оверхеда от управляемости. Добавление элемента в List<T> могло бы просто записать элемент во внутренний массив с одной проверкой границ, а не делать две проверки (одну в своей реализации и вторую неявную при записи в массив) + бесполезную проверку типа для массива с элементами ссылочного типа (не смотря на то, что сам List<T> инвариантен). Примеры бесполезных проверок можно приводить бесконечно. Да, все эти проверки добавляют ровный слой O(1) оверхеда поверх вашего кода и серъезной проблемы не создают в 99% случаев, но почему бы не уметь от них при желании избавляться?

Понятное дело, что у JITа (тем более у JIT’а без фазы интерпретации, профиляции и миллиона других штук, которые есть во всяких HotSpot’ах) очень узкие временные рамки и он не может себе позволить множество оптимизаций (но в CLR это доведено до крайности - примитивнейшее заполение int[] числом 42 циклом от конца массива до начала из-за проверок попадания в границы занимает в 2 раза больше времени, чем от начала до конца). Более того, JIT-компиляция никогда не сможет делать некоторые оптимизации, требующие значительного статического анализа кода. И это вполне нормально, мощь JIT-компиляции раскрывается в оптимизациях, основанных на профилировании или знании специфики какой-нибудь платформы. Но нельзя и останавливаться на этом, многие трансформации можно произвести с самим байткодом, а можно и оставлять hint’ы JIT-компилятору чтобы облегчить ему работу.

Возникает более глубокие вопросы - кто должен определять понятие “верифицируемости”? Так ли важно иметь верифицированный/управляемый код, особенно на мобильных платформах? Что если понятие верифицируемости будет отделено от виртуальной машины? Часто-ли код, исполняемый в VM полностью верифицируемый?

Пример из жизни - наш продукт не часто, но все же использует P/Invoke (доступ к Win32 API и LevelDB, например). Это означает, что неуправляемый код, заполучив указатель на managed-кучу, может сделать сколько угодно деструктивные действия. Волнует-ли это? Вообще нет. Случаются-ли проблемы в реальной жизни? Нет, unmanaged код честно себя ведет и не делает ничего плохого. Может-ли unmanaged-код устроить дестрой? Конечно. Можно-ли считать весь продукт верифицируемым? Конечно нет.

Очень жаль, что префикс no. не был реализован в CLR. Это могло бы стимулировать возникновение альтернативных тулов для верификации/умных бэкендов компиляторов, использующих no. чтобы срезать доказуемо бесполезные managed-проверки. Частично проверки можно устранить и существующими сейчас unmanaged-средствами, например, используя ldind (разыменование) вместо ldelem (доступ к элементу массива, то же разыменование + проверка границ), но работают такие техники преимущественно с unsafe-типами (не-generic типами-значениями с полями unsafe-типа).

Еще одно проявление управляемости, которое можно было бы научиться устранять через hint’ы в байткоде - разрешить newobj выделять managed-объекты на стеке (или initobj научить кушать токены ссылочных типов). Тогда можно было бы производить escape-анализ до всякой JIT-компиляции и устранять излишнее давление на сборщик мусора, опять же теряя классическую верифицируемость CLI. Сейчас чтобы использовать аллокации на стеке, C++/CLI (для unmanaged-классов), например, использует initobj и типы-значения, которые затем передаются ниже по стеку по значению или по unmanaged-ссылке. Но такой подход имеет все проблемы типов-значений - unmanaged-классы C++/CLI не могут реализовывать managed-интерфейсы (их потребовалось бы боксить). Таким образом полноценного способа выделить managed объект (с настоящим object header’ом) на стеке сейчас в CLR не существует.

Подводя итог хочется сказать только одно - было бы интересно посмотреть на современную VM для управляемых языков, позволяющую легко контроллировать эту самую управляемость через байткод. А что думаете вы?