C# Heppard @csharp_gepard Channel on Telegram

C# Heppard

@csharp_gepard


25 способов эффективно использовать .NET

Поддержать канал можно тут: https://sponsr.ru/sharp_heppard

C# Heppard (Russian)

Если вы интересуетесь программированием на C# и работой с .NET, то канал C# Heppard идеально подойдет для вас! Наш канал csharp_gepard предлагает вам 25 способов эффективно использовать .NET, чтобы улучшить ваши навыки и знания в этой области. Мы делимся полезными советами, инструкциями и примерами, которые помогут вам стать опытным разработчиком на C#.

C# Heppard - это сообщество людей, увлеченных программированием и готовых обмениваться опытом. Мы поддерживаем общение, обсуждение и взаимопомощь между участниками канала. Здесь вы найдете вдохновение, новые идеи и возможность расти в своей профессии.

Подписывайтесь на наш канал csharp_gepard, чтобы получать актуальную информацию, обучающие материалы и быть в курсе всех новинок в мире C# и .NET. Поддержите наш канал, перейдя по ссылке: https://sponsr.ru/sharp_heppard и помогите нам развиваться и делиться ценными знаниями с вами!

C# Heppard

28 Dec, 06:44


Тут на соседнем канале зашла речь про ускорение некоторых алгоритмов с помощью SIMD и я побыстрому накидал реализацию двух - косинусное сходство и корреляцию Пирсона (на скриншоте бенчи для него, для косинусного сходства - в камментах в gist). Алгоритмы как будто прямо таки созданы для Single Instruction/Multiple Data :)

Первый блок на скриншоте - просто мап на Vector<double> и дальнейшие операции, ничо сложного, но даже это даёт 6-кратный буст. Второй блок с float, тут ещё побыстрее, просто потому что элемент в 2 раза тоньше и за один чпок забирается в два раза больше элементов по сравнению с double.

Но вот дальше там был ещё один кейс, когда входные данные короче И double И float - например short. И вот тут становица всё ещё интереснее: отмапленый в Vector256<short> забирает сразу 16 элементов входного массива. Напрямую в Vector256<float> такое не смапиш конечно, поэтому операция двухэтапная - сначала GetLower/GetUpper по 8 элементов экспандяца до int (32 бита = 256 бит), а потом кастяца до float (тоже 256 бит).

Вроде выглядит некоторыми костылями, но это даёт 14-кратный буст даже на длинных массивах, которые гарантированно не влезают в L2 кэш. Если кастить в 32-битный float конечно, с double ситуация пожиже - там буст ровно в два раза хуже (~x7), что вполне логичо :))

Судя по всему выполнение SIMD инструкций тут отлично сочетается с асинхронностью L1/L2-кэша - пока локальные данные кастяца, множаца и складываюца - в кэш подтягиваются следующие порции данных и к моменту следующей итерации они уже там. #simd

C# Heppard

24 Dec, 07:49


Быстрый и экономный xlsx #память #скорость

Наш сервис формирует отчёты в формате xlsx (Excel). Отчёты скачиваются часто и активно, некоторые из них могут быть достаточно большие (> 50 Мб). Для формирования xlsx мы использовали EPPlus, который на тот момент знали лучше. Функционал был реализован, что очень обрадовало заказчика.

Однако, мы были не очень рады. Во-первых, бесплатный EPPlus давно не обновлялся (последний коммит аж 4 года назад). Во-вторых он потреблял много памяти, что иногда приводило к OutOfMemoryException. В принципе, мы знали с чем имеем дело, поэтому подготовились заранее - написали собственную "обёртку" вокруг формирования xlsx, чтобы иметь возможность перейти на другую библиотеку в будущем.

Месяц назад этот момент настал. Да, до этого мы уже делали попытки изучить библиотеки, которые, в перспективе, могут дать буст производительности и снизить аллокацию. Увы, некоторые не подходили нам по возможностям стилизации ячеек excel-файла.

Изучив несколько известных библиотек (ClosedXML и Open XML) мы сделали замеры - увы, эти библиотеки хоть и были лучше бесплатного EPPlus, но не давали нужного прироста экономии памяти и производительности. К этому моменту мы предприняли попытку самостоятельного написания библиотеки для формирования xlsx и уже примерно представляли, что с использованием современных подходов C# можно лучше.

Написание собственной библиотеки остановилось, когда мы нашли SpreadCheetah. Его результаты вы наблюдаете на бенчмарке. Спустя пару-тройку недель мы перешли на него и выкатились в PROD. В принципе, результаты нас устраивают.

Выводы:
1. Современный .NET может быть очень очень быстрым.
2. Если у вас проблемы с производительностью, то попробуйте найти готовое решение, прежде чем писать свой велосипед.
3. Писать велосипед полезно, чтобы знать ключевые слова для поиска и референсные значения производительности.

P.S.: Бенч в другом месте - слишком много кода. Туда же выложил результаты. Либы из бенчмарка в комментах.
P.P.S: Сравнение с NPOI тут.

C# Heppard

30 Nov, 22:47


Зарплаты в IT #деньги

Взял тут. Откуда они взяли цифры - не знаю, но канал весьма авторитетный. В принципе, с моими данными эти цифры плюс-минус бьются. Среднее арифметическое значение по этой табличке:

Lead: 417k (если это он)
Senior: 316k
Middle: 276k

Максимумы:

Lead: 600k
Senior: 750k
Middle: 500k

Я к этим цифрам добавлю, что по моим знакомым и в последнее время всё больше заметно следующее. В компаниях, которые считаются "солью IT", зарплаты не очень конкурентные. То есть коллеги выезжают, во многом, благодаря ранее сформированному HR-бренду. У них именно среднее арифметическое.

Зарплаты близкие к максимальным чаще всего относятся к т.н. "ноу нейм" компаниям.

P.S.: По моим данным, подчёркиваю. Как получаются именно мои наблюдения, я написал тут, повторяться не буду.

C# Heppard

25 Nov, 06:31


Структура как Span #решение #память #скорость

Разобравшись с InlineArray и SkipLocalsInit мы можем пойти дальше. Например, мы можем представить любую структуру как Span. Напомню, что Span это простой указатель на адрес в памяти + отступ, умноженный на размер элемента Span'a.

Сделать Span из структуры достаточно просто:
private struct MyStruct {  
private int _item0;
...
private int _item9;
}

// Пропускаем инициализацию структуры
Unsafe.SkipInit(out MyStruct myStruct);

// Получаем ссылку на структуру на стеке
ref var reference = ref Unsafe.As<MyStruct, int>(ref myStruct);

// Получаем структуру как спан
var span = MemoryMarshal.CreateSpan(ref reference, MyStructItemCount);


