Каждый разработчик на .NET сталкивался с этим. Всё работает быстро, но иногда случается внезапный фриз. Игра проседает с 60 до 30 FPS на секунду. Сервис отвечает на запрос 100 мс вместо обычных 10. UI дёргается.
Виновник — Garbage Collector.
Когда GC решает собрать мусор, он останавливает все потоки приложения (Stop-The-World). Для игр и real-time сервисов это катастрофа.
Стандартные коллекции .NET создают мусор везде:
// Каждая из этих операций аллоцирует памятьvar list = new List<int>(); // аллокацияlist.Add(42); // может аллоцировать при росте capacityvar result = list.Where(x => x > 10); // аллокация итератораvar arr = list.ToArray(); // аллокация массиваlist.Clear(); // O(N) — обход массива, но не аллокация
В типичном игровом кадре может быть сотни таких операций. Мусор накапливается, GC собирает его в самый неподходящий момент — во время босс-файта.
Что с этим делают обычно
|
Подход |
Проблемы |
|---|---|
|
Ограничивать аллокации вручную |
Очень сложно, легко ошибиться |
|
Использовать |
Неудобно, нет поддержки Dictionary/HashSet, нужно вручную возвращать |
|
Использовать |
Не покрывает все сценарии (словари, хеш-сеты) |
|
Unity Collections + Burst |
Работает только в Unity, сложный API |
|
Свои пулы |
Долго писать и отлаживать, особенно для сложных коллекций |
Решение: GC-free коллекции с пулингом и знакомым API
GcFreeCollections — библиотека для .NET 8.0+ и Unity, которая заменяет стандартные коллекции на их GC-free аналоги.
dotnet add package GcFreeCollections --version 1.0.0
Ключевые возможности:
-
PooledList<T> — замена
List<T>без аллокаций после прогрева -
PooledDictionary<TKey,TValue> — замена
Dictionary<K,V> -
PooledHashSet<T> — замена
HashSet<T> -
PooledQueue<T>, PooledStack<T>, PooledPriorityQueue<T,P>
-
PooledMemoryStream — замена
MemoryStream -
PooledStringBuilder — замена
StringBuilderс меньшим числом аллокаций -
PooledQuery — LINQ-пайплайн без аллокаций
-
HotPath.Enter() — отлавливает случайные аллокации в DEBUG
-
Leak tracking — находит утечки пулированных объектов
-
Reference Quarantine — безопасная очистка через
Maintain()
Чем отличается от стандартных коллекций
|
Характеристика |
List<T> |
PooledList<T> |
|---|---|---|
|
Аллокация при создании |
Есть (new List<T>()) |
Нет (берётся из пула) |
|
Аллокация при добавлении элементов |
Есть (при росте capacity) |
Нет после прогрева (capacity фиксирован или пул расширяется) |
|
Аллокация при Clear() |
Нет, но O(N) обход |
Нет, O(1) (Hot-first Clear) |
|
Возврат памяти |
Только когда GC соберёт |
Явный возврат в пул (Dispose) |
|
Отслеживание утечек |
Нет |
Да (LogLeaks) |
|
Защита от аллокаций в hot path |
Нет |
Да (HotPath.Enter) |
Ключевое отличие: стандартные коллекции оперируют на уровне «создал-использовал-забыл-пусть-GC разбирается». GcFreeCollections — «взял-из-пула-использовал-вернул-в-пул».
Быстрый старт
Шаг 1. Подключение пространства имён
using GcFreeCollections;
Шаг 2. PooledList — замена List
// Вместо var list = new List<int>();using var list = PooledList<int>.Create();list.Add(42);list.Add(100);list.Add(73);foreach (var x in list) // struct enumerator, без аллокаций{ Console.WriteLine(x);}list.Clear(); // O(1), без обхода массива// В конце using автоматически вернёт list в пул
Важно: после Clear() элементы не зануляются мгновенно. Вместо этого используется Reference Quarantine.
Шаг 3. Hot-first Clear и Maintain
// В игровом кадреusing var enemies = PooledList<Enemy>.Create();// ... добавляем врагов, работаем ...enemies.Clear(); // O(1) — быстрая очистка// В конце кадра (или раз в несколько кадров)PooledGlobals.Maintain(); // Постепенно чистим ссылки на Enemy
Шаг 4. PooledDictionary
using var dict = PooledDictionary<string, int>.Create();dict["health"] = 100;dict.Add("mana", 50);if (dict.TryGetValue("health", out int health)){ Console.WriteLine($"Health: {health}");}foreach (var kv in dict) // struct enumerator{ Console.WriteLine($"{kv.Key}: {kv.Value}");}
Шаг 5. PooledHashSet
using var visited = PooledHashSet<int>.Create();visited.Add(42);visited.Add(100);if (visited.Contains(42)){ Console.WriteLine("Found");}
Шаг 6. LINQ-пайплайн без GC
using var numbers = PooledList<int>.Create(10000);for (int i = 0; i < 10000; i++) numbers.Add(i);// Вместо numbers.Where(x => x > 10).Select(x => x * 2).Take(256).ToList()using var result = numbers .Where(x => x > 10) .Select(x => x * 2) .Take(256) .ToPooledList(capacityHint: 256);// Ни одной аллокации на всём пайплайне
Шаг 7. PooledStringBuilder
using var sb = PooledStringBuilder.CreatePooled(128);sb.Append("Player ");sb.Append(123);sb.Append(" HP");string text = sb.ToString(); // Единственная аллокация — финальная строка
Шаг 8. PooledMemoryStream
using var ms = PooledMemoryStream.Create();ms.Write(Encoding.UTF8.GetBytes("hello world"));ms.Position = 0;using var reader = new StreamReader(ms);string content = reader.ReadToEnd();
Полный рабочий пример: игровой менеджер врагов
using GcFreeCollections;public class EnemyManager{ private readonly PooledList<Enemy> _allEnemies; private readonly PooledList<Enemy> _nearbyEnemies; private readonly PooledHashSet<int> _deadEnemyIds; public EnemyManager(int maxEnemies) { _allEnemies = PooledList<Enemy>.Create(maxEnemies); _nearbyEnemies = PooledList<Enemy>.Create(64); _deadEnemyIds = PooledHashSet<int>.Create(); } public void SpawnEnemy(Enemy enemy) { _allEnemies.Add(enemy); } public void UpdateNearbyEnemies(Vector3 playerPosition, float radius) { // Быстрая очистка (O(1)) _nearbyEnemies.Clear(); // Поиск ближайших врагов foreach (var enemy in _allEnemies) { if (Vector3.Distance(enemy.Position, playerPosition) < radius) { _nearbyEnemies.Add(enemy); } } } public void HandleDeaths() { // Используем временную коллекцию для хранения ID умерших using var toRemove = PooledList<int>.Create(); for (int i = 0; i < _allEnemies.Count; i++) { if (_allEnemies[i].IsDead) { toRemove.Add(i); _deadEnemyIds.Add(_allEnemies[i].Id); } } // Удаляем мёртвых (с конца, чтобы не сбивать индексы) for (int i = toRemove.Count - 1; i >= 0; i--) { _allEnemies.RemoveAt(toRemove[i]); } } public void Update() { // Обновляем AI для всех врагов foreach (var enemy in _allEnemies) { enemy.Update(); } } public void EndOfFrame() { // Постепенная очистка ссылок PooledGlobals.Maintain(); } public void Dispose() { _allEnemies.Dispose(); _nearbyEnemies.Dispose(); _deadEnemyIds.Dispose(); }}
Производительность
Тестовый стенд: Intel Core i5-11400F, Windows 11, .NET 8, BenchmarkDotNet 0.15.8
List — Add + итерация
|
N |
List<T> Mean |
List<T> Alloc |
PooledList<T> Mean |
PooledList<T> Alloc |
Speedup |
Alloc gain |
|---|---|---|---|---|---|---|
|
1000 |
1,665 ns |
4,056 B |
1,503 ns |
56 B |
1.11× |
72× |
|
10000 |
16,312 ns |
40,056 B |
14,319 ns |
56 B |
1.14× |
715× |
Dictionary — Add + TryGetValue
|
N |
Dictionary Mean |
Dictionary Alloc |
PooledDictionary Mean |
PooledDictionary Alloc |
Speedup |
Alloc gain |
|---|---|---|---|---|---|---|
|
1000 |
21,293 ns |
31,016 B |
25,407 ns |
88 B |
0.84× |
352× |
|
10000 |
348,706 ns |
283,042 B |
310,081 ns |
88 B |
1.12× |
3,216× |
Note: Dictionary на 1000 элементах немного медленнее, но экономит 352× памяти.
HashSet — Add + Contains
|
N |
HashSet Mean |
HashSet Alloc |
PooledHashSet Mean |
PooledHashSet Alloc |
Speedup |
Alloc gain |
|---|---|---|---|---|---|---|
|
1000 |
12,518 ns |
58,664 B |
6,377 ns |
72 B |
1.96× |
815× |
|
10000 |
177,439 ns |
538,656 B |
55,427 ns |
72 B |
3.20× |
7,481× |
LINQ — Where/Select/Take/ToList
|
N |
LINQ Mean |
LINQ Alloc |
PooledQuery Mean |
PooledQuery Alloc |
Speedup |
Alloc gain |
|---|---|---|---|---|---|---|
|
1000 |
2,367 ns |
6,496 B |
1,845 ns |
112 B |
1.28× |
58× |
|
10000 |
12,522 ns |
42,496 B |
10,726 ns |
112 B |
1.17× |
379× |
StringBuilder / PooledStringBuilder
|
N |
StringBuilder Mean |
StringBuilder Alloc |
PooledStringBuilder Mean |
PooledStringBuilder Alloc |
Speedup |
Alloc gain |
|---|---|---|---|---|---|---|
|
1000 |
37 ns |
408 B |
49 ns |
80 B |
0.75× |
5.1× |
Note: PooledStringBuilder пока медленнее для маленьких строк, но экономит память.
Где применяется
1. Игровая разработка (Unity / Godot / Monogame)
Проблема: GC-спайки вызывают frame hitches.
Решение: Pooled-коллекции в Update/FixedUpdate.
void Update(){ using var activeEnemies = PooledList<Enemy>.Create(); GetActiveEnemies(activeEnemies); foreach (var enemy in activeEnemies) { enemy.UpdateAI(); }} // Автоматический возврат в пул
2. Real-time сервисы (FinTech / Trading)
Проблема: GC-паузы влияют на latency-sensitive операции.
Решение: Zero-аллокации в обработке заявок.
public void ProcessOrders(ReadOnlySpan<Order> orders){ using var buyOrders = PooledList<Order>.Create(orders.Length); using var sellOrders = PooledList<Order>.Create(orders.Length); foreach (var order in orders) { if (order.Type == OrderType.Buy) buyOrders.Add(order); else sellOrders.Add(order); } MatchOrders(buyOrders.AsSpan(), sellOrders.AsSpan());}
3. Сетевые серверы (Multiplayer games)
Проблема: Тысячи подключений, каждое создаёт мусор.
Решение: Переиспользуемые коллекции для каждого клиента.
public class GameRoom{ private readonly PooledList<Player> _players; private readonly PooledList<Message> _pendingMessages; public void Broadcast(Message msg) { for (int i = 0; i < _players.Count; i++) { _players[i].Send(msg); } }}
4. VR/AR приложения
Проблема: Любой пропущенный кадр вызывает дискомфорт.
Решение: Стабильный frame pacing без GC.
5. Аудио/DSP обработка
Проблема: Аллокации в аудио-потоке вызывают щелчки.
Решение: Pooled-буферы в реальном времени.
6. Unity Editor инструменты
Проблема: Стандартные коллекции в Editor Tooling создают мусор.
Решение: GC-free коллекции для парсеров и генераторов.
Сравнение с конкурентами
|
Библиотека |
GC-free |
Pooling |
Dictionary |
HashSet |
LINQ pipeline |
Hot Clear |
Quarantine |
Актуальность |
|---|---|---|---|---|---|---|---|---|
|
.NET Standart |
❌ |
❌ |
✅ |
✅ |
✅ |
❌ |
❌ |
✅ |
|
Unity Collections |
✅ (Native) |
❌ |
NativeHashMap |
NativeHashSet |
❌ |
❌ |
❌ |
✅ |
|
Roaring Bitmaps |
✅ |
❌ |
❌ |
❌ (только int) |
❌ |
❌ |
❌ |
✅ |
|
C5 |
❌ |
❌ |
✅ |
✅ |
❌ |
❌ |
❌ |
❌ (устарела) |
|
Самописный пул |
✅ |
✅ |
❌ (сложно) |
❌ (сложно) |
❌ |
❌ |
❌ |
Зависит |
|
GcFreeCollections |
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
✅ |
Когда GcFreeCollections выигрывает:
-
Нужны знакомые API (List/Dictionary/HashSet) без аллокаций
-
Проект на чистом .NET (не только Unity)
-
Нужен LINQ-пайплайн без мусора
-
Важны инструменты отладки (Leak tracking, HotPath guard)
-
Не хотите писать свои пулы и отлаживать их
Когда стоит выбрать альтернативу:
-
Unity Collections + Burst — если вы активно используете Job System и вам нужна максимальная производительность за счёт нативного кода
-
Стандартные коллекции — для небольших проектов без требований к GC
-
Roaring Bitmaps — если вам нужно компактное хранение множеств целых чисел
-
Самописный пул — если у вас очень специфические требования и есть время на отладку
Отладка и инструменты
Отслеживание утечек (DEBUG)
// В конце сессии или при выгрузке уровняPooledGlobals.LogLeaks(); // Логирует все объекты, не возвращённые в пулPooledGlobals.AssertClosedPool(); // Бросает исключение, если есть утечки
Защита от случайных аллокаций в hot path (DEBUG)
void CriticalUpdate(){ using var hot = HotPath.Enter(); // Любая аллокация внутри выбросит исключение // Если здесь случится new List<int>() или другая аллокация — получите исключение using var list = PooledList<int>.Create(); // OK list.Add(42); // OK}
Leak tracking для сложных сценариев
В DEBUG режиме каждый объект, взятый из пула, отслеживается. При вызове LogLeaks() вы увидите, где был создан объект, который не вернули в пул.
Unity Integration
using UnityEngine;using GcFreeCollections;public class PooledGameManager : MonoBehaviour{ private PooledList<GameObject> _activeProjectiles; void Start() { _activeProjectiles = PooledList<GameObject>.Create(256); } void Update() { _activeProjectiles.Clear(); // O(1) быстрая очистка foreach (var proj in FindObjectsOfType<Projectile>()) { _activeProjectiles.Add(proj.gameObject); } } void LateUpdate() { // В конце кадра — постепенная очистка PooledGlobals.Maintain(); } void OnDestroy() { _activeProjectiles.Dispose(); PooledGlobals.AssertClosedPool(); // Убедимся, что всё вернули }}
Бесплатное тестирование — без ограничений. Коммерческое использование требует лицензии.
NuGet: https://www.nuget.org/packages/GcFreeCollections
GitHub (бенчмарки): https://github.com/likeslines-maker/GcFreeCollections
GcFreeCollections — это библиотека GC-free коллекций для .NET и Unity.
Она решает конкретную проблему: непредсказуемые паузы из-за сборки мусора в real-time системах.
В отличие от стандартных коллекций:
-
Не создаёт мусора после прогрева (экономия памяти до 7481×)
-
Clear() работает за O(1) вместо O(N)
-
Есть пулинг из коробки
В отличие от Unity Collections:
-
Работает на любом .NET (не только Unity)
-
Привычный API (List/Dictionary/HashSet)
-
Есть LINQ-пайплайн без аллокаций
В отличие от самописных пулов:
-
Уже отлажено и протестировано
-
Есть инструменты отладки (Leak tracking, HotPath guard)
-
Поддерживает сложные структуры (Dictionary, HashSet)
Если ваш проект страдает от GC-спайков — попробуйте GcFreeCollections. Стабильный frame rate и предсказуемая латентность того стоят.
ссылка на оригинал статьи https://habr.com/ru/articles/1042340/