Уже не единожды и не в одном месте описано, что появилось в C# 4.0, но позволю себе повториться и кратко описать эти фичи, а также оценить их применимость в реальной жизни нашего проекта. Также пройдусь по интересным, на мой взгляд, новшествам в .Net 4.0, проверю их на производительность и применимость в жизни.
Что нового в C# 4.0
Значения по умолчанию и именованные параметры функций
Тут, бесспорно, ставлю большой плюс. Это то, чего не хватало по сравнению с C++. И самое замечательное, что сделали довольно качественно. Ниже код, демонстрирующий это, и он же в разобранном рефлектором виде, что хорошо показывает, как это работает:
public class TestClass
{
public void PerformOperation(string val1 = "val", int val2 = 10, double val3 = 12.2)
{
Console.WriteLine("{0},{1},{2}", val1, val2, val3);
}
}
var testClass = new TestClass();
testClass.PerformOperation(val3: 10.2);
//after decompile
public void PerformOperation([Optional, DefaultParameterValue("val")] string val1,
[Optional, DefaultParameterValue(10)] int val2, [Optional, DefaultParameterValue(12.2)] double val3)
{
Console.WriteLine("{0},{1},{2}", val1, val2, val3);
}
var testClass = new TestClass();
testClass.PerformOperation("val", 10, 10.2);
Подробно это описано тут оттуда же пример. То, что данные подставляются в метод на этапе компиляции, - это и плюс и минус. Минус в том, что если функция и ее вызов находятся в разных dll, то при смене значения по умолчанию в функции фактически она будет вызываться со старым значением до тех пор, пока не будет перекомпилирован вызывающий код. Это может стать проблемой, если вы производите dll на экспорт.
Dynamic
Предоставляет новый механизм динамического вызова функций, который по производительности лучше, чем Reflection, и хуже, чем статическая связанность. Так же как и Reflection, использовать его надо очень осторожно, потому что компилятор тебя не проверяет, что само по себе большой минус. В некоторых случаях без этого никуда, но у нас, слава богу, такие случаи бывают редко.
In и out типы в generic
Эта штука может быть полезна тем, кто использует LINQ, так как там часто бывают проблемы с типами возвращаемых значений. Рассмотрим пример того, что часто бывает нужно:
IList<string> strings = new List<string> ();
IList<object> objects = strings;
Но так делать нельзя, потому что потом можно сделать так:
objects[0] = 5;
string s = strings[0];
То есть изначальный список строк мы объявили как список объектов и начали работать с ними как с объектами. Но если предположить, что список объектов доступен только для чтения, то все нормально. Например, так:
IEnumerable<object> objects = strings;
Именно то, что объект доступен только для чтения, и указывается ключевым словом out. В нашем случае это будет выглядеть так:
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
public interface IEnumerator<out T> : IEnumerator
{
bool MoveNext();
T Current { get; }
}
Ключевое слово in делает то же самое с точностью до наоборот - то есть показывает, что тип можно использовать только в передаче параметров, а не в возвращаемых типах. Например, так:
public interface IComparer<in T>
{
public int Compare(T left, T right);
}
То есть в данном случае, IComparer<object> может считаться и IComparer<string>, потому как если уж он может сравнивать объекты типа object, то и string тоже может.
Новые фичи в .Net Framework 4.0
Кортежи
Бывало ли у вас такое, что надо вернуть из функции несколько разнотиповых значений? Я думаю, бывало. И что, по-хорошему, надо делать в этом случае? Правильно, делать структуру под этот результат. Но ведь лень, не правда ли? Придумал: сделаю я ее как out переменную и так получу - это намного быстрее. А что, все нормально работает! А потом таким же образом добавляется третья, четвертая и т.д. В итоге получается полная несуразица. Что-то вроде этого:
decimal HowMuch(Goods wantToBuy, out decimal taxes, out string priceInEnglish);
Мы получаем цену товара, сумму налога и текстовое написание цены. По-моему, не очень красиво - половина там, половина там. Так вот, в .Net 4.0 появился набор классов Tuple <T1, T2...> - до 7 параметров, можно больше, но сложно и, я думаю, не сильно нужно. Если модифицировать код выше на использование кортежей:
Tuple<decimal,decimal,string> HowMuch(Goods wantToBuy);
Так красивее, понятно, что возвращаются три значения, но теперь абсолютно непонятно, какое из значений что значит. Можно описать это в документации, что повысит понятность кода. Но есть еще один немаловажный пункт - это последующая модификация метода. Тут все намного хуже, потому что если добавить между двумя decimal еще один decimal, то компилятор поругается на третий параметр, а не на второй, и очень просто можно получить трудно отлавливаемую ошибку. Моё мнение, что это не панацея и использовать эту вещь допустимо для двух, максимум трех параметров. Если нужно больше, нужно делать структуру, как бы лень ни было.
System.Lazy<T>
В .Net 4.0 всем дали класс, который просто позволяет использовать позднюю инициализацию. Понятное дело, подход хороший, а самим писать не всегда хочется, да и разбираться в том, что это такое, долго. Я так пишу, потому что у опытных программистов, как правило, уже есть своя реализация этого принципа, если в ней была потребность. Использование выглядит примерно следующим образом:
Lazy<MyType> myLazyInstance = new Lazy<MyType>();
myLazyInstance.Value.MySomeMethod();
Объект будет инициализирован по факту в момент первого обращения к Value. Я решил написать свою примитивную реализацию этого подхода и сравнить производительность. Получилось следующее:
public class MyLazy<TType> where TType : class, new() {
private TType _istance;
public TType Value {
get { return _istance ?? (_istance = new TType()); }
}
}
И теперь проведем небольшой нагрузочный тест двух инициализаторов. Например, так:
for (int j = 0; j < 5000000; j++) {
Lazy<Dictionary<int, string>> msdic = new Lazy<Dictionary<int, string>>();
msdic.Value.Add(1,"test");
}
for (int j = 0; j < 5000000; j++) {
MyLazy<Dictionary<int, string>> msdic = new MyLazy<Dictionary<int, string>>();
msdic.Value.Add(1,"test");
}
В итоге первая версия отрабатывала у меня в среднем за 135 секунд, вторая - за 1,3 секунды. Можно сказать, что в первой реализации есть плюсы: например, она ThreadSafe и может использовать другой конструктор кроме конструктора по умолчанию. Ниже приведу пример, как сделать простую реализацию ThreadSafe. Передача конструктора не сильно сложнее:
private class MyLazy where TType : class, new() {
private object _lockObj = new object();
private TType _istance;
public TType Value {
get {
if (_istance == null) {
lock (_lockObj) {
if (_istance == null) {
_istance = new TType();
}
}
}
return _istance;
}
}
}
В общем, использовать эту реализацию я бы не советовал, потому что на большом количестве объектов будет проблема с производительностью, а на малом количестве применение самого подхода поздней инициализации может быть не оправдано. Единственный случай, когда я считаю применение данной фичи приемлемым, - это маленькие приложения "для себя", которые нужны на один раз. Хотя целесообразность использования в них поздней инициализации тоже под большим вопросом.
SortedSet<T>
В моей практике мне нужен был похожий класс, который сам сортировался бы при вставке, удалении и изменении значения. Увы, из того, что мне было нужно в данной реализации, есть только первые две. Если верить тому, что пишет Microsoft, то реализация построена на основе сбалансированного дерева, поэтому должна быть довольно эффективна и во вставках и в поиске. Сравнивать будем SortedSet<int>, List<int> с последующей сортировкой и Dictionary<int,object>.
Для начала попробуем вставку из 10 миллионов частично повторяющихся записей. SortedSet - 27 секунд, List (вставка + сортировка) - 1,5 секунд, List (вставка + сортировка + удаление дубликатов) - 2,9 секунд, Dictionary - 4 сек.
Отдельно хочу отметить еще один вариант, который я пробовал, - это создание List и последующая передача его в конструктор SortedSet. Это заняло 39 минут, то есть 2340 секунд. Это в 100 раз медленнее реализации с foreach/for и поштучной вставкой. Не знаю, что они там намудрили с конструктором, но использовать его нельзя вообще.
Теперь попробуем поиск по нашим 10миллионным спискам/множествам. Выполним 10 миллионов поисков различных элементов, как встречающихся, так и не встречающихся в множествах. SortedSet - 21 сек, Dictionary - 3 сек, List - много. Оценку для List получить не удалось из-за большой длительности, но это примерно несколько часов, потому что каждый поиск - это последовательный перебор всех элементов, так как List не рассчитан на поиск.
Несомненным достоинством SortedSet является наличие операций над множествами, таких как объединения, пересечения, проверка на подмножество/надмножество и т.д. Таких операций не определено над Dictionary, поэтому придётся использовать самописную версию.
Теперь сравним производительность нахождения пересечения между двумя множествами по 10 миллионов. SortedSet - 5-6 сек, Dictionary - 3-4 сек. Но тут не надо забывать, что на инициализацию двух таких SortedSet у нас уйдет около минуты, а для Dictionary всего 8 секунд. Суммарно - это 60 против 12 секунд. Добавлю, что и у Dictionary есть минус из-за того, что в реализации нам пришлось использовать третий словарь для хранения пересечения, что требует дополнительной памяти, выделение которой не было замечено для SortedSet.
Коротко подведем итоги для SortedSet. Единственный плюс - это реализованные операции над множествами. Дальше идут минусы, как-то:
- ужасный конструктор, принимающий на вход набор элементов для инициализации;
- медленная работы как вставки, так и поиска.
В общем, найти реальное применение этому классу довольно тяжело, потому что для простой сортировки его не применишь - поиск работает хуже, чем в том же Dictionary. Если вам все же нужны операции над множествами, а писать их неохота и производительность кода не критична, то вполне можно использовать SortedSet, только не забывайте про медленный конструктор.
ConcurrentDictionary vs ThreadSafeDictionary
В.Net 4.0 появился новый namespace System.Collections.Concurrent, в котором появились давно востребованные ConcurrentDictionary, ConcurrentQueue и т.д. Здесь я коснусь только ConcurrentDictionary, как наиболее интересного для меня.
Потребность в такой штуке назрела давно, еще во времена второго .Net. И не прошло и 10 лет, как Microsoft ее сделали, да еще как, но об этом позже. Ввиду того, что существовала потребность, были сделаны самописные версии. С одной из таких версий и будем сравнивать. Своей у меня нет, поэтому возьмем первое, что находится в Google, а также рекомендуется в комментах к подобным вопросам на Хабре и stackoverflow. Реализацию можно взять здесь.
Проверять будем по количеству операций, выполненных в многопоточном режиме за отведенное время.
private static void WriteDict(object o) {
while (work) {
((IDictionary<int, string>)o).Add(Interlocked.Increment(ref i), "test");
Interlocked.Increment(ref writeOper);
}
}
private static void ReadDict(object o) {
while (work) {
for (int j = 0; j < i; j++) {
string s;
if (((IDictionary<int, string>)o).TryGetValue(j, out s)) {
Interlocked.Increment(ref readOper);
}
if (!work) {
return;
}
}
}
}
private static void RemoveDict(object o) {
while (work) {
for (int j = 0; j < i; j+=2) {
string s;
((IDictionary<int, string>)o).Remove(j);
Interlocked.Increment(ref removeOper);
if (!work) {
return;
}
}
}
}
Конечные реализации немного отличаются, но суть, я думаю, ясна.
| |
ConcurentDictionary |
ThreadSafeDictionary |
| Многопоточность. Количество операций вставки, выполненных 10 из 30 потоков за 30 секунд. В миллионах операций |
5,85 |
0,75 |
| Многопоточность. Количество операций чтения, выполненных 10 из 30 потоков за 30 секунд. В миллионах операций |
487 |
60 |
| Многопоточность. Количество операций удаления, выполненных 10 из 30 потоков за 30 секунд. В миллионах операций |
12 |
0,96 |
Замечательно! Эта штука отлично работает - примерно в 10 раз быстрее чем то, что есть в интернете. Первая хорошая новость за обзор, у меня даже настроение поднялось!
Есть еще только одна вещь, которую я хочу проверить, - это сравнить эти две ThreadSafe реализации с обычным Dictionary. Естественно, это будет делаться в однопоточном режиме, так как последний не ThreadSave. Проверять будем уже на время выполнения вставки/чтения/удаления 10 миллионов записей.
|
Dictionary |
ConcurentDictionary |
ThreadSafeDictionary |
| Однопоточность. Вставка 10 млн записей. В секундах. |
0,75 |
5,75 |
2,15 |
| Однопоточность. Чтение 10 млн записей. В секундах. |
0,25 |
0,33 |
1,65 |
| Однопоточность. Удаление 10 млн записей. В секундах. |
0,32 |
0,66 |
1,56 |
Ну что, даже по сравнению с Dictionary результат ConcurentDictionary можно назвать достойным. Сильно проигрывает только вставка элементов, но если в конструкторе задать capacity, то можно значительно сократить данный результат - примерно до двух секунд.
Итак, реализация ConcurentDictionary в.Net 4.0 заслуживает внимания. Можете смело использовать, потому что на написание/поиск самописной и более производительной реализации уйдет очень много времени.
Parallel.For
По названию ясно, что это параллельная реализация цикла for. Первое, что меня заинтересовало в данном новшестве, - это использование ThreadPool, потому что если использует, то для меня это абсолютно не приемлемо. Но программисты Microsoft были благосклонны, и ThreadPool в ней не используется.
Теперь сравним производительность параллельной и обычной реализаций на некоторых простых операциях. Заполним массив на 200 млн. элементов по алгоритму "значение = индекс ".
double[] array = new double[cnt];
ParallelOptions p = new ParallelOptions();
Parallel.For(0, cnt, p, j => {
array[j] = j;
});
for (int j = 0; j < cnt; j++) {
array[j] = j;
}
Время выполнения получилось одинаковое и довольно небольшое - 1,3 секунды. Я тут ожидал некой разницы в пользу параллельной версии, но ладно, усложним задачу - будем делать хитрые математические вычисления. Примерно вот так:
array[j] = Math.Exp(Math.Log10(Math.Sin(j * Math.PI / 180)));
Тут уже появляется заметная разница: 13 секунд - параллельная версия против 28 -последовательная (естественно на компьютере несколько ядер, иначе расклад был бы противоположный). В процессе измерений меня удивила одна вещь, а именно количество потоков, которое используется для выполнения вычислений. В обоих случаях оно доходило у меня до 5 и останавливалось, даже если передавать ParallelOptions, в котором количество потоков не ограничено. И захотелось мне проверить, как будет работать вызов Parallel.For из нескольких потоков параллельно. Делаем 10 потоков, в каждом делаем Parallel.For, который все так же заполняет массив "значение = индекс" аналогично с обычным for.
Тут меня постигла печаль - для 10 запущенных Parallel.For все также использовалось 5 потоков вычисления. Естественно, что при таком раскладе обычный for, который, как положено, выполнялся в 10 потоках, справился с задачей в два раза быстрее. Какое разочарование! Мне уже начинала нравиться эта штука, а тут такое. Ну ладно, еще один эксперимент, наверное, самый ожидаемый и с ожидаемым результатом: выполнение каких-то операций, ожидание, выполнение еще операций.
double[] array = new double[cnt];
ParallelOptions p = new ParallelOptions();
Parallel.For(0, cnt, p, j => {
array[j] = j;
Thread.Sleep(1000);
array[j] = Math.Exp(Math.Log10(Math.Sin(j * Math.PI / 180)));
});
Как и ожидалось, Parallel.For справился с задачей на отлично и выполнил все в 10 раз быстрее. Но кроме всего прочего я заметил одну интересную вещь - число потоков параллельности доросло до 20, так почему же оно не росло так при параллельном выполнении в 10 потоков? Секрет заключается в алгоритме порождения потоков.
Разбор исходного кода показал, что для этого используется хитрый алгоритм с использованием Thread.Yield, который по сути запускает новые потоки, когда есть свободные ресурсы процессора и нет других потоков, которые хотят их использовать. Изначально запускается 5 потоков, и потом они потихоньку растут (по одному в секунду), если есть ресурсы. По умолчанию число потоков не ограничено. Идея в общем хорошая и правильная, но эксперимент с многопоточностью хорошо показывает, что ресурсы есть, а потоки не запускаются, потому что если бы не было ресурсов, то обычный for не выполнился бы быстрее.
Другой вариант неожиданного поведения: есть код, который выполняет различные запросы на базе, и все идет хорошо и быстро - используются оптимальные 5-10 потоков. Но тут возникает пик нагрузки и запросы "подвисают". Это дает свободные ресурсы процессору, и начинаются порождаться новые потоки, которые еще больше грузят базу, отчего она тормозит еще больше. Еще больше потоков и т.д.
В общем, довольно интересная штука, рекомендую попробовать для ваших прикладных задач, особенно если в них есть элемент ожидания, который занимает приличное время. Также можно использовать для сложных вычислений - это даст некоторый выигрыш. И очень аккуратно используйте эту вещь в и так уже многопоточных приложениях, потому что поведение может сильно отличаться от ожидаемого. Да, и последнее замечание - вся внутренняя реализация должна быть потокобезопасной, что также снижает положительный эффект от параллельного выполнения либо усложняет код.