Представляется, что примерно так работает представление структуры, отмеченной InlineArrayAttribute, когда мы говорим Span<int> span = myInineArray. Бенчмарк подтверждает это, так как скорость доступа к элементам структуры близка к прямому доступу через индексатор InlineArray.

Представление структуры как Span позволяет проще обращаться к элементам структуры (например, без монструозных switch), а именно её заполнение или чтение.

При этом, нам надо быть крайне аккуратными, так как методы статического класса Unsafe очень не зря находятся в этом классе. Пусть нас не обманывает простота их использования - фактически это тот же unsafe-код.

C# Heppard

13 Nov, 11:15


Советы робота #отдых

Решил я тут снова попробовать GPT для банальных подсказок по работе. Задал вопрос - расскажи о простых и понятных способах оптимизации производительности на C#.

И вот, среди более-менее нормальных советов а-ля "подбери алгоритм", "используй правильные коллекции", нашёл странный - минимизируй использование переменных.

Я такой, типа, об-бъяснитесь, мьсё! Приведите пример! Ну он и выдал... А я опешил.

Из его объяснения выходило, что обычный for с промежуточной переменной ("первый способ" на картинке) должен работать медленнее, чем Sum() ("второй способ"), так как там нет переменных. Проверить - дело не хитрое, я написал бенч. Предложение создавать Enumerable.Range(1,100) в бенчмарке я, конечно, отверг - явная ошибка, спишем на молодость робота. Cоздал массив заранее, запустил и... "второй способ" и правда работает быстрее!

Вот это да!... Да? А вот нет. То есть да, работает быстрее, но дело, конечно же, не в "количестве переменных", а в векторизации, которую легко найти в исходном коде BCL. Получается, что робот как бы прав в результате, но не прав (причём сильно) в предпосылках и объяснениях этого результата.

Какой же можно сделать вывод? Да, роботы стали умнее собирать информацию и выдавать её нам. Но насколько нам полезен результат, если предпосылки ошибочные? Мне ночью приснился MR от молодого разработчика, который увидел этот ответ робота, проверил его, восхитился и... стал вычищать C# код от лишних переменных. Брр. А если бы это был экономист? Или юрист? Или медик? Короче говоря, роботов всё ещё нужно использовать аккуратно, внимательно проверяя и перепроверяя. Особенно по профессиональным вопросам.

Бенч элементарный, но он в комментах.

P.S.: Ну и вишенка на торте - робот ошибку признал. Что ему мешало сразу сказать правильно - загадка. Надеюсь, что это поправят.

C# Heppard

08 Nov, 10:05


🤡 Clown Object Heap — очередная новая куча в .NET 9 для вопросов на собеседованиях

Объекты в .NET делятся по четырём кучам. С двумя старыми приходится работать постоянно, про две относительно новые обычно вспоминают только на собесах (зачем про них спрашивают?)

Small Object Heap — обычная куча для объектов, разделённая на 3 поколения — нулевое, первое, и второе. С каждой сборкой мусора, выживший объект продвигается в следующее поколение. Сборки мусора в нулевом поколении быстрые и частые, во втором — медленные, блокирующие, редкие. Во время блокирующих сборок мусора содержимое SOH компактится (живые объекты копируются подряд, чтобы между ними не было дырок и память использовалась эффективно, ссылки на эти объекты обновляются, для этого и нужна блокировка потоков)

Large Object Heap — куча для больших объектов. Отличается тем, что по-умолчанию не компактится, т.к. копировать большие объекты долго. Иногда LOH считают абсолютным злом, но без него GC паузы при сборке мусора второго поколения стали бы совсем неприличными.

Основная проблема, связанная с LOH — фрагментация. Если аллоцировать короткоживущие объекты в LOH, особенно если размер объектов разный, после сборок мусора в LOH будут оставаться дырки. Далее окажется, что эти дырки слишком маленькие, чтобы заполнить их новыми объектами и программа начнёт раздуваться по памяти. Обычно проблема решается переиспользованием объектов

- в LOH попадают объекты размером >= 85000 байт
- в основном в LOH хранятся массивы, т.к. другие большие объекты в реальности не встречаются. Тем не менее, вручную можно сделать объект-не массив, который попадёт в LOH:
[StructLayout(LayoutKind.Sequential, Size = 84977)]
class LOHject { int _; }

Console.WriteLine(GC.GetGeneration(new LOHject())); // 2

- в доисторическом фреймворке в LOH также попадают массивы double от 1000 элементов. Сделано этого для оптимизации работы с double на 32-битных системах, т.к. объекты в LOH на них выравнены по границе 8 байт. Этот факт полезен только на собеседованиях и квизах на конференциях
- можно попросить сборщик мусора скомпактить LOH:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;

- При запуске программы с memory limit LOH не может употребить всю доступную память. OutOfMemory случится до достижения лимита
- Популярные причины аллокаций в LOH: промежуточные коллекции, создаваемые внутри Linq методов, MemoryStream

Pinned Object Heap (.NET 5) — куча, объекты в которой сборщик мусора гарантированно никогда не перемещает. В ней нет аналога CompactOnce и нет требований к размеру объекта. Нужна для массивов, указатели на которые передаются в нативный код.

Аллокация подобных объектов в обычной куче и их пиннинг (запрет на перемещение) через fixed или GCHandle создаёт проблемы для компактящего сборщика мусора. Поэтому для них сделали отдельную кучу.

Также ходят легенды, что доступ к массиву через указатель без bound checks (проверок на выход за границу массива) быстрее, но это скорее легенда, приводящая к багам, чем реальность.

Через публичный API в Pinned Object Heap можно создавать только массивы:
GC.AllocateArray<int>(128, pinned: true);
GC.AllocateUninitializedArray<int>(128, pinned: true);


Объекты в POH собираются сборщиком мусора, и при неаккуратном использовании POH может стать фрагментированным.

Frozen Object Heap (NonGC Object Heap. .NET 8) — куча для объектов, которые живут всё время жизни программы, никогда не собираются сборщиком мусора, не ссылаются на объекты вне FOH, и никогда не изменяются. В ней хранятся объекты, создаваемые рантаймом дотнета: строковые литералы, объекты Type, Array.Empty<>(), ... Публичного API для создания объектов в FOH нет. Нужна для оптимизаций — 1) GC не просматривает эти объекты, 2) JIT может генерировать более эффективный код, зная что объект никуда не переместится, не изменится, и что о новых ссылках на него не нужно сообщать сборщику мусора

@epeshkblog

C# Heppard

31 Oct, 08:11


SkipLocalsInit #скорость

Мы часто используем Span для ускорения сборки строк - создаём буфер на стеке, складываем туда кусочки будущей строки (чтобы не плодить промежуточные), а потом превращаем всё это в строку. Пример с ValueStringBuilder будет выглядеть следующим образом:

