Продолжим изыскания хороших и не очень нововведений в .Net 4.0. На этот раз мы рассмотрим Memory Mapped Files, MemoryCache, CodeContracts, Parallel LINQ и Managed Extensibility Framework (MEF).
Memory Mapped Files
В MSDN есть описание работы MMF, датированное 93м годом. Понятно, что то описание не для .Net, - его тогда и в помине не было. Но сейчас добавили доступ к нативной реализации этого принципа, который, судя по всему, активно используется в Windows.
Основное отличие от обычного подхода в работе с файлами заключается в том, что файл де факто может не существовать, а если он и существует, то все равно работа ведется с образом файла, находящимся в памяти. Во втором случае это сродни кешированию. Доступ к файлу может осуществляться как в обычном потоковом режиме, так и в режиме произвольного доступа, когда вы можете обращаться к произвольной позиции в файле для чтения или записи, что может быть чрезвычайно полезно при решении некоторых задач.
Но для начала предлагаю проверить производительность нового механизма в стандартных задачах, а именно: будем писать, а потом читать большой файл обычным FileStream и MMF, после чего сравним время выполнения.
string magicWords = "Я люблю людей";
using (MemoryMappedFile m = MemoryMappedFile.CreateFromFile("d:\\TestFile.log", FileMode.OpenOrCreate, "test", 2600000000)) {
using (MemoryMappedViewStream viewStream = m.CreateViewStream()) {
using (StreamWriter streamWriter = new StreamWriter(viewStream)) {
for (long i = 0; i < 100000000; i++){
streamWriter.WriteLine(magicWords);
}
}
}
}
using (FileStream fs = File.Open("d:\\TestFile.log", FileMode.OpenOrCreate)) {
using (StreamWriter streamWriter = new StreamWriter(fs)) {
for (long i = 0; i < 100000000; i++) {
streamWriter.WriteLine(magicWords);
}
}
}
Первое неудобство, которое я заметил в MMF, - для создания файла необходимо задавать размер, и выходить за его рамки потом нельзя, иначе будет ошибка. Благо в моем случае вычислить размер было несложно. Также пробовал реализацию использования MMF без StreamWriter. Результаты следующие: MMF - 155 сек, MMF без StreamWriter - 135 сек, FileStream - 109 сек.
Отдельно хочу отметить поведение MMF: сначала на диске резервируется заданное место, для чего создается пустой файл, затем все данные пишутся сначала в память (естественно, приложение активно поедает 2 Гб памяти), и только на выходе из using данные записываются на жесткий диск. Похожая ситуация наблюдается и с чтением файлов - FileStream быстрее, чем MMF. Хотя официально говорится, что MMF удобно применять для больших файлов, мне это показалось не особенно удачным вариантом.
Также в Интернете встречал информацию, что MMF хорошо использовать вместо MemoryStream, так как работает быстрее. На поверку это тоже миф: работают они примерно с одинаковой скоростью.
Теперь поговорим о плюсах MMF. Первый несомненный плюс - это возможность произвольного доступа к файлам. Выглядит это так:
int bufferSize = 1000;
using (MemoryMappedFile m = MemoryMappedFile.CreateFromFile("d:\\TestFile.log", FileMode.Open)) {
using (MemoryMappedViewAccessor viewAccessor = m.CreateViewAccessor()) {
for (int i = 0; i < 50000000; i++){
byte[] bytes = new byte[bufferSize];
viewAccessor.ReadArray(0, bytes, i * bufferSize, bufferSize);
bytes = Encoding.UTF7.GetBytes(Encoding.UTF8.GetString(bytes));
viewAccessor.WriteArray(0, bytes, i * bufferSize, bufferSize);
}
}
}
В данном случае мы обрабатываем файл, меняя кодировку текста с UTF8 на UTF7, читая буфером по 1000 байт. В случае FileStream у нас бы не было возможности произвольно бегать по файлу и модифицировать его, нам надо было бы затянуть его целиком в память и потом записать поверх старого либо сделать новый файл, писать в него и после этого его переименовывать. И тот и другой вариант не всегда применимы.
Еще более удобен MMF, если надо обрабатывать не весь файл, а только его часть. Единственное ограничение в том, что запись производится поверх старых данных, т.е. нет возможности вставки в середину файла, а также удаления из середины файла. Иными словами, чтобы подобная потоковая обработка хорошо выполнялась, нужно, чтобы обработчик на N байт данных на входе давал ровно N на выходе. Это не сильно большое ограничение, потому что можно использовать некие стандартизированные размеры для сериализованного вида ваших структур или закладывать в файл максимальный размер структуры, а остаток свободного места забивать пробелами.
Например, с использованием произвольного доступа можно построить очень большое дерево, если структуру поместить в память, а данные на диск через MMF с произвольным доступом. Дерево будет работать достаточно быстро. Можно, конечно, и его структуру поместить на диск, тогда дерево можно растить еще больше, но работать оно будет медленнее, да и для таких экстремальных случаев, скорее всего, потребуется индивидуальное решение.
Еще один плюс - это возможность передачи данных между приложениями. Делается это с помощью задания имени для MMF и подключения к этому MMF по его имени из другого приложения. Если MMF сделали с возможностью доступа из разных приложений, то это автоматически делает его в достаточной степени ThreadSafe структурой, что может быть полезно даже в рамках одного приложения, если с одним файлом работают из нескольких потоков.
В заключение скажу, что MMF довольно интересный механизм, который нужен не всем, но тем, кому нужен, может принести много пользы.
MemoryCache
В ASP.Net есть класс, который реализует кеширование, и его довольно часто используют, хотя подключение ASP к приложениям, не относящимся к Web, выглядит довольно странно. Чтобы решить данную проблему, Microsoft добавили новый класс System.Runtime.Caching.MemoryCache, который призван обеспечить доступ к механизмам кеширования, не прибегая к подобным костылям. Что из этого получилось, сейчас узнаем.
Буквально первые результаты поиска привели меня к тому, что как надо оно не работает. Если конкретно, то не работает параметр СacheMemoryLimit, т.е. нельзя ограничить размер кеша, - он растет по своему усмотрению, что ты ему ни задавай, и не чистится. "Эпик фейл".
Есть второй параметр, PhysicalMemoryLimitPercentage, который задает примерно то же самое, но в процентах от доступной памяти. Правда, в процентах от чего он считается, узнать сложно. Официально - от количества установленной на компьютере памяти, реально - от какой-то другой цифры. К тому же он int и задать значение меньше, чем 3, у меня не получилось, т.к. при установке меньшего значения оно поднималось до 3.
В итоге, для примера, 3% на машине с 4Гб памяти кеш начинал чиститься в районе 30 мегабайт, на машине с 128 Гб - в районе 900 мегабайт. При том, что максимальное значение, которого удалось достичь для первой машины, было в районе 1.2 Гб, дальнейшая очистка не производилась, хотя это явно больше 3%. Иными словами, использовать этот параметр для контроля размера кеша не получится, потому что поведение у него непонятное либо реализация с ошибками, что в нашем случае равнозначно.
Ниже пример для многопоточной реализации заполнения кеша. Оригинал взят отсюда:
const int objSize = 1024 * 66;
const int cacheOperationCount = 25;
const int totalOpCount = 25000;
const int workerCount = 4;
const bool forceGc = true;
var cache = new MemoryCache(
"test-cache",
new NameValueCollection
{
{ "pollingInterval", "00:00:05" },
{ "cacheMemoryLimitMegabytes", 10.ToString() },
{ "PhysicalMemoryLimitPercentage", 10.ToString() }
});
using (cache) {
for (var i = 0; i < totalOpCount / cacheOperationCount; i++) {
Parallel.For(
0, cacheOperationCount, new ParallelOptions { MaxDegreeOfParallelism = workerCount },
p => {
Thread.Sleep(100);
var buffer = new byte[objSize];
cache.Add(
new CacheItem(Guid.NewGuid().ToString(), buffer),
new CacheItemPolicy { RemovedCallback = arguments => _evictCount++ }
);
});
if (i % 10 == 0) {
if (forceGc) ForceGarbageCollection();
Report(i, cacheOperationCount, objSize);
Thread.Sleep(250);
}
}
}
Еще один интересный момент: когда кеш решает, что надо удалить часть объектов, он их удаляет из своей структуры, но не передает GC и не форсирует вызов GC. Т.е. по факту память не очищается, только кеш перестает думать, что он использует ее, но физически легче от очистки станет только после того, как пройдет GC.
Ну и последний момент про кеш. На этот раз, как ни странно, хороший. Алгоритм выбора элементов для удаления из кеша неплохой (по крайней мере, мне так показалось при разборе reflector'ом). Если я правильно его понял, то значимость элемента кеша - это смесь частоты использования и размера элемента. Но оценить, насколько хорошо это работает, не получится из-за проблем, описанных выше.
Я надеюсь, что в .Net 4.0 SP1 проблемы с MemoryCache устранят, и тогда можно будет снова попробовать им воспользоваться, а пока это никому не нужная безделушка.
Parallel LINQ
Во многом мой обзор PLINQ будет пересекаться с Parallel.For, потому что они обеспечивают примерно один и тот же функционал. Априори у меня были мысли, что если они вышли одновременно и делают почти одно и то же, то и работать они должны одинаково. Ан нет, не тут то было.
И опять первое, что меня заинтересовало, как и в случае с Parallel.For, это использование ThreadPool. И да, PLINQ выполняется, используя потоки из ThreadPool, что сразу снижает его ценность для меня, и сейчас поясню почему.
Раньше я активно использовал ThreadPool для выполнения на нем своих задач, но потом начали появляться странные и непонятные ошибки. Например, сервис не обрабатывал входящие соединения от других частей системы, при том что загрузка системы была на среднем уровне. Оказалось все просто - очень многие стандартные классы .Net используют ThreadPool для реализации своих потребностей, а он один на все приложение и двух быть не может, в частности, на нем работают Таймеры, обработка входящих запросов, в случае если слушается какой-то порт. Скорее всего, и в ASP запросы обрабатываются там же - это не проверял.
Из-за того что ресурсы ThreadPool ограничены, а желающих много, особенно если и вы сами еще добавили нагрузку на него, то наступает момент, когда он перегружается и некоторые потоки запускаются не сразу. Из-за этого можно получить большое число плавающих и неотлаживаемых ошибок. Именно поэтому ручное использование ThreadPool под полным запретом, а применение использующих его элементов по возможности ограничено.
Это было немного лирики. Для проверки производительности воспользуемся уже готовым тестом с математическими вычислениями из Parallel.For. В итоге получаем, что скорость PLINQ приблизительно равна Parallel.For и больше, чем у обычного For и обычного LINQ, которые держатся примерно на одном уровне. Здесь несколько иной подход в определении уровня распараллеливания - у меня всегда сразу запускалось 4 потока, при том что на процессоре 4 потока вычисления, что мне кажется очень логичным и правильным.
Интересно было поведение PLINQ в многопоточном режиме, в котором Parallel.For работал из рук вон плохо. У PLINQ с этим все немного лучше. В тесте, где математические операции выполняются в 10 потоках, каждый из которых пересчитывает весь массив, число потоков не было стабильным и постоянно варьировалось от 5-10 до 20 в зависимости от загрузки системы. По факту выполнялись все мои потоки, хотя первые из запущенных шли немного быстрее остальных.
Отмечу еще один интересный момент - судя по всему, решение о запуске потока принимается на основе загрузки всей системы, а не ресурсов под конкретное приложение, потому что для полной загрузки всех четырех ядер системы вполне хватало 5-10 потоков. Так оно и держалось, но после того как я урезал приложение до использования только одного ядра, число потоков быстро возросло до максимальных 20. Кстати, больше двух потоков в многопоточном режиме мне не удалось добиться никакими способами. Потоки добавлялись так же по одному в секунду при наличии ресурсов системы.
То, что он смотрит на загрузку системы и в зависимости от этого ускоряется или замедляется, сам по себе может быть и благом и злом, потому что способа поднять приоритет этой задачи нет. Если надо быстро что-то выполнить, а система загружена, то PLINQ может выбрать вариант медленного выполнения.
Если коротко подвести итог, то область применения PLINQ такая же, как Parallel.For, со всеми плюсами и минусами параллельного выполнения, с тем лишь ограничением, что используется ThreadPool. В крупных приложениях повсеместно использовать нежелательно.
CodeContracts
Это развитие идеи программирования по контракту. Если коротко, то это расширение спецификации компонентов системы. Например, в стандартную спецификацию функции входит перечень и типы входных и выходного параметров, а также имя функции. С помощью Code Contracts можно также указать, например, что один из параметров должен быть не null, а второй находиться в диапазоне от 0 до 100. Т.е. это то, что сейчас, как правило, реализуется с помощью конструкций if then throw.
Отдельного внимания заслуживает статический анализатор кода на соответствие контракту, т.е. все явные вызовы с неподходящими значениями будут найдены и отмечены. Однако для получения такой замечательной штуки необходимо скачать и установить дополнительное расширение.
К несчастью, у статического анализатора есть 3 проблемы, которые сводят пользу от него на нет. Первая - это то, что при нахождении неправильного вызова, он генерирует не Build error, а всего лишь безобидный Warning. Вторая - это отлов только явных вызовов, т.е. если передавать неправильное значение, которое было записано в некую переменную строчкой выше, то он этого не заметит. И последнее - это скорость работы. Нагружает комп оно прилично, что вкупе с нелегкой VS может сделать работу невозможной.
Выглядит это примерно так:
public class OrderItemContract {
private string _itemName;
private decimal _price;
public string ItemName {
get {
Contract.Ensures(!string.IsNullOrEmpty(_itemName));
return _itemName;
}
set { _itemName = value; }
}
public decimal Price {
get { return _price; }
set {
Contract.Requires(value > 0, "value must be grater then 0");
_price = value;
}
}
}
При недолгом пользовании данной фичей у меня создалось впечатление незаконченности, примерно как от beta-версии. Завелась она далеко не сразу и при этом ругалась абсолютно неинформативными ошибками. Также очень не понравилось, что описание контракта находится в теле функции, а не задается атрибутами, как это было бы логично, что абсолютно перечеркивает возможность написания интерфейса с указанием контрактных обязательств и последующую передачу его на реализацию разработчикам, что можно отнести к основным плюсам CodeContracts как методики.
Из плюсов могу отметить, что в Runtime работает оно довольно быстро, проверка на null отработала немного быстрее, чем привычный if then throw.
Итого, реализация не ахти. Если в дальнейшем ее разовьют и доведут до ума, то можно будет о чем-то говорить, но на текущий момент от такой вещи скорее будет больше вреда, чем пользы.
Managed Extensibility Framework
Это реализация DI-контейнера от Microsoft. Если быть совсем точным, то это второй или третий компонент в данной области. До этого были Unity и Add-In, которые делали по большому счету то же самое, но немного по-другому.
Не буду углубляться в то, что такое Dependency Injection, зачем и когда оно нужно. Скажу только, что подобных фреймворков довольно много и выбирать надо по большей части "под себя". Естественно, по мнению Microsoft, их реализация самая хорошая, учитывает ошибки всех других реализаций и не повторяет их :) Сравнивать их я не возьмусь, потому что, как я уже говорил, такие вещи лучше выбирать "под себя". Есть объективные критерии вроде времени подгрузки модулей, но основная суть DI - в гибкости и удобности кода, а это уже сугубо субъективный показатель.
В общем, если вы задумали ввести в свой проект полномасштабный DI, то рассмотрите и данный вариант. Возможно, он вам приглянется.