Как стать автором
Поиск
Написать публикацию
Обновить
Контур
Делаем сервисы для бизнеса

Нельзя просто так взять и выбрать Any() или Count для проверки коллекции

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров12K

Сравнивая различный code-style в проектах, я упоминал про методы проверки коллекций на наличие элементов. Самые очевидные способы – это использование LINQ-метода Any() или сравнение свойства Count с нулем. И если вы выбрали первый вариант, то могут быть проблемы с производительностью. Поэтому предлагаю подробнее рассмотреть этот вопрос. Кстати, если вы выбрали второй вариант, то проблемы так же могут быть.

Начнем с массива

Начнем с самого простого, с массива. Проверим, есть ли какая-то разница между вызовами array.Any() и array.Length != 0.

Код бенчмарка
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace AnyVsCount
{
    [MemoryDiagnoser]
    [SimpleJob(RuntimeMoniker.Net90)]
    [SimpleJob(RuntimeMoniker.Net80)]
    [SimpleJob(RuntimeMoniker.Net60)]
    [SimpleJob(RuntimeMoniker.Net50)]
    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [SimpleJob(RuntimeMoniker.Net48)]
    [HideColumns("Job", "Error", "StdDev", "Gen0")]
    public class ArrayBenchmark
    {
        [Params(10, 10000)]
        public int N;

        private int[] array;

        [GlobalSetup]
        public void SetUp()
        {
            array = new int[N];
        }

        [Benchmark]
        public bool ArrayAny()
        {
            return array.Any();
        }

        [Benchmark]
        public bool ArrayCount()
        {
            return array.Length != 0;
        }
    }
}

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

| Method     | Runtime            | N     |      Mean |    Median | Allocated |
|------------|--------------------|-------|----------:|----------:|----------:|
| ArrayAny   | .NET Framework 4.8 | 10000 | 9.0093 ns | 9.0043 ns |      32 B |
| ArrayCount | .NET Framework 4.8 | 10000 | 0.0123 ns | 0.0137 ns |         - |
| ArrayAny   | .NET Core 3.1      | 10000 | 7.9624 ns | 7.7794 ns |      32 B |
| ArrayCount | .NET Core 3.1      | 10000 | 0.0311 ns | 0.0295 ns |         - |
| ArrayAny   | .NET 5.0           | 10000 | 4.8357 ns | 4.8352 ns |         - |
| ArrayCount | .NET 5.0           | 10000 | 0.0008 ns | 0.0007 ns |         - |
| ArrayAny   | .NET 6.0           | 10000 | 6.0913 ns | 6.0747 ns |         - |
| ArrayCount | .NET 6.0           | 10000 | 0.0147 ns | 0.0153 ns |         - |
| ArrayAny   | .NET 8.0           | 10000 | 4.7691 ns | 4.7521 ns |         - |
| ArrayCount | .NET 8.0           | 10000 | 0.0110 ns | 0.0078 ns |         - |
| ArrayAny   | .NET 9.0           | 10000 | 2.2933 ns | 2.2906 ns |         - |
| ArrayCount | .NET 9.0           | 10000 | 0.0121 ns | 0.0109 ns |         - |

Мы видим, что прямой вызов свойства Length у массива минимум на 2 порядка быстрее вызова метода Any(). И кажется, что нет никакого смысла его использовать. Но зачастую мы работаем не с коллекциями напрямую, а с обобщенным кодом:

public bool IsEmpty<T>(IEnumerable<T> collection)
{
    return !collection.Any();
}

Здесь у нас уже нет возможности использовать свойстваCount или Length. Поэтому продолжим наше исследование и внимательнее посмотрим, почему получается такая разница в методе Any() в разных версиях фреймворка.

Сравним разные версии .NET

Видно, что от .NET 4.8 до .NET 9.0 время выполнения метода Any() уменьшилось в 4 раза:

Время выполнения метода Any() в разных версиях фреймворка.
Время выполнения метода Any() в разных версиях фреймворка.

Разница между последней версией классического фреймворка и .NET Core незначительная. Давайте посмотрим на реализацию метода Any() внутри .NET 4.8 и .NET Core 3.1 (они совпадают):

public static bool Any<TSource>(this IEnumerable<TSource> source)
{
    if (source == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
    }

    using (IEnumerator<TSource> e = source.GetEnumerator())
    {
        return e.MoveNext();
    }
}

Как видим, чтобы определить, есть ли в последовательности хотя бы один элемент, создаётся итератор по этой последовательности и вызывается метод MoveNext(). На это же нам намекали 32 байта выделяемой памяти в бенчмарке – как раз затраты на создание итератора. Что же изменилось в .NET 5? Давайте опять посмотрим на код:

public static bool Any<TSource>(this IEnumerable<TSource> source)
{
    if (source == null)
    {
        ThrowHelper.ThrowArgumentNullException(ExceptionArgument.source);
    }

    if (source is ICollection<TSource> collectionoft)
    {
        return collectionoft.Count != 0;
    }
    else if (source is IIListProvider<TSource> listProv)
    {
        int count = listProv.GetCount(onlyIfCheap: true);
        if (count >= 0)
        {
            return count != 0;
        }
    }
    else if (source is ICollection collection)
    {
        return collection.Count != 0;
    }

    using (IEnumerator<TSource> e = source.GetEnumerator())
    {
        return e.MoveNext();
    }
}

Реализация метода заметно усложнилась. Вначале проверяется, реализует ли перечисление интерфейс ICollection<T>, у которого есть свойство Count. А дальше мы видим использование нового интерфейса IIListProvider<TSource> (внутренний интерфейс .NET, оптимизирующий операции LINQ за счёт избегания перечисления элементов). И только если все «дешевые» варианты не подошли, то будет создан итератор. Это решение подозрительно похоже на то, которое я предлагал еще 7 лет назад в своём блоге. Если интересно, то заходите почитать.

В восьмой версии .NET код, который вычисляет размер коллекции (не выполняя то самое перечисление) вынесли в отдельный метод TryGetNonEnumeratedCount(). Этот метод работает за константное время, но не всегда может вернуть значение.

В девятой версии .NET концепция использования IIListProvider<TSource> получила развитие и LINQ-методы были переработаны с использованием нового класса Iterator<TSource>, что позволило еще улучшить производительность.

Напрашивающееся решение с проверкой типа перечисления и свойства Count было реализовано в новых версиях .NET. Казалось бы, на этом можно было бы и остановиться, ведь все популярные коллекции реализуют интерфейс ICollection<T>, а значит, мы за константное время можем получить размер коллекции и сравнить его с 0.

Но будет ли это эффективно для всех коллекций?

ConcurrentDictionary

В многопоточных системах часто используются потокобезопасные коллекции из пространства System.Collections.Concurrent. Давайте рассмотрим самую распространенную из них – ConcurrentDictionary<TKey, TValue>. Вспоминая прошлое исследование, кажется, что могут быть проблемы. Запустим аналогичный бенчмарк.

Так же не буду приводить все результаты, оставим самые показательные:

| Method          | Runtime            | N     |        Mean |      Median | Allocated |
|-----------------|--------------------|-------|------------:|------------:|----------:|
| DictionaryAny   | .NET Framework 4.8 | 10    |    18.48 ns |    18.47 ns |      64 B |
| DictionaryCount | .NET Framework 4.8 | 10    |   110.77 ns |   110.75 ns |         - |
| DictionaryAny   | .NET Core 3.1      | 10    |    18.21 ns |    18.17 ns |      64 B |
| DictionaryCount | .NET Core 3.1      | 10    |   108.27 ns |   110.12 ns |         - |
| DictionaryAny   | .NET 5.0           | 10    |    92.98 ns |    92.81 ns |         - |
| DictionaryCount | .NET 5.0           | 10    |    97.02 ns |    96.42 ns |         - |
| DictionaryAny   | .NET 9.0           | 10    |    83.02 ns |    83.52 ns |         - |
| DictionaryCount | .NET 9.0           | 10    |    80.43 ns |    79.34 ns |         - |
| DictionaryAny   | .NET Framework 4.8 | 10000 |    18.87 ns |    18.71 ns |      64 B |
| DictionaryCount | .NET Framework 4.8 | 10000 | 6,870.38 ns | 6,859.65 ns |         - |
| DictionaryAny   | .NET Core 3.1      | 10000 |    18.04 ns |    18.06 ns |      64 B |
| DictionaryCount | .NET Core 3.1      | 10000 | 7,000.55 ns | 7,000.95 ns |         - |
| DictionaryAny   | .NET 5.0           | 10000 | 5,877.30 ns | 5,877.50 ns |         - |
| DictionaryCount | .NET 5.0           | 10000 | 5,962.47 ns | 5,958.17 ns |         - |
| DictionaryAny   | .NET 9.0           | 10000 | 5,917.83 ns | 5,917.32 ns |         - |
| DictionaryCount | .NET 9.0           | 10000 | 5,847.50 ns | 5,848.04 ns |         - |

Уже видны некоторые особенности:

  • В старых версиях фреймворка метод Any() был намного быстрее и не зависел от размера коллекции

  • В новых версиях фреймворка метод Any() сравнялся по производительности с Count

Время выполнения метода Any на коллекции ConcurrentDictionary.
Время выполнения метода Any на коллекции ConcurrentDictionary.

Давайте посмотрим актуальную реализацию свойства Count:

public int Count
{
    get
    {
        int locksAcquired = 0;
        try
        {
            AcquireAllLocks(ref locksAcquired);

            return GetCountNoLocks();
        }
        finally
        {
            ReleaseLocks(locksAcquired);
        }
    }
}

private void AcquireAllLocks(ref int locksAcquired)
{
    //...

    // First, acquire lock 0, then acquire the rest. _tables won't change after acquiring lock 0.
    AcquireFirstLock(ref locksAcquired);
    AcquirePostFirstLock(_tables, ref locksAcquired);
    Debug.Assert(locksAcquired == _tables._locks.Length);
}

Подсчет всех элементов требует получения блокировок на специальный внутренний массив _tables._locks. Каждый элемент этого массива блокирует часть словаря. Таким образом, начиная с .NET 5, метод Any() для ConcurrentDictionary использует свойство Count через ICollection<T>, что приводит к таким же блокировкам, как и при прямом вызове Count. А пользователи получили неожиданное ухудшение производительности на ровном месте.

Есть ли альтернативы?

Давайте восстановим реализацию Any() с итератором и проверим производительность этого метода:

[Benchmark]
public bool DictionaryEnumerator()
{
    using (var enumerator = dictionary.GetEnumerator())
    {
        return enumerator.MoveNext();
    }
}
| Method               | Runtime            | N     |     Mean |   Median | Allocated |
|----------------------|--------------------|-------|---------:|---------:|----------:|
| DictionaryEnumerator | .NET Framework 4.8 | 10    | 15.51 ns | 15.52 ns |      64 B |
| DictionaryEnumerator | .NET Core 3.1      | 10    | 14.60 ns | 14.60 ns |      64 B |
| DictionaryEnumerator | .NET 5.0           | 10    | 15.16 ns | 15.14 ns |      64 B |
| DictionaryEnumerator | .NET 6.0           | 10    | 18.60 ns | 18.67 ns |      64 B |
| DictionaryEnumerator | .NET 8.0           | 10    | 12.97 ns | 12.96 ns |      64 B |
| DictionaryEnumerator | .NET 9.0           | 10    | 12.80 ns | 12.81 ns |      64 B |
| DictionaryEnumerator | .NET Framework 4.8 | 10000 | 15.41 ns | 15.38 ns |      64 B |
| DictionaryEnumerator | .NET Core 3.1      | 10000 | 15.03 ns | 14.96 ns |      64 B |
| DictionaryEnumerator | .NET 5.0           | 10000 | 15.90 ns | 15.92 ns |      64 B |
| DictionaryEnumerator | .NET 6.0           | 10000 | 18.50 ns | 18.50 ns |      64 B |
| DictionaryEnumerator | .NET 8.0           | 10000 | 13.38 ns | 12.92 ns |      64 B |
| DictionaryEnumerator | .NET 9.0           | 10000 | 12.97 ns | 12.53 ns |      64 B |

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