public string MyMethod() {
Span<char> buffer = stackallock char[1024];
var vsb = new ValueStringBuilder(buffer);
...
}


Это работает быстро, но можно сделать быстрее. В данном случае, местом ускорения будет создание буфера. В платформе есть правило - каждая переменная перед использованием должна быть инициализирована. В данном случае, платформа осуществляет инициализацию Span , то есть заполняет его элементы значениями по умолчанию. Это достаточно ресурсоёмкая операция (см. Span против SpanSkipInit на бенчмарке), которую мы можем не делать, воспользовавшись атрибутом SkipLocalsInit.

Атрибут SkipLocalsInit, применённый к методу, заставляет компилятор не использовать инициализацию локальных переменных этого метода. Важно понимать, что это не безопасная штука, так как в созданной переменной могут содержаться значения из памяти, которые были в памяти до этого. Именно поэтому, применение атрибута SkipLocalsInit требует объявить всю сборку как unsafe.

Я бы не стал использовать этот атрибут в обычном коде, но для библиотечного кода (который, надеюсь, обложен тестами) этот подход может весьма существенно ускорить работу с промежуточными буферами.

Также, немаловажно то, что чем больше буфер, тем более выгодно брать его из ArrayPool. Это отчётливо видно на бенчмарке: буфер размером более 4096 char'ов уже стоит брать оттуда. Это понятнее для разработчиков и не требует применения unsafe.

Больше подробностей о том, как работает инициализация в C# с SkipLocalsInitAttribute, есть тут и тут.

Код бенчмарка в комментариях.

P.S.: Если же мы хотим пропускать инициализацию модно и молодёжно, не помечая сборку как unsafe, мы должны использовать Unsafe.SkipInit.

C# Heppard

26 Oct, 07:19


Атрибут DoesNotReturn #решение

Когда мы пишем оптимальный код, мы пытаемся вынести выброс Exception за пределы тела метода, чтобы упростить inline. Типичная ситуация выглядит следующим образом:

public object MyMethod() {
var data = ...
if (data == null) {
Error.MyError("msg");
}
return data;
}

public static Error {
public static void MyError(string msg) {
throw new Exception(msg);
}
}


В этом случае статический анализатор (конечно, при включённом nullable annotations) вежливо подсказывает - data может быть null (см. скриншот). Это правильно и нормально, так как возвращаемое значение метода не отмечено как object?, то есть оно не допускает значение null.

Некоторе, в подобной ситуации, кричат на код ("я знаю что делаю, дурацкая ты железка"), просто делая return data!. Однако, когда код будет меняться и разрастаться, логика первоначального создателя может изменится или утратиться, а криков на код станет больше.

Есть, однако, другое решение, которое я подсмотрел в BCL - атрибут DoesNotReturn. Этот атрибут указывает компилятору, что метод или свойство никогда не возвращает значение. Другими словами, он всегда создаёт исключение (выполнение вызывающего метода прекратится).

Обладая этими нехитрыми знаниями, мы просто добавляем атрибут DoesNotReturn для всех методов, которые выбрасывают ошибку и убираем крики на код. Просто и красиво.

[DoesNotReturn]
public static void MyError(string msg) {
throw new Exception(msg);
}


Более подробно про помощь компилятору можно прочитать тут и тут. Там же много других полезных атрибутов, которые помогут меньше кричать на код, а значит сделать его красивее и понятнее. Кажется, что все эти атрибуты особенно важны для библиотечного кода, тогда как в обычном коде они представляются мало востребованными.

C# Heppard

25 Oct, 06:14


Бесплатные Rider и WebStorm

Ого, решительно поддерживаю! Обещают, что приложение будет полностью функциональным и ни чем не отличаться от платной версии. Хорошее решение для пет-проектов и не только.

Злые языки утверждают, что такой шаг со стороны чехов - последствия снижения качества, низких продаж и жаркого дыхания VS и VSCode в спину Rider'у. Но я желаю парням успехов.

Ещё интернетах пишут, что эти IDE уже можно скачать через некий uTorrent. Не знаю, что это за приложение - надо попробовать. Увы, официальный Toolbox что-то перестал скачивать обновления - видимо, высокая нагрузка.

P.S.: И вот ещё новая информация, о блокировании пользователей, которые уже оплатили лицензии.

C# Heppard

25 Aug, 08:10


Словари: поиск по ключу #бенч

Хотелось бы напомнить про скорость извлечения данных из словарей в .NET. Напомню, что словарей у нас много и все они немного разные - Dictionary, ConcurrentDictionary, FrozenDictionary, ImmutableDictionary и ReadonlyDictionary. У всех у них чуть-чуть разные задачи и немного разная производительность на разных ключах (в том числе на содержимом ключей).

Для того, чтобы грамотно выбрать словарь, я рекомендую послушать один из последних выпусков RadioDotNet (00:54:30). Там неплохо рассказали о том, как появились имплементации словарей, как они работают, где их слабые и сильные стороны.

При чтении бенчмарка прошу обратить внимание на то, что я использую интерфейс IDictionary, а не явные имплементации словарей. Мотивация создания именно такого бенчмарка - поиск пути безопасной подмены имплементации одного словаря на другой в уже работающей кодовой базе. Естественно, при написании нового кода, я бы предпочёл использовать явные имплементаци (т.е. классы, а не интерфейс). Более того, IDE явно на это намекает.


CA1859: используйте конкретные типы, если это возможно для повышения производительности.


Обратите внимание, что этот бенчмарк касается только извлечения данных по ключу. При выборе конкретной имплементацию этого бенчмарка не достаточно, и нужно обязательно обратить внимание на тип ключа и ваш профиль нагрузки - часто добавление значения в коллекцию полностью нивелирует скорость извлечения данных.

P.S.: Также, коллеги напоминают, что производительность FrozenDictionary очень зависит от ключа.
P.P.S: Сравнение скорости работы, когда ключ это строка тут. Там возможности FrozenDictionary раскрываются во всей красе.

C# Heppard

10 Aug, 15:49


Быстро парсим float #скорость #решение

Нашёл тут для себя задачку по парсингу большого количества значений из большого файла. В файле перечислены города и температуры в них. В принципе, ничего особенного - название_города;температура. Температура это число с плавающей запятой (например, -2.33, 7.4, 0.3). В принципе, понятно, что если извлечь строку и поделить её по символу точка-с-запятой, то мы получим набор байтиков с числом.

Этот набор байтиков мы, конечно, как знающие люди, можем запихнуть в эффективный float.Parse. Код получается примерно вот такой:


ReadOnlySpan<byte> row = …;
int separator = row.IndexOf(‘;’); // байтик
ReadOnlySpan<byte> temperatureBytes = row[(separator + 1)..];
float temperature = float.Parse(temperatureBytes, Culture);


И вот я, значит, написал этот высокоэффективный код, смотрю с помощью dotTrace на предмет, а что, собственно, можно ещё улучшить. Если кто не знает, эта такая утилитка, которая показывает, где и на что мы тратим время в коде. И, к моему большому удивлению оказалось, что куча времени тратится на float.Parse. На 100 миллионах строк это видно отчётливо…

Я как-то даже немного подрасстроился. Ну, думаю, уж эту штуку должны были написать круто. Может быть я как-то не так готовлю float.Parse? Или что-то не докрутил с настройками (там они есть, их несколько)? Масса вопросов. Взял себя в руки, пошёл искать в интернетах, мол, что люди делают в такой ситуации.

Я человек прошареный, поэтому искал решения в том числе на C++. Оказалось, что есть такая интересная штука как fast_float. И, о чудо, эта имплементация нашлась и на C#. Бенчмарк, замеры… Да, оказалось сильно быстрее, аж в пять раз. Человеку, который это придумал - респект. Код будет в комментариях.

Библиотеку слишком плотно не тестировал, но в деле решения ускорения исходной задачи она сильно помогает. Возможно, кто-то знает какие-то более классные решения - буду рад посмотреть.

P.S.: Сергей, спасибо за задачку!
P.P.S: Сравнение с Utf8Parser.TryParse (который, вроде как, реализует fast_float) тут. Евгений, спасибо!

C# Heppard

08 Aug, 11:40


Работа с ArrayPool и MemoryPool #память

Пул массивов - классная штука, но есть проблема. И даже две.

Первая проблема. Когда мы пытаемся получить ArrayPool<T>.Shared.Rent массив размером, допустим, в 4 элемента, мы получаем массив размером в 16 элементов. Если запросим 16, то получим 16, а вот если нам нужно 17 элементов, то мы получим аж 32. Таким образом, при запросе массива, мы всегда получаем массив размером не менее нужного. Это сделано специально, чтобы не аллоцировать большое количество массивов разного размера.

Соответственно, при попытке итерироваться по полученному массиву, мы можем столкнуться с пустыми элементами, либо элементами, которые содержат предыдущие данные (очистка массива перед возвратом в пул это право, а не обязанность потребителя).

Вариантов решения этой проблемы несколько. Во-первых, мы можем воспользоваться ArraySegment - это структура, которая знает размер необходимого нам сегмента массива и следит за тем, чтобы мы не вышли за его пределы при итерировании.

var pool = ArrayPool<int>.Shared;
var array = pool.Rent(length);
var segment = new ArraySegment<int>(array, 0, length);


Теперь мы можем передавать ArraySegment в нужные методы, и избавить потребителей от необходимости знать о том, какого же размера массив был запрошен и необходим.

Во-вторых, мы можем воспользоваться Span. Это отличный вариант, когда мы передаём кусочек массива в методы, которые не являются async. Напомню, нам запрещено работать с ref struct в асинхронных методах.


var span = array.AsSpan(..length);


В-третьих, можно использовать Memory - структурой, которая так же как и ArraySegment, указывает на область памяти. Конечно, при данном подходе, логичнее использовать не ArrayPool, а MemoryPool, который работает примерно так же, как и ArrayPool.

var pool = MemoryPool<int>.Shared;
using var memoryOwner = pool.Rent(length);
var memory = memoryOwner.Memory[..length];


Memory это обычная структура, а значит нет никаких проблем передавать её в async методы и не думать в них о том, какой же реальный размер памяти мы используем. Напомню, что взаимодействие с Memory осуществляется через Span (memory.Span).

Вторая проблема при работе с ArrayPool - возврат использованного массива. Когда мы закончили работу с массивом из пула, логично было бы вернуть его в пул в том же методе, в котором мы его получили. Более того, мне кажется, что это правильное решение с точки зрения архитектуры.

var pool = ArrayPool<int>.Shared;
var array = pool.Rent(length);

DoSomething(array.AsSpan(..length));

pool.Return(array);


Если мы обратим внимание на использование MemoryPool, то он возвращает не Memory, а IMemoryOwner, что как бы намекает: есть владелец памяти, а есть методы, которые память используют. Передавая в методы использования и Memory, и IMemoryOwner’a, мы делаем утверждение, что теперь другой метод является владельцем области памяти, а значит именно он ответственен за её очистку.

var pool = MemoryPool<int>.Shared;
var memoryOwner = pool.Rent(length);
var memory = memoryOwner.Memory[..length];

DoSomething(memory, memoryOwner);


Замечу ещё раз, этот способ не совсем правильный и является выходом из ситуации, когда нам всё-таки не удобно возвращать массив в пул по месту получения.

Другие способы (ArraySegment или Span) такой конструкцией не обладают, и мы должны самостоятельно придумывать костыли для возврата массива в пул. Например, можно написать микс ArraySegment’a с IMemoryOwner, который обладает возможностью возврата массива в пул при вызове Dispose. Его код будет в комментариях. А можно ещё и вот так.

Используя подобный велосипед, вы сможете передавать в методы-потребители структуру PooledArray<T> и в нужном месте вызывать Dispose, который вернёт массив в пул. В принципе, весьма неплохое решение. Если смотреть на бенчмарк всего сценария, то получается даже быстро.

C# Heppard

03 Aug, 16:50


TryGetNonEnumerated #память

Хотелось бы напомнить про такую банальную, но весьма полезную оптимизацию, как создание списка с заранее известным размером.

Напомню, что первоначально List<T> создаётся с внутренним массивом размера 0. При последующем добавлении элементов происходит проверка, и, если места не хватает, внутренний массив расширяется на свой размер, умноженный на 2. «Расширение», в данном случае, означает, что создаётся новый массив, а содержимое старого массива копируется в новый. Таким образом, если мы заранее создадим List с внутренним массивом в 200 элементов, то мы избежим аллокаций шести массивов - это весьма солидно .

Однако, увы, некоторые методы возвращают IEnumerable<T>, из которого весьма проблематично узнать размер. Да, за IEnumerable<T> может скрываться любая из коллекций, имплементирующая ICollection<T>, и тогда проблем с выяснением первоначального размера нашей коллекции нет. Но что если нам пришёл результат чего-то вот такого?


_data = array // первоначальный массив
.Skip(1)
.Take(Count / 2)
.Order()
.Select(static i => i * i % 10 == 0 ? "да" : "нет");


