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

Разница между последней версией классического фреймворка и .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

Давайте посмотрим актуальную реализацию свойства 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.
Бонус: автоматизация
Понимая, что в голове всё не удержишь, я стал собирать такие примеры оптимизаций в отдельный анализатор исходного кода.
Подключив его к своим проектам можно автоматически отслеживать потенциально проблемные места:

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

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