http://habrahabr.ru/company/enterra/blog/243371/
С каждым днём кроссплатформенная разработка под .NET становится всё более реальной. А после недавнего
анонса официальной поддержки Linux/MacOS счастливое будущее стало ещё немножечко ближе. Вышеприведённая картинка утратила свою былую актуальность, ведь исходники теперь будут под MIT. Впрочем, писать кроссплатформенные .NET-приложения можно достаточно давно — в этом нам помогает Mono. Но вот отношение к нему в сообществе довольно неоднозначное. Мне зачастую приходится слышать изречения вроде «Mono тупит, под него всё в три раза медленнее работает» или «Под Mono вообще нормально ничего не запускается». Причём очень редко доводится слышать от этих людей конкретные факты. Вопросы «А что конкретно тупит?» или «А что конкретно не работает?» повергают их в ступор. Не всех (некоторые способны на конструктивную дискуссию), но большинство. Чаще всего начинаются возмущённые ответы в духе «Да вообще ничего не работает! А если и работает, то очень медленно!». В конце беседы создаётся впечатление, что каждая конечная машинная команда под Mono работает в несколько раз медленнее, а в половине исходников стоят
throw new Exception()
.
В этом посте мне хотелось бы немножко поделиться опытом. Не так давно мы портировали наш продукт
PassportVision (
анонс на Хабре) под Linux. Могу заявить, что работает он вполне нормально. Да, чутка медленнее, чем под Windows на классическом .NET от Microsoft (далее — MS.NET). Но работает вполне стабильно, а падение производительности не принципиальное. При этом продукт у нас достаточно большой и вполне попадает под категорию enterprise, а возможности C#/.NET мы используем на полную катушку. Так что завести большое серверное приложение под .NET реально — было бы желание. Также мне довелось беседовать с разными разработчиками, которые пишут что-то под Mono — истории в большинстве своём успешные.
Но почему же тогда встречается столько негатива в сторону Mono? Я считаю, что проблема в том, что люди не особо хотят разбираться в разнице между рантаймами. Запустили разок какое-нибудь .NET-приложение под Linux на Mono 2.4, а оно с ходу не запустилось — всё, Mono целиком плохой, не будем его использовать. А в итоге виноват оказывается один-единственный метод, у которого реализация немного отличается от MS.NET. Новые версии Mono выходят раз в пару месяцев, реализацию уже давно поправили, но люди всё равно продолжают ходить и хаять бедный Mono, не желая разбираться в деталях.
Сегодня я приведу несколько примеров того, чем вообще могут отличаться разные рантаймы. Конечно же, все отличия привести не получится — про это целую книгу написать можно. Да и суперкрутые проблемы из production-кода тоже привести не выйдет, зачастую слишком уж сложно их отделить от контекста программной архитектуры и привести в виде маленького понятного куска кода. Моя сегодняшняя задача — просто донести мысль того, что может отличаться в Mono и MS.NET. Надеюсь, это поможет начинающим Mono-программистам шире взглянуть на возникающие проблемы и начать с ними разбираться, вместо того чтобы развернуться и уйти со словами «Mono тупой».
Пример 1. Разные версии компилятора
Начнём с очень простого примера. Допустим, у нас есть некоторая C#-программа, а мы её хотим скомпилировать. Как мы знаем, стандартный компилятор просто оттранслирует наш C#-код в соответствующий ему IL-код и оформит в виде сборки. Причём процесс трансляции не включает в себя какие-то особо хитроумные оптимизации, ведь за них отвечает JIT. Трансляция достаточно проста, а опытный .NET-разработчик зачастую может примерно прикинуть, какие же IL-команды получатся в итоге. Но отчего-то многие разработчики уверены, что этот процесс
однозначен. Я имел несколько достаточно горячих дискуссий, в которых мне пытались доказать, что «существует ровно один способ отобразить исходную программу в IL». Обычно люди мотивируют это замечательным аргументом
«Есть же спецификации!». Хочу вас заверить, что в спецификациях написано много чего полезного, но ничего про единственный вариант трансляции произвольной программы там не сказано. Причём в разных версиях компилятора логика трансляции может отличаться. Я подобрал очень простой пример, который это иллюстрирует. Рассмотрим следующий код:
var numbers = new int[] { 1, 2, 3 };
В Mono 2.10+ и MS.NET мы увидим примерно следующее:
IL_0000: ldc.i4.3
IL_0001: newarr [mscorlib]System.Int32
IL_0006: dup
IL_0007: ldtoken field valuetype '<PrivateImplementationDetails>{de495e46-bf42-4605-a020-39ddddfe413c}'/'$ArrayType=12' '<PrivateImplementationDetails>{de495e46-bf42-4605-a020-39ddddfe413c}'::'$field-0'
IL_000c: call void class [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
IL_0011: stloc.0
// ...
.field assembly static valuetype '<PrivateImplementationDetails>{de495e46-bf42-4605-a020-39ddddfe413c}'/'$ArrayType=12' '$field-0' at D_00004000
// ...
.data D_00004000 = bytearray (
01 00 00 00 02 00 00 00 03 00 00 00) // size: 12
Как видно из примера, значения исходных элементов хранятся в специальном массиве байт. А вот во времена Mono 2.4.4 (можете не верить, но по сей день есть люди, которые им пользуются) транслятор был не настолько умён:
IL_0000: ldc.i4.3
IL_0001: newarr [mscorlib]System.Int32
IL_0006: dup
IL_0007: ldc.i4.0
IL_0008: ldc.i4.1
IL_0009: stelem.i4
IL_000a: dup
IL_000b: ldc.i4.1
IL_000c: ldc.i4.2
IL_000d: stelem.i4
IL_000e: dup
IL_000f: ldc.i4.2
IL_0010: ldc.i4.3
IL_0011: stelem.i4
IL_0012: stloc.0
На самом деле тут происходит следующее:
var numbers = new int[3];
numbers[0] = 1;
numbers[1] = 2;
numbers[2] = 3;
Функционально ничего не поменялось, но теперь у нас имеется осознание того, что разные версии компилятора могут выдавать различный код. Но этот пример не настолько интересен, т. к. поведение кода осталось прежним (если не считать небольшого проседания производительности для старых версий Mono). Давайте перейдём к более интересным примерам.
Пример 2. Отличия в работе с IL
Практика показывает, что простой «тривиальный» код работает примерно одинаково как под MS.NET, так и под Mono. А проблемы чаще всего начинаются, когда в коде появляются не такие уж и тривиальные вещи. Не так давно Джон Скит написал замечательный пост
«When is a string not a string?» (
русский перевод от
impwx). Если кратко, то содержание поста сводится к рассмотрению следующего примера:
[Description(Value)]
class Test
{
const string Value = "X\ud800Y";
}
Строка в C# представляет собой последовательность слов в UTF-16. А значение
"X\ud800Y"
не особо хорошее, т.к. включает в себя старшее слово суррогатной пары
0xD800
, после которого должно бы идти младшее слово (интервал
0xDC00..0xDFFF
), но вместо него идёт
Y
(
0x0059
). Проблемы начинаются из-за того, что в IL-коде для хранения аргументов конструктора атрибута используется UTF-8. Впрочем, у Джона Скита всё очень хорошо расписано, всем советую прочитать оригинальный пост.
Меня заинтересовало, как же будут себя вести MS.NET и Mono в этой непростой ситуации (
подробная заметка). А вести они себя будут по-разному. Первое различие можно увидеть во время компиляции. MS.NET положит значение строки в метаданные в виде
58 ED A0 80 59
, а Mono — в виде
58 59 BF BD 00
(оба значения являются невалидными UTF-8 строчками). Второе различие можно пронаблюдать запустив полученные приложения. MS.NET сможет запустить обе версии и успешно достанет значение аргумента атрибута (в виде
0058 fffd fffd 0059
и
0058 0059 fffd fffd 0000
соответственно), а Mono поперхнётся настолько невалидной строкой и вернёт
null
в каждом из случаев. Из-за этого маленький пример Джона Скита сразу упал, когда я попытался запустить его под Mono.
Проблемы заключаются в различной реализации конвертации строк между кодировками. Мне этот пример нравится тем, что при своей минималистичности он показывает, что MS.NET и Mono могут как формировать различный IL-код при компиляции, так и по-разному с ним работать при запуске приложения.
Пример 3. Баги в Mono
Да, в Mono есть
баги. И да, их не очень мало, приходится с этим жить. К счастью, попадать на них приходится нечасто, а из открытых багов не так много критичных. Расскажу вам одну историю: при портировании PassportVision на Mono я наткнулся на один не очень приятный момент. Мне пришлось заниматься кодогенерацией и часть логики создавать на лету в зависимости от ряда условий. Я создавал новые типы через
TypeBuilder, а типы эти реализовывали определённые интерфейсы. Я узнал
очень много нового про генерацию кода в Mono, но большую часть очень сложно кратко поведать в отрыве от контекста. А вот один баг воспроизводился очень легко: метод
TypeBuilder.CreateType()
не проверял область видимости объявленных интерфейсов. Т. е. код
private interface IFoo {}
// ...
void Main()
{
var assemblyBuilder = Thread.GetDomain().DefineDynamicAssembly(
new AssemblyName("FooAssembly"), AssemblyBuilderAccess.Run);
var moduleBuilder =
assemblyBuilder.DefineDynamicModule("FooAssembly");
TypeBuilder typeBuilder = moduleBuilder.DefineType("Foo",
TypeAttributes.Public,
typeof(object), new[] { typeof(IFoo) });
typeBuilder.CreateType();
}
падал под MS.NET (как ему и положено), но зато прекрасно отрабатывал под Mono. Меня такое положение вещей не очень устраивало, поэтому я пошёл и завёл
баг. Надо отдать ребятам из Xamarin должное —
поправили они его через 5 дней. Если вы пользуетесь Mono 3.8.0 (которая вышла в августе), то у вас этот баг ещё есть, а вот Mono 3.10.0
вышел с исправлением.
Данная проблема не особо критична, но ряд неудобств она мне всё-таки доставила. А ведь в баг-трекере всё ещё висит
около 5000 открытых проблем. Поэтому нужно держать в уме, что определённые ошибки в Mono могут быть и что от версии к версии поведение некоторых мелких моментов может меняться (возможно, из-за моего баг-репорта у кого-то перестало что-то работать после обновления). Если есть возможность, то лучше сидеть под последней стабильной версией Mono.
Пример 4. Баги в .NET
После таких разговоров обычно начинают лететь камни в сторону Mono: мол, какой-же он плохой, баги в нём есть. И все как-то забывают, что в Microsoft баги тоже делают. Следующая история стоила определённого количества нервных клеток одному моему товарищу. После очередного коммита билд-сервер сообщил, что тесты упали. Причём на рабочей машине они на отличненько проходили, а вот на билд-сервере падали. Я опущу увлекательное описание того, как происходил поиск баги, и перейду к сути: на билд-сервере и рабочей машине стояли разные версии .NET Framework: 4.0 и 4.5. А сама бага заключалась в том, что строчка
new Uri("http://localhost/%2F1").AbsoluteUri
выдаёт разный результат в зависимости от TargetFramework (
подробная заметка). В 4.0 была
бага с экранированием слеша (он же
%2F
): данная строчка возвращала
http://localhost//1
. В 4.5 багу поправили в соответствии с
RFC 3986, новый результат —
http://localhost/%2F1
.
А что же в Mono? А там была аналогичная
бага, которую
поправили в августе (исправление включено в Mono 3.10.0). Этот пример учит нас тому, что одинаковые проблемы могут возникать в различных реализациях .NET и исправляться с новыми версиями. Действительно кроссплатформенное приложение должно либо не использовать менявшуюся от версии к версии функциональность платформы, либо использовать её очень грамотно, чтобы оставаться работоспособным под различными рантаймами.
Пример 5. Тонкости реализации стандартных классов
Приведу ещё одну поучительную историю. Ребята из одной хорошей фирмы как-то раз решили использовать структуры в качестве ключей для хеш-таблиц. Ничто не предвещало беды, приложение работало нормально. Немного тормозило, но не так, чтобы прям слишком критично. А потом в один прекрасный день ребята решили запустить своё приложение под Mono. И о чудо: оно начало работать в разы быстрее. «Ох, какой хороший Mono, как же быстро под ним всё работает», — обрадовались ребята. Единственное, не давала покоя мысль, что не должно так быть, что где-то что-то не так.
Чтобы понять данный пример, нужно немножко почитать про реализацию GetHashCode у структур в MS.NET (
хороший хабрапост). Если кратко, то есть две версии для хеш-функции: одна для структур без ссылочных полей и свободного пространства между полями, а другая — для всех остальных. Как мог догадаться вдумчивый читатель, со структурой-ключом у наших ребят было не всё хорошо (а именно, были «дырки» между полями; прописать явный GetHashCode() никто не удосужился). А в этом случае используется вторая версия хеш-функции, которая базируется на основе первого поля структуры. Рассмотрим на примере:
var a1 = new KeyValuePair<int, int>(1, 2);
var a2 = new KeyValuePair<int, int>(1, 3);
Console.WriteLine(a1.GetHashCode() == a2.GetHashCode());
var b1 = new KeyValuePair<int, string>(1, "x");
var b2 = new KeyValuePair<int, string>(1, "y");
Console.WriteLine(b1.GetHashCode() == b2.GetHashCode());
Данный код выведет
False True
под MS.NET. Хеши
a1
и
a2
буду различаться (они будут считаться на основе всех полей), а хеши для
b1
и
b2
совпадут (они будут считаться только на основе первого поля). Разработчики Mono решили не заморачиваться с кучей разных версий хеш-функции: они написали одну, которая работает на основе всех полей (см.
GetHashCode и
InternalGetHashCode). Соответственно, приведённый код выведет
False False
под Mono, ведь все хеши будут различными.
Вернёмся к нашим весёлым ребятам с хеш-таблицей. Если посмотреть на ситуацию под правильным углом, то их приложение не летало под Mono, а тормозило под MS.NET. А тормозило оно из-за того, что очень много ключей имело одинаковый хеш из-за совпадающего первого поля. Казалось бы, мелочь, но на производительность она оказала достаточно сильное влияние.
Знаете, когда я кому-то начинаю рассказывать про внутренности .NET, то меня часто упрекают фразами «Да зачем всё это знать? Подобные детали внутренних реализаций
никогда в реальной жизни не пригодятся». А я отвечаю: «Ну-ну, разумеется, не пригодятся никому и никогда».
.NEXT
Подобные истории я могу травить очень долго, но в этом посте мне хотелось лишь обозначить общую проблематику и заставить людей ещё раз подумать. Подумать о том, что живём мы в сложном мире, в котором ваша C#-программа может работать по-разному в зависимости от окружения. Разница в поведении упирается во вполне конкретные вещи, с которыми можно разобраться, и добиться того, чтобы .NET-приложение работало быстро и стабильно под разные платформы.
Если вы хотите послушать больше весёлых историй, то приходите на мой доклад в рамках конференции
.NEXT (8 декабря 2014, Москва, Radisson Славянская). Я продолжу обсуждать тему разных платформ, расскажу про особенности Mono по работе с памятью и отличия в реализациях JIT-компиляторов. А ещё я весь день буду бродить по площадке, так что меня можно будет поймать и пообщаться на тему .NET.
Также надеюсь, что у Хабражителей хватает своих весёлых историй. Буду рад их послушать =)