Это точно не ICollection<string>, то есть выяснить размер мы не сможем. Вернее, всё-таки сможем. Если заглянуть в недра .NET, то мы узнаем, что это некий Enumerable.SelectIPartitionIterator, который, на наше счастье, реализует внутренний интерфейс IIListProvider. Чтобы попытаться воспользоваться его методом GetCount, нам поможет метод TryGetNonEnumeratedCount, который появился аж в .NET 6.

В нашем случае он может быть применён вот так:


var capacity = _data.TryGetNonEnumeratedCount(out var count)
? count
: ваша_эвристическая_константа;

var list = new List<string>(capacity);
list.AddRange(_data);


Почему этот подход не используется в конструкторе того-же List’a (который принимает IEnumerable) и в его же методе AddRange - загадка. Наверное, у коллег пока просто не дошли руки.

P.S.: Для желающих посмотреть, что метод TryGetNonEnumerated действительно ничего не перебирает, а просто возвращает значение - в комментариях есть бенчмарк.

C# Heppard

29 Jul, 11:31


Кажется, надо попробовать! Мистеру успехов в развитии канала.

Я также, как и Евгений, достаточно скептично отношусь к способности современных "чатов" делать что-то полезное для программиста. Помню давным-давно пробовал, но меня не впечатлило. Кажется, было много ошибок, а я получал какой-то код уровня джуна с курсов "Шарп за неделю".

Возможно, сейчас настала пора попробовать снова. Тем более и циферки появились (бенчмарки), и инфраструктуру какую-никакую для IDE уже создали, да и сама технология более-менее избавилась от первых проблем.

https://t.me/probelov_net/21

C# Heppard

28 Jul, 16:39


Боксинг IEnumerator #скорость #память #бенч

Кажется, что не всем понятна борьба за использование исходной коллекции в .NET. Поясняю весьма избитую истину - бежать по IEnumerable (IList, IReadOnlyCollection и т.п.) сильно дороже, чем по исходной коллекции. Как по памяти, так и по скорости.

Позволю себе напомнить внутреннюю работу .NET на примере List<T>.

Класс List<T> обладает методом GetEnumerator, который вызывается при попытке сделать по нему foreach. Как можно заметить, List<T>.Enumerator - структура. Это важно, поскольку структура создаётся на стеке, а её методы вызываются напрямую (см. call и callvirt). Это позволяет пробегать по списку быстро и без затрат на выделение места в куче, а значит без работы GC.

Но стоит нам скастить List<T> к IList<T> - ситуация изменится. В этом случае foreach вызовет метод интерфейса IEnumerable.GetEnumerator, который возвращает IEnumerator<T>, то есть интерфейс перечислителя. В случае List’a это будет та же самая структура, но размещённая в куче и доступом к её методам через callvirt. То есть мы не только создадим небольшой memory traffic, но и сильно замедлим перебор коллекции.

Насколько всё это будет медленнее - см. результаты бенчмарка.

C# Heppard

28 Jul, 12:07


Танцы вокруг Enumerable.ToArray #скорость #память

Если мы пишем код по гайдлайнам, то наши методы часто возвращают IEnumerable<T>, IReadonlyCollection<T> и прочие интерфейсы коллекций. Это необходимо для того, чтобы сигнатура метода не изменялась при изменении логики метода, что, в свою очередь, является фундаментом для создания устойчивого к изменениям кода. В принципе, очень полезная и весьма здравая мысль.

При получении IEnumerable из какого-либо метода, мы часто делаем ToList или ToArray. Например, чтобы получить возможность пробежаться по перечислению более чем один раз (см. multiple enumerations в случае IEnumerable). Или, например, мы не хотим аллоцировать Enumerator при пробегании по IReadonlyCollection. Короче говоря, по каким-то перформансным соображениям, нам интерфейсы не подходят.

В этой ситуации некоторые программисты допускают первую логическую ошибку. Они подсматривают код метода, который возвращает интерфейс коллекции, видят, что за ним на самом деле скрывается массив, и делают предположение о том, что им тоже нужно использовать исходную коллекцию, скрытую за интерфейсом.

Далее действия могут быть различными и зависеть от знаний глубин .NET'a.

Например, некоторые коллеги верят, что если они вызовут метод ToArray, то произойдёт магия: мол, dotnet знает настоящий тип коллекции, которая возвращается из метода, а значит просто его и вернёт. Увы, это не так. Если мы посмотрим код, то можно заметить, что при вызове Enumerable.ToArray создаётся новый массив с копией данных исходного, который и возвращается потребителю.

Другие коллеги будут более упорными в желании добраться до исходной коллекции, и создадут метод AsArray. Он, я уверен, есть во многих проектах. Этот метод прост, он проверяет тип, и, если это действительно массив, просто возвращает его. Если же это другой тип коллекции, то будет использоваться стандартный Enumerable.ToArray.

Скорость сильно выше, никаких дополнительных аллокаций сделано не будет (см. бенчмарк), а значит будет сделан вывод, что это идеальное решение для ситуаций работы с приходящим IEnumerable. Код, думаю, будет примерно таким:


public static T[] AsArray<T>(this IEnumerable<T> collection)
{
return collection as T[] ?? collection.ToArray();
}


Казалось бы win-win. Но нет. И это вторая логическая ошибка.

Если мы ожидаем массив, исходный метод делает массив и мы придумали костыль, чтоб всё-таки получить массив... не лучше ли просто возвращать массив? Зачем эти танцы с бубном?

Если это наш код и нам ну очень нужен конкретный тип коллекции - давайте просто его использовать. Да, если до этого был IEnumerable, а теперь стал массив - это ломающее изменение и оно не подойдёт для библиотеки с тыщами потребителей... но уж внутри нашего приложения медиаторный хэндлер мы поправить в состоянии.

Ещё одним неприятным моментом будет изменение исходной коллекции. Например, мы получили перечисление, скастили его к массиву и изменили... А оно, например, служит неким кэшем. Как результат, возможно очень странное поведение в других местах кода, которые полагаются на неизменяемость исходной коллекции.

Короче говоря, мне кажется, что иногда не нужно изобретать велосипед. В данном случае, я в этом почти уверен.

P.S.: Александр, спасибо!
P.P.S.: Для серьёзных ребят Денис сделал собственный перечислитель для разных случаев. Кажется, что это очень хорошее решение, которое ликвидирует минусы подобного подхода.

C# Heppard

29 Jun, 14:38


Логика на throw #скорость #память

Известно, что логика на throw - не очень. Ну, это когда мы выбрасываем ошибку в методе, окружаем его вызов try/catch и, в зависимости от того, была ли ошибка, выбираем тот или иной сценарий выполнения.

Типа, всем известно, что выброс ошибки, её перехват и раскручивание стека вызова - дорогая операция. Но меня давно интересовало, а, собственно, насколько "дорого" строить логику на throw? Как раз недавно, на собеседовании, был затронут этот вопрос.

