Ускоряем работу C# Dictionary в 2 раза.
Nikolay Gushcharin апреля 18, 2025 Изменено: апреля 18, 2025 #dotnet #csharp #dictionary #csДавайте поговорим о том, как оптимизировать производительность типа Dictionary
в C#.
Этот тип данных — один из самых популярных типов коллекций .NET, и его правильное использование может значительно улучшить производительность вашего приложения. Сначала рассмотрим немного теории работы словаря, а после познакомимся со способами ускорения методов вставки элементов.
Как работает Dictionary?
Перед тем как перейти к оптимизации, давайте разберемся, как вообще работает Dictionary
.
Словарь представляет собой реализацию стандартной хеш-таблицы. Его суть работы состоит в том, чтобы однозначно связать ключ с его значением. И каждый раз когда мы делаем запрос по этому ключу, мы всегда должны получать одно и то же значение. Ключи могут быть как ссылочными, так и значимыми типами. Инициализация происходит либо при создании (если передана начальный размер коллекции), либо при добавлении первого элемента, причем в качестве размера будет выбрано ближайшее простое число. При этом создаются 2 внутренние коллекции — int[] buckets и Entry[] entries.
public
Первая будет содержать индексы элементов во второй коллекции, а она, в свою очередь, сами элементы в таком виде:
private
При добавлении элемента вычисляется хэшкод его ключа и затем — индекс корзины в которую он будет добавлен по модулю от величины коллекции. Затем проверяется нет ли уже такого ключа в коллекции, если есть — то операция Add выбросит исключение, а присваивание по индексу просто заменит элемент на новый.
// выбросит исключение
dictionary.;
// просто подменит значение на новое
dictionary = 20;
Если достигнут максимальный размер словаря, то происходит расширение (выбирается новый размер в 2 раза больше предыдущего, и округляется до ближайшего простого числа). Дальше старый словарь полностью копируется в новое выделенное место. А старая область памяти будет подвержена сборки мусора. Сложность такой операции будет соответственно — O(n).
Если происходит коллизия (в корзине индексов _buckets
уже есть элемент), то новый элемент добавляется в коллекцию, его индекс сохраняется в корзине, а индекс старого элемента — в его поле next.
В результате мы получаем односвязный список относительно дублируемого ключа хеширования. Данный механизм разрешения коллизий называется chaining. Чтобы предотвратить такое поведение, необходимо выбирать такой алгоритм в методе GetHashCode
, который будет давать максимально уникальные значения.
Проблема двойного хеширования
Предположим, необходимо выполнить вставку в словарь какого-либо значения, но перед этим проверить, если ключ уже существует, то вернуть то значение которое уже было записано:
if
На первый взгляд, это кажется логичным решением. Но проблема заключается в том, что здесь происходит двойное хеширование:
- При вызове
ContainsKey
вычисляется хеш для проверки существования ключа. - При добавлении элемента снова вычисляется хеш.
Это приводит к дополнительной нагрузке, особенно если такая операция выполняется в цикле или в горячих путях программы.
Решение проблемы через TryGetValue
Одним из способов минимизировать эту проблему является использование метода TryGetValue
:
if
Этот подход позволяет избежать явного вызова ContainsKey
, но все равно требует двух обращений к словарю:
- Проверка наличия ключа.
- Добавление значения.
Например, у класса ConcurrentDictionary есть метод GetOrAdd, который лишен проблемы с двойным доступом к словарю.
Как можно упростить работу и сохранить функционал? Для начала можно обернуть двойной доступ в отдельный метод GetOrAdd и там разместить методы по добавлению значения.
public static
Но это не решает проблему. Мы все равно делаем две операции доступа по ключу.
Можно ли сделать лучше?
Эффективное решение через CollectionMarshal
Да, можно! В современном .NET есть класс System.Runtime.InteropServices.CollectionsMarshal
, который предоставляет метод TryGetValueRefOrAddDefault
. Он позволяет получить ссылку на значение по ключу или добавить значение за одно обращение к словарю. Вот пример использования:
using ..;
public static TValue
Этот метод доступен начиная с .NET 6 и выше. Он особенно полезен, когда вам нужно выполнять операции "получить или добавить" в высокопроизводительных частях кода.
Расширение функциональности
Кроме GetOrAdd
, вы можете реализовать другие полезные методы для словарей, например:
TryUpdate
Метод TryUpdate
позволяет обновить значение по ключу, если ключ уже существует:
public static bool
Таким же образом можно создать другие методы расширения для словарей, например:
RemoveIfExists
Позволяет удалить ключ, если он существует.
public static bool
Upsert
Позволяет обновить значение, если ключ существует, или добавить новое значение.
public static void
Практический пример
Давайте рассмотрим практический пример использования этих методов:
var dictionary = ;
dictionary.;
dictionary.;
Console.; // {"1":"UpdatedValue1"}
Как видите, эти методы делают работу со словарем более эффективной и чистой.
Заключение
Подведем итоги:
- Использование
ContainsKey
иTryGetValue
может быть неэффективным из-за двойного хеширования. - Методы
CollectionsMarshal
позволяют оптимизировать работу со словарями. - Создание своих методов расширения поможет сделать ваш код более удобным и производительным.
Дополнительные источники: