Сравнивая различный 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.

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

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

Комментарии (10)


  1. kxl
    02.07.2025 05:49

    Было бы неплохо, если в readme была бы строчка о том, как этот анализатор подключить к своему проекту...


    1. srogatnev Автор
      02.07.2025 05:49

      Справедливо. Добавил секцию про установку. Кратко: подключается как любой другой nuget-пакет к проекту.


  1. Schwarzen
    02.07.2025 05:49

    Когда вы видите время метода 0.012ns у вас ничего в голове не щелкает? Бенчмаркдотнет много пытается вернуть адекватные числа, но иногда нужно включать мозг все же. Не вижу полных данных с указанным процессором и модальностью распределения, но, наверное, вы сможете поделить секунду на гигагерцы.


    1. kxl
      02.07.2025 05:49

      Другими словами - цифры менее наносекунды считать условными...


  1. Skykharkov
    02.07.2025 05:49

    Как-то встречал реализацию проверки есть ли что-то в массиве или в list просто проверкой нулевого индекса. Ну и try ... catch конечно. Смешновато конечно, типично индусский код. Но тоже имеет право на жизнь.


    1. Proscrito
      02.07.2025 05:49

      У массива фиксированный размер, который указывается при создании. Иными словами, если массив не нулевой длины, в нем всегда "что-то есть", по каждому индексу. Да, существует Resize, который по сути пересоздает массив с новым Length. Но в любом случае, Length у массива всегда известен. List это надстройка над массивом, у которой Count также всегда известен. Он НЕ равен Length массива под капотом, но все равно сохраняется в интовом поле, всегда доступен и ничего быстрее Count == 0 не придумать. Если зачем-то хочется все же получить внутренний массив из листа есть CollectionMarshall.AsSpan(List list) который возвращает спан, что еще лучше чем массив. CollectionMarshall даже позволяет установить Count листу, что чревато катастрофой, если его установить неправильно.

      Что-то оптимизировать имеет смысл только при работе с LINQ. Там часто производительность жертвуется в пользу универсальности и совместимости, поэтому выигрыш там может быть существенным на соответствующих задачах. Правда смысла все меньше, потому что команда дотнет и так оптимизирует LINQ с каждой версией, и делает это очень неплохо. Но если вдруг супероптимизация необходима здесь и сейчас, есть https://github.com/Cysharp/ZLinq вот такая либа, очень и очень на сегодня рабочая. Хотя с каждой мажорной версией дотнета теряет актуальность.


      1. Skykharkov
        02.07.2025 05:49

        Да я то это знаю.
        Говорю-же встречал такое:

        List<string> list = new();
        ... тут что делаем...

        try
        _ = list[0];
        catch
        //А лист то пустой

        Понятно что бред.


        1. VYudachev
          02.07.2025 05:49

          Меня больше интересует проверка

          if (count >= 0)

          Вот интересно, а может ли в реальном коде быть count < 0 и какой смысл в это закладывается? Эта даёт какую-то микрооптимизацию на уровне IL/asm, перестраховка на то, что кто-то в своей реализации интерфейса так пометил специальный случай или просто в жизнь старый анекдот воплощают?

          Математики шутят...

          В аудитории лектор читает лекцию по математике трем студентам. Внезапно встает пять человек и уходят. Лектор:

          - Вот сейчас придут еще двое, и вообще никого не останется.


          1. Skykharkov
            02.07.2025 05:49

            Ну сову на глобус натянуть можно. Хотя чисто в теории... Хотя даже в теории все равно... count это int, а не uint. Хотя... Если

            count = -1;
            try
            count = list.count();
            catch


            И тут дальше какая-то логика когда "-1" это индикация что list вообще не инициализировался даже с нулевой длиной. Это чисто в теории, естественно нужно делать по другому.


  1. lxvkw
    02.07.2025 05:49

    Стоит отметить, что когда идет работа с обобщениями IEnumerable<T>, то желательно проходится по стриму данных один раз.
    В случае же коллекций из серии *Concurrent, они могут меняться пареллельно обработке. И для комбинаций сложнее ToArray\Count\Any стоит использовать внешний локер.