Итак, докладываю. Бенчмарк будет в комментариях.

1. Обычный if/else вне конкуренции.
2. Выброс ошибки не только в восемь тыщ (!) раз медленнее, но и аллоцирует. Немного, в Gen0, но очень неприятно в горячих местах кода.
3. Если возвращать ошибку в Result (очень популярная фишка из функциональщины), то это чуть-чуть медленнее обычного if.

Выводы: не надо строить логику на ошибках (а кто бы сомневался), ну а если нам очень надо всё-таки возвращать ошибку коду выше, но без throw, то делаем это с помощью Result.

Казалось бы, очевидно. Но нет, иногда таки встречается в реальном коде.

P.S.: Сергей, спасибо за вопрос.
P.P.S.: Коллега напоминает, что про дорогой выброс ошибки ещё писали вот тут.

C# Heppard

20 Jun, 11:34


ContinueWith #скорость

Не секрет, что мы можем использовать метод ContinueWith для небольшого увеличения производительности. Давно об этом знал, но всё руки не доходили протестировать. Так вот, докладываю.

Делается это просто - мы можем вызвать наш асинхронный метод, а затем, не используя await, написать что-то вроде:


MyAsyncMethod(cancellation).ContinueWith(task => DoSomething(task.Result), cancellation);


Это будет несколько быстрее, чем:


var result = await MyAsyncMethod(cancellation);
DoSomething(result);


Отлично применяется с известной многим сущностью Result<T>, где, в зависимости от этого результата нужно что-то сделать или не сделать.

Напомню, что минусом применения подхода с ContinueWith является то, что логи с ошибками становятся немного... плохо читаемыми.

P.S.: Бенчмарк в комментариях.
P.P.S: Алексею и Игорю спасибо) Было весело это всё отлаживать.

C# Heppard

07 Jun, 15:11


ObjectPool #память #решение

Есть такая работа - ObjectPool. Это когда мы не позволяем GC уничтожать объекты, которые живут очень короткое время, а используем их снова и снова. Это очень помогает в экономии памяти.

Если кто не знал, то new - дорогая операция (хотя есть мнение, что нет), которая требует выделить памяти в heap (для классов, конечно же). Ну в куче, которую контролирует GC. Чтобы не заставлять GC работать (а его работа это дорогой обход дерева), мы можем помещать объекты, которые живут не долго, в специальное место - пул. Извлекая их оттуда, мы их переиспользуем, то есть не заставляем их снова и снова появляться в куче.

Существует хорошая библиотека, которая позволяет это делать. Причём не от кого-нибудь, а от вендора. Это быстрая и хорошо написанное решение.

Но что делать, если нам нужно быстрее?

Ответ, как всегда, есть.

1. Велосипед. Это Manual в бенчмарке. В принципе, там нет ничего особенного - я списал какие-то, с моей точки зрения, важные вещи с реализации Microsoft и из другой билиблиотеки. Это решение лаконично и понятно.
2. Можно воспользоваться той самой "другой" библиотекой. Скорость выше не на порядки, но, тем не менее, выше. А хорошее и подробное описание кода может погрузить нас в прекрасный мир высокой производительности.

В результате, мы получим либо аналогичную производительность (но без зависимостей), либо производительность чуть-чуть выше, чем у того, что предлагает нам вендор.

При этом, внимательный читатель наверняка заметил, что самым эффективным пулом для маленьких объектов является некий ConcurrentToolkitLite. Это внутренний класс библиотеки ConcurrentToolkit. Его реализация проста, и основана на том, что существует всего один ThreadStatic объект, который и содержит данные пула. Вот так просто и элегантно.

Если кому-то мало комментариев Евгения и хочется большего, то могу сообщить дополнительно, что имплементация ConcurrentQueue в .NET выросла вот отсюда. Она же, чаще всего, лежит в основах примеров в интернете.

Кода много, поэтому он тут.

C# Heppard

07 Jun, 12:31


Исходник тут. Джависты нас любят. #юмор

C# Heppard

07 Jun, 10:21


Микросервисы vs монолит #доклад

Что-то я пропустил доклад некого Станислава о монолитах, через микросервисы и обратно, но уже в модули. Всем, кого данная тема волнует, я рекомендую это видео.

1. Глубоко, как это принято у Станислава, затронута история вопроса и предпосылки, которые толкают нас от монолитов к микросервисам.
2. Отмечен чисто эмпирический эффект, когда разработчики (или требуют заказчики) закладывают ресурсы на каждый микросервис без понимания границы ресурсов кластера при наличии горизонтального размножения сервисов.
3. Рассказана байка о "черной пятнице", когда взрывной рост нагрузки вызывает каскадный эффект на сотне микросервисов, которые пытаются удвоить потребление ресурсов.
4. Также, подробно, как Станислав любит, рассказана история слияния микросервисов обратно в монолит.
5. Продемонстрирована предварительная статистика слияния 11 сервисов в один, что привело к снижению потребления ОЗУ в 5 раз, а процессора - в 2 раза.
6. Затронут вопрос о том, что взаимодействие сервисов тоже стоит процессорного времени и ОЗУ. И это ещё мы забываем про то, что наличие микросервисов ест ресурсы внешних систем - очередей, балансеров, различных демонов и прочее-прочее.
7. Модульная архитектура позволяет отложить вопрос о том, нужен нам модульный монолит или всё-таки микросервисы, так как вы можете принять решение в моменте.

Код Станислав выложил вот сюда.

Также, я настоятельно рекомендую раскрутить некого Руслана ещё раз рассказать, но уже на камеру, вот этот доклад про путь от микросервисов к модулям.

C# Heppard

06 Jun, 16:44


Аутстафф #философия

Коллеги, у меня нет предубеждения перед аутстаффом. Ну, я про работу на галеры. Про компании, которые продают разработчиков другим компаниям. Я лишь хочу указать на некоторые нюансы для коллег с той и с другой стороны.

Итак, в чём преимущества быть аутстаффом:
1. Можно обучиться работать с разными технологиями и проектами. Быстро. Много. Это очень ценный опыт. Резюме будет прекрасным!
2. Рост от джуна до сеньора для серьезных мужиков и девчонок может быть стремительным. Если, конечно, слушать и запоминать. Проверено на собственном опыте.
3. Если проект или технологии не нравятся - их можно быстро сменить. Просто говоришь начальнику галеры, и он договаривается с покупателем. Ну или просит остаться за бонус. Всем хорошо.
4. Вроде как неплохо принимают джунов.