Но разработчики .NET дают нам альтернативный вариант для такого специфичного кейса – свойство IsEmpty:

| Method            | Runtime            | N     |         Mean |       Median | Allocated |
|-------------------|--------------------|-------|-------------:|-------------:|----------:|
| DictionaryIsEmpty | .NET Framework 4.8 | 10    |    99.023 ns |    98.709 ns |         - |
| DictionaryIsEmpty | .NET Core 3.1      | 10    |     2.313 ns |     2.313 ns |         - |
| DictionaryIsEmpty | .NET 5.0           | 10    |     2.545 ns |     2.545 ns |         - |
| DictionaryIsEmpty | .NET 6.0           | 10    |     2.247 ns |     2.283 ns |         - |
| DictionaryIsEmpty | .NET 8.0           | 10    |     2.567 ns |     2.568 ns |         - |
| DictionaryIsEmpty | .NET 9.0           | 10    |    10.917 ns |    10.902 ns |         - |
| DictionaryIsEmpty | .NET Framework 4.8 | 10000 | 6,027.991 ns | 6,026.648 ns |         - |
| DictionaryIsEmpty | .NET Core 3.1      | 10000 |     2.320 ns |     2.315 ns |         - |
| DictionaryIsEmpty | .NET 5.0           | 10000 |     2.513 ns |     2.512 ns |         - |
| DictionaryIsEmpty | .NET 6.0           | 10000 |     2.756 ns |     2.759 ns |         - |
| DictionaryIsEmpty | .NET 8.0           | 10000 |     2.673 ns |     2.673 ns |         - |
| DictionaryIsEmpty | .NET 9.0           | 10000 |     3.801 ns |     3.804 ns |         - |

Уже начиная с .NET Core 3.1, реализация этого свойства не зависит от количества элементов, и в случае не пустых коллекций вызов этого свойства не является блокирующим. Для .NET 4.8 мы получили результаты аналогичные вызову свойства Count – там так же используется блокировка на всю коллекцию.

Для .NET 9 результаты получились несколько хуже, но у меня нет быстрого ответа, почему так произошло.

Реализация метода Any() сегодня достаточно оптимизирована, чтобы его использовать в обобщенном коде. В большинстве случаев не будет создаваться итератор, а просто проверим свойство Count. Но для потокобезопасных коллекций есть задел для оптимизаций.

Рекомендации

  • Используйте свойства Count/Length для простых коллекций, если вам важна производительность.

  • Используйте метод Any() для обобщенного кода и IEnumerable<T>.

  • Используйте свойство IsEmpty, если коллекции его поддерживают, начиная с .NET Core 3.1.

Бонус: автоматизация

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

Подключив его к своим проектам можно автоматически отслеживать потенциально проблемные места:

Вместе с предупреждениями так же реализованы и исправления:

Автоматическая замена метода Any() на свойство IsEmpty.
Автоматическая замена метода Any() на свойство IsEmpty.

Помимо разобранного примера, анализатор диагностирует еще некоторые случаи подозрительной работы с коллекциями.

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

Теги:
Хабы:
+54
Комментарии20

Публикации

Информация

Сайт
tech.kontur.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Варя Домрачева