В чем плюсы аутстаффа для покупателя:
1. Экстренный ресурс. Например, быстро закрыть потребность в разработчиках на критических стадиях проекта.
2. Легко уволить. Надо просто не продлевать контракт. Никаких проблем с отработками и дополнительными выплатами - по законодательству аутстафф работает на другую компанию.
3. Чаще всего персонал как минимум уровня middle. Это значит, что коллеги умеют и будут копать. Наверное. Возможно. Если не подсунут джуна.

В чём минусы быть аутстаффом:
1. Могут не найти проект. В этом случае надо будет раскладывать пасьянс. Я серьёзно. Просто ходишь на работу и, например, пишешь какие-то внутренние документы, которые никто не читает. Или прям пасьянс.
2. Иногда ЗП зависит от прибыли с проекта. Например, ЗП это МРОТ, а всё остальное - бонус. В этом случае, получить нормальные деньги можно только тогда, когда есть проект. Иначе - пасьянс.
3. Мутные схемы работы. Например, иногда надо работать под акаунтом человека, который давно уволился. Но покупатель об этом не знает. И надо скрывать. И соответствовать тому чуваку. Помню, я уволился, а под моим акаунтом в одной американской компании ещё лет 5 сидели 10 разных людей.
4. Иногда надо работать на два проекта. Но каждый из покупателей об этом не знает. Например, вы работаете на проект и компанию А, а в Б внезапно надо быстро что-то сделать. Без погружения, без всяких сложностей. Дали задачу, дали доступ к репо - вперёд, есть ночь.
5. Иногда в резюме для покупателя пишут не то, что умеешь, а потом надо как-то притворяться тем, кем ты не являешься.
6. Увольнение стремительное и без объяснений.
7. В своё время в одной крупной американской компании, которая имела деятельность на территории РФ, были бейджики двух разных цветов. И снять его было нельзя. Надо ли говорить, что права разных бейджиков были разными?

В чем минусы аутстаффа для покупателя:

1. Иногда middle не нужен. А senior’ов нет, так как все они ушли в продуктовые компании. Поэтому будут подсовывать мидлов под видом сеньёров.
2. Обучение нового сотрудника не быстрое, а его могут просто не продлить на следующий месяц. И всё обучение «в трубу».
3. Врут в резюме. Или можно наткнуться на кандидата, который вообще не имеет опыта в нужных технологиях, но оплата была за каждого, кто был предоставлен, а поэтому гонят всех подряд. Без оценки. C++ и C#, ну какая, к бесу, разница?
4. Каждого кандидата надо собеседовать. Также, как и обычных. Иначе можно попасть в ситуацию, что подослали джуна с питоном, который работает на нескольких проектах.
5. Ад с доступами. Аутстафф не всегда человек по версии ИБ - нужна куча дополнительных согласований. И, кстати, не факт, что успешных.
6. Нужны хорошие процессы производства. Если у покупателя задачи в трекере описаны тезиснно, документации нет, тестов мало - будет большой проблемой объяснить аутстаффу, а что, собственно, нужно сделать.

Как это связано с производительностью? Если вы лид, то напрямую - вашу команду усиливают, а значит будут требовать результаты. Знайте плюсы и минусы. Ну, а если вы аутстафф - знайте то, в какой ситуации вам надо высекать каменный цветок.

P.S.: Привет моим коллегам с галеры! Это было крутое время!

C# Heppard

05 Jun, 16:14


Разные платформы и процессоры #бенч

В продолжение разговора о разных ОС и процессорах, который был начат в посте про Random, необходимо понимать следующее.

И это нужно знать. Это нужно проверять. И с этим нужно смириться.

Ваш код на разных платформах и на разных процессорах будет исполняться по разному. Такова реальность. Особенно, если дело касается микрооптимизаций.

Вот, например, у нас простейший бенчмарк. Это старый бенчмарк, который я периодически запускаю на разных версиях .NET и на разных компьютерах. Бенчмарк пытается воспроизвести Array bound check elimination (оптимизацию JIT, которая позволяет избегать проверки границ массива). Штука уже не очень актуальная, но я люблю этот бенч, так как я подсмотрел его аж в 2015 году у некого Андрея.

И вот, мы снова запускаем его в 2024 году на одном .NET 8, но на разных машинах.

Что мы видим:
1. Версия BenchmakDotNet и самого .NET одинаковая.
2. Разные ОС (Windows 11 и MacOS 14.5).
3. Разные процессоры (Ryzen 5800H и M3 Max).
4. Разная скорость исполнения (46 ns против 29 ns).
5. И совершенно разные относительные результаты.

Если на Windows результат _array.Length и константы примерно одинаковый, то на MacOS результаты разные, что несколько внезапно и совершенно не понятно.

IL код в моей IDE одинаковый.

Вывод: знайте результаты на целевой ОС и целевых процессорах. В идеале, нужно встроить проверку работы ключевых алгоритмов прямо в CI/CD. Всё иное (я говорю про микрооптимизации) - результат на вашей и только вашей машине.

Код бенчмарка в комментариях.

C# Heppard

03 Jun, 16:22


Как читать бенчмарки #бенч

Давайте мы немного вспомним основы, и напомним, как читать результаты BenchmarkDotNet.

Итак, когда мы создали первый бенчмарк мы получаем примерно такую табличку с данными. Казалось бы, ну чего тут такого. Рассказываю.

1. В заголовке мы видим версию BenchmarkDotNet. Это важно, так как .NET меняется, а значит меняется и прибор, с помощью которого его измеряют.
2. Далее следует версия ОС. Это важно. Например, запуск на Windows и Linux может отличаться.
3. Далее идёт информация о процессоре. В данном случае она "Unknown processor", так как это контейнер. Однако, мы не можем сомневаться в том, что тип и разрядность процессора влияют на скорость работы.
4. Далее идёт версия .NET. Напомню, что разница производительности некоторых версий .NET поразительна. Иногда есть деградация, иногда - прорыв. На неё нужно обращать внимание.
5. Method - имя бенчмарка. Каждый бенчмарк запускается изолировано и сопровождается отдельным прогревом (подробности пока оставим). Мы можем быть уверенными в том, что бенчмарки не влияют друг на друга.
6. Mean - это время выполнения бенчмарка. Замечу, что по-умолчанию, это усреднённое время выполнения 15 бенчмарков. Иногда - большего количества, если в процессе их выполнения были обнаружены статистические выбросы - тогда количество повторений увеличится вплоть до 100.
7. Ratio - это отклонение скорости работы отдельных бенчмарков относительно основного (Baseline). Его можно узнать по цифре "1.00". В данном случае это Storage.
8. Gen0, Gen1, Gen2 - среднее количество сборок мусора по поколению на одно исполнение бенчмарка. Особенно важно, что эта статистика указывает, насколько наш GC (см. ОС и процессор) в нашем сценарии должен успевать собирать мусор.
9. Allocated - общее количество аллоцированых данных в памяти. Помогает оценить верхнюю границу памяти, затраченной на один бенчмарк.
10. Alloc Ratio - относительное количество затраченной памяти. Помогает оценивать работу алгоритма относительно Baseline.

C# Heppard

03 Jun, 14:57


LeetCode #скорость

Когда вы тренируете свои навыки в области алгоритмов с помощью сторонних ресурсов - это прекрасно! Честь и хвала таким ресурсам! Там есть задачи, на которых можно тренировать навыки, которые всегда понадобятся нам в разработке на собеседовании.

Однако, есть один неприятный нюанс. Даже три. Два понятных, а один - такой себе.

Например, задачка TwoSum. Это тот самый "изян", который спрашивают на собеседованиях в разные конторы. Так что, собственно, мне не нравится?

Во-первых, наше решение запускаются на непонятных машинах. Например, на моём скриншоте решение задачи выполнялось аж 156 миллисекунд.

Да, согласен, у меня банальный брутфорс, решение "в лоб", да и вообще, я это всё написал для примера. Но... 156, да ещё и миллисекунд, перебор. То есть то, на чём запускается наш алгоритм будет, скорее всего, не самой мощной и не самой нормальной тачкой. Следовательно, доверять измерениям скорости нельзя.

Во-вторых, я употребил аж 47 Мб. Гайс, ну, коммон. Я даже ничего не аллоцировал в своём решении. Откуда 47 Мб? Хотя, наверное, это всё та же машина (или процесс), которая поверхностно измеряется в плане потребляемой памяти. Скорее всего, это ценник за весь дотнет вообще. Странно, конечно, но понятно, и тоже принимается.

В-третьих, и, видимо, в главных. Я надеюсь, что в этих задачах никто не гонится за первенство в скорости или потребления памяти. Да, судя по графику, там есть люди, которые выполнили задачу за 80 миллисекунд или за 45 мегабайт... Но по факту, все эти цифры невероятно завышены. Я бы сказал, на порядки. И взяты с потолка. Ну то есть вообще.

Причём даже относительные цифры сомнительны, верить им нельзя. Так, мой алгоритм выдавал то 104, то 156 мс при разных запусках. Так и в чём соревнование? Неужели в предложении купить подписку?... Нет, надеюсь, что это не так.

Код бенчмарка, который работает 105 наносекунд и использует 448 байт памяти без всяких оптимизаций - в комментах. Задачка, ещё раз, вот эта.

P.S.: Предположение о том, почему "47 МБ" - высказано коллегой тут.

C# Heppard

28 May, 14:01


Random #скорость

Когда мы используем Random, мы явно не имеем ввиду ничего криптостойкого. Собственно, в документации прямо так и сказано: генерирует последовательность чисел, отвечающую определенным статистическим критериям случайности. Это так называемые псевдо-случайные числа.

Более того, чаще всего Random используется просто для некоторой случайности или её имитации. Например, в тестах, UI или компьютерных играх. Последние особенно круто вводят игроков в заблуждение, поскольку у этих ребят каждый кадр на счету.

Ни для кого не будет открытием, что Random.Shared быстрее обычного Random за счёт имплементации более интересного алгоритма.

Но что, если нам нужно быстрее? Ответ, как всегда, есть.

Если нам нужна некоторая последовательность чисел, которая просто случайная для 5-10 секунд, то нам поможет их запись в отдельный массив. Да, последовательность цифр будет одинаковая, но за счёт того, что они берутся в разное время для разных сущностей или ситуаций, мы получаем... случайность.

Естественно, раз в некоторый такт системы (см. метод Dice.Update, который обновляет внутреннюю коллекцию псевдо-случайных чисел) мы можем обновить случайную последовательность, что и создаёт иллюзию случайности вообще.

Надо ли говорить, что такой подход сильно быстрее тех алгоритмов, которые существуют в .NET "из коробки". Естественно, с ограничениями, которые можно заметить на сравнении 100 и 1000 в бенчмарке.

Код в комментариях.

P.S.: Я этот подход явно где-то подглядел. Если кто-то знает научное название, то напишите в комментах - всем будет приятнее и понятнее.
P.P.S: Коллега прогнал бенч на Intel + Win11 - результат тут. Он интересный.

C# Heppard

23 May, 16:20


ConcurrentDictionary #собес #память

Коллеги, задачка. Прямо с собеса, с пылу-с-жару. Сколько раз значение будет выведено в консоль?


const string Key = "SomeKey";
var topLevel = new ConcurrentDictionary<string, string>();

for (var i = 1; i < 100; i++) {
var thread = new Thread(Add);
thread.Start(topLevel);
}

static void Add(object? val) {
var dict = (ConcurrentDictionary<string, string>)val!;
dict.GetOrAdd(Key, x => {
lock (Lock.Obj)
{
if (dict.TryGetValue(Key, out var value)) return value;
var res = Guid.NewGuid().ToString();
Console.WriteLine(res);
return res;
}
});
}


Усложняем:
1. Теперь заменим тип значения у ConcurrentDictionary на Guid. Естественно строку var res = Guid.NewGuid().ToString(); нужно исправить как var res = Guid.NewGuid().
2. А теперь изменим тип значения на int.

Как же так? Надо пояснить в чём, собственно, дело. Можно скопировать и воспроизвести у себя. И я настоятельно (!) рекомендую это сделать.

---

С одной стороны, суть происходящего проста: ConcurrentDictionary не гарантирует, что элемент по ключу будет создан один и только один раз. Это делается для исключения проблем, которые могут возникнуть при выполнении неизвестного кода под блокировкой, что верно заметил коллега.

А другой стороны, суть происходящего в сложном сценарии не так проста, но всё ещё, кстати, логична.

Когда мы отпускаем lock, то железке нужно время записать данные в память. И чем бОльший объем данных мы записываем в память, тем это дольше, что подтверждает эмпирический опыт коллеги и дальнейшее развитие рассуждений.

Причём скорость зависит (c) от всего сразу: размера данных, количества процессоров, разрядности процессора и скорости памяти. Например, размер int это 4, а string - 8. И, если повезёт, это, в свою очередь, равно sizeof(IntPtr) или nint (в зависимости от разрядности процессора). Если разрядность позволяет записать данные в память за одну операцию, то это дает другим потокам (ядрам) больше времени посоревноваться на скорость. Если поток всего один - то конкуренции нет.

Мораль всего этого такова: осторожнее на собеседованиях. Нет, мораль другая - если мы пишем асинхронный код, то было бы неплохо протестировать наши результаты не только у себя, но, также, на целевой машине и у того парня. Возможно, наш гениальный код будет работать тут, но не там, а вот там - он вообще всё сломает.

1,479

subscribers

55

photos

2

videos