// MIT License - Copyright (c) 2023 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
// ReSharper disable ConvertClosureToMethodGroup
namespace WallstopStudios.UnityHelpers.Utils
{
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Threading;
using UnityEngine;
using Debug = UnityEngine.Debug;
#if SINGLE_THREADED
using WallstopStudios.UnityHelpers.Core.Extension;
#else
using System.Collections.Concurrent;
#endif
///
/// Provides thread-safe pooled access to commonly used Unity coroutine yield instructions and StringBuilder instances.
/// This class helps reduce allocations by reusing frequently created objects.
///
public static class Buffers
{
///
/// Gets or sets the quantization step (in seconds) applied to pooled WaitForSeconds/WaitForSecondsRealtime durations.
/// Values less than or equal to zero disable quantization.
///
public static float WaitInstructionQuantizationStepSeconds
{
get => Volatile.Read(ref _waitInstructionQuantizationStepSeconds);
set
{
float sanitized = value;
if (float.IsNaN(sanitized) || sanitized <= 0f)
{
sanitized = 0f;
}
Volatile.Write(ref _waitInstructionQuantizationStepSeconds, sanitized);
}
}
///
/// Gets or sets the maximum number of distinct WaitForSeconds/WaitForSecondsRealtime entries cached.
/// A value of 0 disables the cap (unbounded cache).
///
public static int WaitInstructionMaxDistinctEntries
{
get => Volatile.Read(ref _waitInstructionMaxDistinctEntries);
set
{
int sanitized = value < 0 ? 0 : value;
Volatile.Write(ref _waitInstructionMaxDistinctEntries, sanitized);
}
}
///
/// Snapshot of the WaitForSeconds cache (distinct entries, limit hits, quantization info).
///
public static WaitInstructionCacheDiagnostics WaitForSecondsCacheDiagnostics =>
BuildDiagnostics(
WaitForSecondsCacheName,
GetWaitForSecondsEntryCount(),
Volatile.Read(ref _waitForSecondsLimitHits),
Volatile.Read(ref _waitForSecondsEvictions)
);
///
/// Snapshot of the WaitForSecondsRealtime cache (distinct entries, limit hits, quantization info).
///
public static WaitInstructionCacheDiagnostics WaitForSecondsRealtimeCacheDiagnostics =>
BuildDiagnostics(
WaitForSecondsRealtimeCacheName,
GetWaitForSecondsRealtimeEntryCount(),
Volatile.Read(ref _waitForSecondsRealtimeLimitHits),
Volatile.Read(ref _waitForSecondsRealtimeEvictions)
);
///
/// Enables or disables LRU eviction when the cache reaches the max distinct entry count. When enabled, the oldest entries are removed and reused instead of refusing new durations.
///
///
///
/// Performance Characteristics:
/// All LRU operations (lookup, insertion, eviction, access-order update) run in O(1) time.
/// This is achieved by storing a reference directly in each
/// cache entry, enabling O(1) removal and re-insertion for access-order updates.
///
///
/// Thread Safety:
/// When SINGLE_THREADED is not defined (default), all cache operations are protected
/// by a lock. When SINGLE_THREADED is defined, lock overhead is eliminated for
/// WebGL and other single-threaded runtimes.
///
///
public static bool WaitInstructionUseLruEviction
{
get => Volatile.Read(ref _waitInstructionUseLruEvictionFlag) != 0;
set => Volatile.Write(ref _waitInstructionUseLruEvictionFlag, value ? 1 : 0);
}
///
/// Stores a cached wait instruction alongside its position in the LRU ordering.
///
/// The type of wait instruction (WaitForSeconds or WaitForSecondsRealtime).
///
/// By storing the directly, we achieve O(1) complexity for:
///
/// - Removing the entry from its current position: is O(1)
/// - Moving the entry to the end (most recently used): is O(1)
/// - Evicting the oldest entry: and are O(1)
///
/// This avoids the O(n) traversal that would be required if we only stored keys and had to search for them.
///
private readonly struct WaitInstructionCacheEntry
{
internal readonly TInstruction _value;
internal readonly LinkedListNode _node;
internal WaitInstructionCacheEntry(TInstruction value, LinkedListNode node)
{
this._value = value;
this._node = node;
}
}
private static readonly Dictionary<
float,
WaitInstructionCacheEntry
> WaitForSeconds = new();
private static readonly Dictionary<
float,
WaitInstructionCacheEntry
> WaitForSecondsRealtime = new();
private static readonly LinkedList WaitForSecondsOrder = new();
private static readonly LinkedList WaitForSecondsRealtimeOrder = new();
public const int WaitInstructionDefaultMaxDistinctEntries = 512;
private const int WaitInstructionLimitWarningInterval = 25;
private const string WaitForSecondsCacheName = "WaitForSeconds";
private const string WaitForSecondsRealtimeCacheName = "WaitForSecondsRealtime";
private static float _waitInstructionQuantizationStepSeconds;
private static int _waitInstructionMaxDistinctEntries =
WaitInstructionDefaultMaxDistinctEntries;
private static int _waitInstructionUseLruEvictionFlag;
private static int _waitForSecondsLimitHits;
private static int _waitForSecondsRealtimeLimitHits;
private static int _waitForSecondsEvictions;
private static int _waitForSecondsRealtimeEvictions;
private static readonly object WaitInstructionCacheLock = new();
///
/// Reusable WaitForFixedUpdate instance to avoid repeated allocations in coroutines.
/// Use this when waiting for the next fixed update frame.
///
public static readonly WaitForFixedUpdate WaitForFixedUpdate = new();
///
/// Reusable WaitForEndOfFrame instance to avoid repeated allocations in coroutines.
/// Use this when waiting for the end of the current frame.
///
public static readonly WaitForEndOfFrame WaitForEndOfFrame = new();
///
/// Generic pool for StringBuilder instances. Automatically clears the StringBuilder when returned to the pool.
/// Use this to reduce allocations when building strings.
///
public static readonly WallstopGenericPool StringBuilder = new(
() => new StringBuilder(),
onRelease: builder => builder.Clear()
);
///
/// Gets a pooled StringBuilder with at least the requested capacity.
///
public static PooledResource GetStringBuilder(
int capacity,
out StringBuilder builder
)
{
PooledResource pooled = StringBuilder.Get(out builder);
if (builder.Capacity < capacity)
{
builder.Capacity = capacity;
}
return pooled;
}
///
/// Gets a cached WaitForSeconds instance for the specified duration.
/// This method caches instances to avoid repeated allocations in coroutines.
///
/// The duration to wait in seconds.
/// A WaitForSeconds instance that waits for the specified duration.
///
/// IMPORTANT: Only use with CONSTANT time values, otherwise this is a memory leak.
/// DO NOT USE with random or variable values as each unique value creates a cached entry that persists forever.
///
public static WaitForSeconds GetWaitForSeconds(float seconds)
{
WaitForSeconds pooled = RentWaitInstruction(
WaitForSeconds,
WaitForSecondsOrder,
wait => CreateWaitForSeconds(wait),
seconds,
ref _waitForSecondsLimitHits,
ref _waitForSecondsEvictions,
WaitForSecondsCacheName,
allowEviction: true
);
return pooled ?? new WaitForSeconds(seconds);
}
///
/// Attempts to retrieve a cached WaitForSeconds instance without allocating a fallback when the cache limit is reached.
/// Returns null if the duration would exceed the configured cache size.
///
public static WaitForSeconds TryGetWaitForSecondsPooled(float seconds)
{
return RentWaitInstruction(
WaitForSeconds,
WaitForSecondsOrder,
wait => CreateWaitForSeconds(wait),
seconds,
ref _waitForSecondsLimitHits,
ref _waitForSecondsEvictions,
WaitForSecondsCacheName,
allowEviction: false
);
}
///
/// Gets a cached WaitForSecondsRealtime instance for the specified duration.
/// This method caches instances to avoid repeated allocations in coroutines.
/// Unlike WaitForSeconds, this uses unscaled (real) time.
///
/// The duration to wait in real seconds (unaffected by Time.timeScale).
/// A WaitForSecondsRealtime instance that waits for the specified duration.
///
/// IMPORTANT: Only use with CONSTANT time values, otherwise this is a memory leak.
/// DO NOT USE with random or variable values as each unique value creates a cached entry that persists forever.
///
public static WaitForSecondsRealtime GetWaitForSecondsRealTime(float seconds)
{
WaitForSecondsRealtime pooled = RentWaitInstruction(
WaitForSecondsRealtime,
WaitForSecondsRealtimeOrder,
wait => CreateWaitForSecondsRealtime(wait),
seconds,
ref _waitForSecondsRealtimeLimitHits,
ref _waitForSecondsRealtimeEvictions,
WaitForSecondsRealtimeCacheName,
allowEviction: true
);
return pooled ?? new WaitForSecondsRealtime(seconds);
}
///
/// Attempts to retrieve a cached WaitForSecondsRealtime instance without allocating a fallback when the cache limit is reached.
/// Returns null if the duration would exceed the configured cache size.
///
public static WaitForSecondsRealtime TryGetWaitForSecondsRealtimePooled(float seconds)
{
return RentWaitInstruction(
WaitForSecondsRealtime,
WaitForSecondsRealtimeOrder,
wait => CreateWaitForSecondsRealtime(wait),
seconds,
ref _waitForSecondsRealtimeLimitHits,
ref _waitForSecondsRealtimeEvictions,
WaitForSecondsRealtimeCacheName,
allowEviction: false
);
}
private static WaitForSeconds CreateWaitForSeconds(float seconds)
{
return new WaitForSeconds(seconds);
}
private static WaitForSecondsRealtime CreateWaitForSecondsRealtime(float seconds)
{
return new WaitForSecondsRealtime(seconds);
}
///
/// Core LRU cache implementation for wait instructions. Provides O(1) operations for all cache operations.
///
///
///
/// Algorithm:
/// Uses a Dictionary for O(1) key lookup combined with a LinkedList for O(1) LRU ordering.
/// Each cache entry stores a reference to its LinkedListNode, enabling O(1) removal and reinsertion.
///
///
/// Operations:
///
/// - Cache hit: O(1) lookup + O(1) move to end of LRU list
/// - Cache miss with capacity: O(1) add to dictionary + O(1) add to end of LRU list
/// - Cache miss with eviction: O(1) remove oldest + O(1) add new entry
///
///
///
/// Thread Safety:
/// Protected by unless SINGLE_THREADED is defined.
///
///
private static TInstruction RentWaitInstruction(
Dictionary> cache,
LinkedList order,
Func factory,
float requestedSeconds,
ref int limitHits,
ref int evictionCount,
string cacheName,
bool allowEviction
)
where TInstruction : class
{
float quantized = QuantizeSeconds(requestedSeconds);
#if !SINGLE_THREADED
lock (WaitInstructionCacheLock)
{
#endif
if (cache.TryGetValue(quantized, out WaitInstructionCacheEntry entry))
{
if (entry._node.List != null)
{
order.Remove(entry._node);
order.AddLast(entry._node);
}
return entry._value;
}
bool useLru =
allowEviction
&& WaitInstructionUseLruEviction
&& WaitInstructionMaxDistinctEntries > 0;
if (useLru && cache.Count >= WaitInstructionMaxDistinctEntries)
{
if (order.First != null)
{
float evictKey = order.First.Value;
order.RemoveFirst();
if (cache.Remove(evictKey))
{
Interlocked.Increment(ref evictionCount);
}
}
}
else if (!CanCacheNewEntry(cache.Count))
{
ReportCacheLimit(cacheName, ref limitHits);
return null;
}
LinkedListNode node = order.AddLast(quantized);
TInstruction created = factory(quantized);
cache[quantized] = new WaitInstructionCacheEntry(created, node);
return created;
#if !SINGLE_THREADED
}
#endif
}
private static float QuantizeSeconds(float seconds)
{
float step = WaitInstructionQuantizationStepSeconds;
if (step <= 0f || float.IsNaN(step) || float.IsInfinity(step))
{
return seconds;
}
if (float.IsNaN(seconds) || float.IsInfinity(seconds))
{
return seconds;
}
float normalized = seconds / step;
float rounded = Mathf.Round(normalized);
return rounded * step;
}
private static bool CanCacheNewEntry(int currentCount)
{
int maxEntries = WaitInstructionMaxDistinctEntries;
return maxEntries <= 0 || currentCount < maxEntries;
}
private static void ReportCacheLimit(string cacheName, ref int limitHits)
{
int hits = Interlocked.Increment(ref limitHits);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
int maxEntries = WaitInstructionMaxDistinctEntries;
if (maxEntries > 0 && (hits == 1 || hits % WaitInstructionLimitWarningInterval == 0))
{
Debug.LogWarning(
$"[Buffers] {cacheName} cache reached the configured limit of {maxEntries} unique wait instructions. Consider using Buffers.TryGet... or increasing Buffers.WaitInstructionMaxDistinctEntries."
);
}
#endif
}
private static int GetWaitForSecondsEntryCount()
{
#if !SINGLE_THREADED
lock (WaitInstructionCacheLock)
{
#endif
return WaitForSeconds.Count;
#if !SINGLE_THREADED
}
#endif
}
private static int GetWaitForSecondsRealtimeEntryCount()
{
#if !SINGLE_THREADED
lock (WaitInstructionCacheLock)
{
#endif
return WaitForSecondsRealtime.Count;
#if !SINGLE_THREADED
}
#endif
}
private static WaitInstructionCacheDiagnostics BuildDiagnostics(
string cacheName,
int distinctEntries,
int limitHits,
int evictions
)
{
return new WaitInstructionCacheDiagnostics(
cacheName,
distinctEntries,
WaitInstructionMaxDistinctEntries,
limitHits,
evictions,
WaitInstructionQuantizationStepSeconds,
WaitInstructionUseLruEviction
);
}
internal static void ResetWaitInstructionCachesForTesting()
{
#if !SINGLE_THREADED
lock (WaitInstructionCacheLock)
{
#endif
WaitForSeconds.Clear();
WaitForSecondsRealtime.Clear();
WaitForSecondsOrder.Clear();
WaitForSecondsRealtimeOrder.Clear();
#if !SINGLE_THREADED
}
#endif
WaitInstructionQuantizationStepSeconds = 0f;
WaitInstructionMaxDistinctEntries = WaitInstructionDefaultMaxDistinctEntries;
WaitInstructionUseLruEviction = false;
Volatile.Write(ref _waitForSecondsLimitHits, 0);
Volatile.Write(ref _waitForSecondsRealtimeLimitHits, 0);
Volatile.Write(ref _waitForSecondsEvictions, 0);
Volatile.Write(ref _waitForSecondsRealtimeEvictions, 0);
}
internal static IDisposable BeginWaitInstructionTestScope()
{
return new WaitInstructionTestScope();
}
private sealed class WaitInstructionTestScope : IDisposable
{
private readonly WaitInstructionCacheSnapshot _waitForSecondsSnapshot;
private readonly WaitInstructionCacheSnapshot _waitForSecondsRealtimeSnapshot;
private readonly float _quantizationStepSnapshot;
private readonly int _maxDistinctEntriesSnapshot;
private readonly bool _useLruSnapshot;
private readonly int _waitForSecondsLimitHitsSnapshot;
private readonly int _waitForSecondsRealtimeLimitHitsSnapshot;
private readonly int _waitForSecondsEvictionsSnapshot;
private readonly int _waitForSecondsRealtimeEvictionsSnapshot;
private bool _disposed;
internal WaitInstructionTestScope()
{
_waitForSecondsSnapshot = SnapshotCache(WaitForSeconds, WaitForSecondsOrder);
_waitForSecondsRealtimeSnapshot = SnapshotCache(
WaitForSecondsRealtime,
WaitForSecondsRealtimeOrder
);
_quantizationStepSnapshot = WaitInstructionQuantizationStepSeconds;
_maxDistinctEntriesSnapshot = WaitInstructionMaxDistinctEntries;
_useLruSnapshot = WaitInstructionUseLruEviction;
_waitForSecondsLimitHitsSnapshot = Volatile.Read(ref _waitForSecondsLimitHits);
_waitForSecondsRealtimeLimitHitsSnapshot = Volatile.Read(
ref _waitForSecondsRealtimeLimitHits
);
_waitForSecondsEvictionsSnapshot = Volatile.Read(ref _waitForSecondsEvictions);
_waitForSecondsRealtimeEvictionsSnapshot = Volatile.Read(
ref _waitForSecondsRealtimeEvictions
);
ResetWaitInstructionCachesForTesting();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
WaitInstructionQuantizationStepSeconds = _quantizationStepSnapshot;
WaitInstructionMaxDistinctEntries = _maxDistinctEntriesSnapshot;
WaitInstructionUseLruEviction = _useLruSnapshot;
Volatile.Write(ref _waitForSecondsLimitHits, _waitForSecondsLimitHitsSnapshot);
Volatile.Write(
ref _waitForSecondsRealtimeLimitHits,
_waitForSecondsRealtimeLimitHitsSnapshot
);
Volatile.Write(ref _waitForSecondsEvictions, _waitForSecondsEvictionsSnapshot);
Volatile.Write(
ref _waitForSecondsRealtimeEvictions,
_waitForSecondsRealtimeEvictionsSnapshot
);
RestoreCache(WaitForSeconds, WaitForSecondsOrder, _waitForSecondsSnapshot);
RestoreCache(
WaitForSecondsRealtime,
WaitForSecondsRealtimeOrder,
_waitForSecondsRealtimeSnapshot
);
}
private static WaitInstructionCacheSnapshot SnapshotCache(
Dictionary> cache,
LinkedList order
)
where TInstruction : class
{
#if !SINGLE_THREADED
lock (WaitInstructionCacheLock)
{
#endif
Dictionary entries = new(cache.Count);
foreach (
KeyValuePair> pair in cache
)
{
entries[pair.Key] = pair.Value._value;
}
List ordering = new(order);
return new WaitInstructionCacheSnapshot(entries, ordering);
#if !SINGLE_THREADED
}
#endif
}
private static void RestoreCache(
Dictionary> cache,
LinkedList order,
WaitInstructionCacheSnapshot snapshot
)
where TInstruction : class
{
#if !SINGLE_THREADED
lock (WaitInstructionCacheLock)
{
#endif
cache.Clear();
order.Clear();
if (snapshot.Order == null || snapshot.Entries == null)
{
return;
}
Dictionary> nodes = new(snapshot.Order.Count);
foreach (float key in snapshot.Order)
{
LinkedListNode node = order.AddLast(key);
nodes[key] = node;
}
foreach (KeyValuePair pair in snapshot.Entries)
{
if (!nodes.TryGetValue(pair.Key, out LinkedListNode node))
{
node = order.AddLast(pair.Key);
nodes[pair.Key] = node;
}
cache[pair.Key] = new WaitInstructionCacheEntry(
pair.Value,
node
);
}
#if !SINGLE_THREADED
}
#endif
}
private readonly struct WaitInstructionCacheSnapshot
where TInstruction : class
{
internal WaitInstructionCacheSnapshot(
Dictionary entries,
List order
)
{
Entries = entries;
Order = order;
}
internal Dictionary Entries { get; }
internal List Order { get; }
}
}
}
public readonly struct WaitInstructionCacheDiagnostics
{
public WaitInstructionCacheDiagnostics(
string cacheName,
int distinctEntries,
int maxDistinctEntries,
int limitRefusals,
int evictions,
float quantizationStepSeconds,
bool lruEnabled
)
{
CacheName = cacheName;
DistinctEntries = distinctEntries;
MaxDistinctEntries = maxDistinctEntries;
LimitRefusals = limitRefusals;
Evictions = evictions;
QuantizationStepSeconds = quantizationStepSeconds;
IsLruEnabled = lruEnabled;
}
public string CacheName { get; }
public int DistinctEntries { get; }
public int MaxDistinctEntries { get; }
public int LimitRefusals { get; }
public int Evictions { get; }
public float QuantizationStepSeconds { get; }
public bool IsQuantized => QuantizationStepSeconds > 0f;
public bool IsLruEnabled { get; }
public override string ToString()
{
return $"{CacheName}: entries={DistinctEntries}, max={MaxDistinctEntries}, refusals={LimitRefusals}, evictions={Evictions}, quantizationStep={QuantizationStepSeconds}, lru={IsLruEnabled}";
}
}
///
/// Provides thread-safe generic pools for commonly used collection types.
/// All collections are automatically cleared when returned to their respective pools.
///
/// The element type for the collections.
public static class Buffers
{
///
/// Generic pool for List<T> instances. Lists are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool> List = new(
() => new List(),
onRelease: list => list.Clear()
);
///
/// Gets a pooled List with at least the requested capacity.
///
public static PooledResource> GetList(int capacity, out List list)
{
PooledResource> pooled = List.Get(out list);
if (list.Capacity < capacity)
{
list.Capacity = capacity;
}
return pooled;
}
///
/// Generic pool for HashSet<T> instances. Sets are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool> HashSet = new(
() => new HashSet(),
onRelease: set => set.Clear()
);
///
/// Generic pool for Queue<T> instances. Queues are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool> Queue = new(
() => new Queue(),
onRelease: queue => queue.Clear()
);
///
/// Generic pool for Stack<T> instances. Stacks are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool> Stack = new(
() => new Stack(),
onRelease: stack => stack.Clear()
);
}
public static class StopwatchBuffers
{
public static readonly WallstopGenericPool Stopwatch = new(
() => System.Diagnostics.Stopwatch.StartNew(),
onGet: stopwatch => stopwatch.Restart(),
onRelease: stopwatch => stopwatch.Stop()
);
}
///
/// Provides thread-safe generic pools for set collections with custom comparers.
/// Includes factory methods to create pools with custom equality and comparison logic.
///
/// The element type for the sets.
public static class SetBuffers
{
///
/// Generic pool for SortedSet<T> instances using the default comparer.
/// Sets are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool> SortedSet = new(
() => new SortedSet(),
onRelease: set => set.Clear()
);
#if SINGLE_THREADED
private static readonly Dictionary<
IComparer,
WallstopGenericPool>
> SortedSetCache = new();
private static readonly Dictionary<
IEqualityComparer,
WallstopGenericPool>
> HashSetCache = new();
#else
private static readonly ConcurrentDictionary<
IComparer,
WallstopGenericPool>
> SortedSetCache = new();
private static readonly ConcurrentDictionary<
IEqualityComparer,
WallstopGenericPool>
> HashSetCache = new();
#endif
///
/// Gets or creates a pool for SortedSet<T> instances that use the specified comparer.
/// The pool is cached and reused for subsequent calls with the same comparer instance.
///
/// The comparer to use for sorting elements in the set.
/// A pool that creates SortedSet instances with the specified comparer.
/// Thrown when comparer is null.
public static WallstopGenericPool> GetSortedSetPool(IComparer comparer)
{
return comparer == null
? throw new ArgumentNullException(nameof(comparer))
: SortedSetCache.GetOrAdd(
comparer,
inComparer => new WallstopGenericPool>(
() => new SortedSet(inComparer),
onRelease: set => set.Clear()
)
);
}
///
/// Gets or creates a pool for HashSet<T> instances that use the specified equality comparer.
/// The pool is cached and reused for subsequent calls with the same comparer instance.
///
/// The equality comparer to use for determining element equality.
/// A pool that creates HashSet instances with the specified equality comparer.
/// Thrown when comparer is null.
public static WallstopGenericPool> GetHashSetPool(IEqualityComparer comparer)
{
return comparer == null
? throw new ArgumentNullException(nameof(comparer))
: HashSetCache.GetOrAdd(
comparer,
inComparer => new WallstopGenericPool>(
() => new HashSet(inComparer),
onRelease: set => set.Clear()
)
);
}
///
/// Checks if a HashSet pool has been created for the specified equality comparer.
///
/// The equality comparer to check for.
/// True if a pool exists for this comparer; otherwise, false.
/// Thrown when comparer is null.
public static bool HasHashSetPool(IEqualityComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
return HashSetCache.ContainsKey(comparer);
}
///
/// Checks if a SortedSet pool has been created for the specified comparer.
///
/// The comparer to check for.
/// True if a pool exists for this comparer; otherwise, false.
/// Thrown when comparer is null.
public static bool HasSortedSetPool(IComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
return SortedSetCache.ContainsKey(comparer);
}
///
/// Destroys the HashSet pool associated with the specified equality comparer and disposes of all pooled instances.
///
/// The equality comparer whose pool should be destroyed.
/// True if the pool was found and destroyed; false if no pool existed for this comparer.
/// Thrown when comparer is null.
public static bool DestroyHashSetPool(IEqualityComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
if (!HashSetCache.TryRemove(comparer, out WallstopGenericPool> pool))
{
return false;
}
pool.Dispose();
return true;
}
///
/// Destroys the SortedSet pool associated with the specified comparer and disposes of all pooled instances.
///
/// The comparer whose pool should be destroyed.
/// True if the pool was found and destroyed; false if no pool existed for this comparer.
/// Thrown when comparer is null.
public static bool DestroySortedSetPool(IComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
if (!SortedSetCache.TryRemove(comparer, out WallstopGenericPool> pool))
{
return false;
}
pool.Dispose();
return true;
}
}
///
/// Provides a thread-safe generic pool for LinkedList instances.
///
/// The element type for the linked list.
public static class LinkedListBuffer
{
///
/// Generic pool for LinkedList<T> instances. Lists are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool> LinkedList = new(
() => new LinkedList(),
onRelease: linkedList => linkedList.Clear()
);
}
///
/// Provides thread-safe generic pools for dictionary types with custom comparers.
/// Includes factory methods to create pools with custom equality and comparison logic.
///
/// The key type for the dictionaries.
/// The value type for the dictionaries.
public static class DictionaryBuffer
{
///
/// Generic pool for Dictionary<TKey, TValue> instances using the default equality comparer.
/// Dictionaries are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool> Dictionary = new(
() => new Dictionary(),
onRelease: dictionary => dictionary.Clear()
);
///
/// Generic pool for SortedDictionary<TKey, TValue> instances using the default comparer.
/// Dictionaries are automatically cleared when returned to the pool.
///
public static readonly WallstopGenericPool<
SortedDictionary
> SortedDictionary = new(
() => new SortedDictionary(),
onRelease: sortedDictionary => sortedDictionary.Clear()
);
#if SINGLE_THREADED
private static readonly Dictionary<
IEqualityComparer,
WallstopGenericPool>
> DictionaryCache = new();
private static readonly Dictionary<
IComparer,
WallstopGenericPool>
> SortedDictionaryCache = new();
#else
private static readonly ConcurrentDictionary<
IEqualityComparer,
WallstopGenericPool>
> DictionaryCache = new();
private static readonly ConcurrentDictionary<
IComparer,
WallstopGenericPool>
> SortedDictionaryCache = new();
#endif
///
/// Gets or creates a pool for Dictionary<TKey, TValue> instances that use the specified equality comparer.
/// The pool is cached and reused for subsequent calls with the same comparer instance.
///
/// The equality comparer to use for key equality.
/// A pool that creates Dictionary instances with the specified equality comparer.
/// Thrown when comparer is null.
public static WallstopGenericPool> GetDictionaryPool(
IEqualityComparer comparer
)
{
return comparer == null
? throw new ArgumentNullException(nameof(comparer))
: DictionaryCache.GetOrAdd(
comparer,
inComparer => new WallstopGenericPool>(
() => new Dictionary(inComparer),
onRelease: dictionary => dictionary.Clear()
)
);
}
///
/// Gets or creates a pool for SortedDictionary<TKey, TValue> instances that use the specified comparer.
/// The pool is cached and reused for subsequent calls with the same comparer instance.
///
/// The comparer to use for sorting keys.
/// A pool that creates SortedDictionary instances with the specified comparer.
/// Thrown when comparer is null.
public static WallstopGenericPool> GetSortedDictionaryPool(
IComparer comparer
)
{
return comparer == null
? throw new ArgumentNullException(nameof(comparer))
: SortedDictionaryCache.GetOrAdd(
comparer,
inComparer => new WallstopGenericPool>(
() => new SortedDictionary(inComparer),
onRelease: dictionary => dictionary.Clear()
)
);
}
///
/// Checks if a Dictionary pool has been created for the specified equality comparer.
///
/// The equality comparer to check for.
/// True if a pool exists for this comparer; otherwise, false.
/// Thrown when comparer is null.
public static bool HasDictionaryPool(IEqualityComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
return DictionaryCache.ContainsKey(comparer);
}
///
/// Checks if a SortedDictionary pool has been created for the specified comparer.
///
/// The comparer to check for.
/// True if a pool exists for this comparer; otherwise, false.
/// Thrown when comparer is null.
public static bool HasSortedDictionaryPool(IComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
return SortedDictionaryCache.ContainsKey(comparer);
}
///
/// Destroys the Dictionary pool associated with the specified equality comparer and disposes of all pooled instances.
///
/// The equality comparer whose pool should be destroyed.
/// True if the pool was found and destroyed; false if no pool existed for this comparer.
/// Thrown when comparer is null.
public static bool DestroyDictionaryPool(IEqualityComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
if (
!DictionaryCache.TryRemove(
comparer,
out WallstopGenericPool> pool
)
)
{
return false;
}
pool.Dispose();
return true;
}
///
/// Destroys the SortedDictionary pool associated with the specified comparer and disposes of all pooled instances.
///
/// The comparer whose pool should be destroyed.
/// True if the pool was found and destroyed; false if no pool existed for this comparer.
/// Thrown when comparer is null.
public static bool DestroySortedDictionaryPool(IComparer comparer)
{
if (comparer == null)
{
throw new ArgumentNullException(nameof(comparer));
}
if (
!SortedDictionaryCache.TryRemove(
comparer,
out WallstopGenericPool> pool
)
)
{
return false;
}
pool.Dispose();
return true;
}
}
#if SINGLE_THREADED
///
/// A generic object pool that manages reusable instances of type T with configurable auto-purging.
/// This single-threaded implementation uses a List for storage to support purging operations.
///
/// The type of objects to pool.
///
///
/// The pool supports automatic purging of idle items and capacity-based eviction.
/// Configure purge behavior using or by setting properties directly.
///
///
/// When is enabled, the pool tracks usage patterns and
/// only purges items that are unlikely to be needed, avoiding GC churn from purge-allocate cycles.
///
///
/// The pool automatically registers itself with on creation
/// and unregisters on disposal, enabling cross-pool operations like .
///
///
/// options = new()
/// {
/// MaxPoolSize = 100,
/// IdleTimeoutSeconds = 60f,
/// Triggers = PurgeTrigger.OnRent | PurgeTrigger.OnReturn
/// };
/// WallstopGenericPool pool = new(() => new MyObject(), options: options);
///
/// // Create a pool with intelligent purging
/// PoolOptions smartOptions = new()
/// {
/// UseIntelligentPurging = true,
/// IdleTimeoutSeconds = 300f,
/// Triggers = PurgeTrigger.OnRent
/// };
/// WallstopGenericPool smartPool = new(() => new MyObject(), options: smartOptions);
/// ]]>
///
///
public sealed class WallstopGenericPool : IDisposable, GlobalPoolRegistry.IPoolStatistics
{
private struct PooledEntry
{
public T Value;
public float ReturnTime;
}
///
/// Gets the current number of instances in the pool.
///
internal int Count => _pool.Count;
///
public int CurrentPooledCount => _pool.Count;
///
public float LastAccessTime => _lastAccessTime;
///
/// Gets or sets the maximum number of items to retain in the pool.
/// A value of 0 or less means unbounded (no size limit).
///
public int MaxPoolSize { get; set; }
///
/// Gets or sets the minimum number of items to always retain during purge operations.
/// This is the absolute floor - pools never purge below this.
///
public int MinRetainCount { get; set; }
///
/// Gets or sets the warm retain count for active pools.
/// Active pools (accessed within ) keep this many items warm
/// to avoid cold-start allocations. Idle pools purge to .
/// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0).
///
public int WarmRetainCount { get; set; }
///
/// Gets or sets the idle timeout in seconds. Items idle longer than this are eligible for purging.
/// A value of 0 or less disables idle timeout purging.
///
public float IdleTimeoutSeconds { get; set; }
///
/// Gets or sets the interval in seconds between periodic purge checks.
/// Only used when includes .
///
public float PurgeIntervalSeconds { get; set; }
///
/// Gets or sets when automatic purge operations should be triggered.
///
public PurgeTrigger Triggers { get; set; }
///
/// Gets or sets the callback invoked when an item is purged from the pool.
///
public Action OnPurge { get; set; }
///
/// Gets or sets whether intelligent purging is enabled.
/// When enabled, the pool tracks usage patterns and avoids purging items likely to be needed.
///
public bool UseIntelligentPurging { get; set; }
///
/// Gets or sets the maximum number of items to purge per operation.
/// Limits GC pressure by spreading large purge operations across multiple calls.
/// A value of 0 or less means unlimited (purge all eligible items in one operation).
///
///
///
/// When set to a positive value, purge operations will process at most this many items
/// before returning, setting to true to continue
/// on subsequent operations.
///
///
/// Emergency purges (e.g., ) bypass this limit
/// to ensure memory is freed immediately when the system is under memory pressure.
///
///
public int MaxPurgesPerOperation
{
get => _maxPurgesPerOperation;
set => _maxPurgesPerOperation = Math.Max(0, value);
}
///
/// Gets whether there are pending purges that were deferred due to .
/// When true, subsequent Rent/Return/Periodic operations will continue purging.
///
public bool HasPendingPurges => _hasPendingPurges;
private readonly Func _producer;
private readonly Action _onGet;
private readonly Action _onRelease;
private readonly Action _onDispose;
private readonly Func _timeProvider;
private readonly Action _returnAction;
private readonly PoolUsageTracker _usageTracker;
private readonly List _pool = new();
private const float MinAutoPurgeIntervalSeconds = 1.0f;
private long _rentCount;
private long _returnCount;
private long _purgeCount;
private long _idleTimeoutPurges;
private long _capacityPurges;
private long _fullPurgeOperations;
private long _partialPurgeOperations;
private int _peakSize;
private float _lastPeriodicPurge;
private float _lastAccessTime;
private bool _hasPendingPurges;
private int _maxPurgesPerOperation;
private float _lastAutoPurgeTime;
// Single-threaded platforms do not require Volatile reads/writes for _disposed
// since there is no concurrent access from multiple threads.
private bool _disposed;
///
/// Creates a new generic pool with the specified producer function and optional callbacks.
///
/// Function that creates new instances when the pool is empty.
/// Number of instances to create and add to the pool during initialization. Default is 0.
/// Optional callback invoked when an instance is retrieved from the pool.
/// Optional callback invoked when an instance is returned to the pool.
/// Optional callback invoked when the pool is disposed for each pooled instance.
/// Optional pool configuration for auto-purging behavior.
/// Thrown when producer is null.
public WallstopGenericPool(
Func producer,
int preWarmCount = 0,
Action onGet = null,
Action onRelease = null,
Action onDisposal = null,
PoolOptions options = null
)
{
_producer = producer ?? throw new ArgumentNullException(nameof(producer));
_onGet = onGet;
_onRelease = onRelease;
_onDispose = onDisposal;
_timeProvider = options?.TimeProvider ?? DefaultTimeProvider;
_returnAction = ReturnToPool;
MaxPoolSize = options?.MaxPoolSize ?? PoolOptions.DefaultMaxPoolSize;
MinRetainCount = options?.MinRetainCount ?? PoolOptions.DefaultMinRetainCount;
IdleTimeoutSeconds =
options?.IdleTimeoutSeconds ?? PoolOptions.DefaultIdleTimeoutSeconds;
PurgeIntervalSeconds =
options?.PurgeIntervalSeconds ?? PoolOptions.DefaultPurgeIntervalSeconds;
Triggers = options?.Triggers ?? PurgeTrigger.Periodic;
OnPurge = options?.OnPurge;
PoolPurgeEffectiveOptions effectiveOptions =
PoolPurgeSettings.GetSizeAwareEffectiveOptions();
bool useIntelligent = options?.UseIntelligentPurging ?? effectiveOptions.Enabled;
UseIntelligentPurging = useIntelligent;
WarmRetainCount = options?.WarmRetainCount ?? effectiveOptions.WarmRetainCount;
MaxPurgesPerOperation =
options?.MaxPurgesPerOperation ?? effectiveOptions.MaxPurgesPerOperation;
float rollingWindow =
options?.RollingWindowSeconds ?? effectiveOptions.RollingWindowSeconds;
float hysteresis = options?.HysteresisSeconds ?? effectiveOptions.HysteresisSeconds;
float spikeThreshold =
options?.SpikeThresholdMultiplier ?? effectiveOptions.SpikeThresholdMultiplier;
float bufferMult = options?.BufferMultiplier ?? effectiveOptions.BufferMultiplier;
_usageTracker = new PoolUsageTracker(
rollingWindow,
hysteresis,
spikeThreshold,
bufferMult
);
if (useIntelligent && IdleTimeoutSeconds <= 0f)
{
IdleTimeoutSeconds = effectiveOptions.IdleTimeoutSeconds;
}
_lastPeriodicPurge = _timeProvider();
float warmTime = _timeProvider();
for (int i = 0; i < preWarmCount; ++i)
{
T value = _producer();
_onGet?.Invoke(value);
_onRelease?.Invoke(value);
_pool.Add(new PooledEntry { Value = value, ReturnTime = warmTime });
}
int warmCount = _pool.Count;
if (warmCount > _peakSize)
{
_peakSize = warmCount;
}
GlobalPoolRegistry.Register(this);
}
// Use Stopwatch for timing instead of Time.realtimeSinceStartup to avoid
// hanging during Unity's early initialization (e.g., during "Open Scene").
// Time.realtimeSinceStartup can block or behave unexpectedly when accessed
// during static initialization before Unity is fully loaded.
private static readonly System.Diagnostics.Stopwatch PoolStopwatch =
System.Diagnostics.Stopwatch.StartNew();
private static float DefaultTimeProvider()
{
return (float)PoolStopwatch.Elapsed.TotalSeconds;
}
///
/// Gets a pooled resource. When disposed, the resource is automatically returned to the pool.
/// If the pool is empty, a new instance is created using the producer function.
///
/// A PooledResource wrapping the retrieved instance.
public PooledResource Get()
{
return Get(out _);
}
///
/// Gets a pooled resource and outputs the value. When disposed, the resource is automatically returned to the pool.
/// If the pool is empty, a new instance is created using the producer function.
///
/// The retrieved instance.
/// A PooledResource wrapping the retrieved instance.
public PooledResource Get(out T value)
{
if (_disposed)
{
value = _producer();
_onGet?.Invoke(value);
return new PooledResource(value, _returnAction);
}
float currentTime = _timeProvider();
if ((Triggers & PurgeTrigger.OnRent) != 0)
{
PurgeInternal(false, currentTime);
}
if ((Triggers & PurgeTrigger.Periodic) != 0)
{
TryPeriodicPurge(currentTime);
}
_rentCount++;
_lastAccessTime = currentTime;
_usageTracker.RecordRent(currentTime);
if (_pool.Count > 0)
{
int lastIndex = _pool.Count - 1;
value = _pool[lastIndex].Value;
_pool.RemoveAt(lastIndex);
}
else
{
value = _producer();
// Update peak size when creating a new item (tracks total items in circulation)
int totalInCirculation = _pool.Count + _usageTracker.CurrentlyRented;
if (totalInCirculation > _peakSize)
{
_peakSize = totalInCirculation;
}
}
_onGet?.Invoke(value);
return new PooledResource(value, _returnAction);
}
private void ReturnToPool(T value)
{
if (_disposed)
{
InvokeOnDispose(value);
return;
}
_onRelease?.Invoke(value);
float currentTime = _timeProvider();
_pool.Add(new PooledEntry { Value = value, ReturnTime = currentTime });
_returnCount++;
_usageTracker.RecordReturn(currentTime);
int currentCount = _pool.Count;
if (currentCount > _peakSize)
{
_peakSize = currentCount;
}
if ((Triggers & PurgeTrigger.OnReturn) != 0)
{
PurgeInternal(false, currentTime);
}
if ((Triggers & PurgeTrigger.Periodic) != 0)
{
TryPeriodicPurge(currentTime);
}
}
private void TryPeriodicPurge(float currentTime)
{
if (PurgeIntervalSeconds <= 0f)
{
return;
}
if (currentTime - _lastPeriodicPurge >= PurgeIntervalSeconds)
{
_lastPeriodicPurge = currentTime;
PurgeInternal(false, currentTime);
}
}
///
/// Explicitly purges eligible items from the pool.
///
/// The number of items purged.
public int Purge()
{
return PurgeInternal(true, _timeProvider());
}
///
/// Explicitly purges eligible items from the pool with a specified reason.
///
/// The reason for purging (used in callbacks).
///
/// If true, bypasses the hysteresis check and purges immediately.
/// Used for emergency purges (e.g., low memory situations).
///
/// The number of items purged.
///
/// This method purges all eligible items without respecting limits.
/// It is treated as an explicit cleanup operation similar to .
/// Use this method when you need immediate, complete cleanup of the pool.
///
public int Purge(PurgeReason reason, bool ignoreHysteresis = false)
{
if (_disposed)
{
return 0;
}
float currentTime = _timeProvider();
// Check hysteresis unless explicitly bypassed OR this is an idle timeout purge
// (idle timeout purges are essential hygiene and should proceed during hysteresis)
bool shouldBypassHysteresis = ignoreHysteresis || reason == PurgeReason.IdleTimeout;
if (
!shouldBypassHysteresis
&& UseIntelligentPurging
&& _usageTracker.IsInHysteresisPeriod(currentTime)
)
{
return 0;
}
// For explicit purge with reason, respect only MinRetainCount (absolute floor)
// not WarmRetainCount, since this is an explicit cleanup operation
int effectiveMinRetain = MinRetainCount;
if (_pool.Count <= effectiveMinRetain)
{
return 0;
}
int purged = 0;
for (int i = _pool.Count - 1; i >= 0 && _pool.Count > effectiveMinRetain; i--)
{
PooledEntry entry = _pool[i];
_pool.RemoveAt(i);
purged++;
_purgeCount++;
InvokeOnPurge(entry.Value, reason);
InvokeOnDispose(entry.Value);
}
// Track as a full purge operation since this bypasses MaxPurgesPerOperation
if (purged > 0)
{
_fullPurgeOperations++;
}
return purged;
}
///
/// Forces a full purge that bypasses limits.
/// Use this when immediate cleanup is required regardless of gradual purge settings.
///
/// The number of items purged.
///
/// This method is useful for:
///
/// - Manual cleanup before application shutdown
/// - Responding to memory pressure warnings
/// - Testing and debugging pool behavior
///
/// Unlike emergency purges (), this still respects
/// and intelligent purging hysteresis.
///
public int ForceFullPurge()
{
return PurgeInternalCore(true, _timeProvider(), forceFullPurge: true);
}
///
/// Forces a full purge with a specified reason, bypassing limits.
///
/// The reason for purging (used in callbacks).
///
/// If true, bypasses the hysteresis check and purges immediately.
/// Used for emergency purges (e.g., low memory situations).
///
/// The number of items purged.
public int ForceFullPurge(PurgeReason reason, bool ignoreHysteresis = false)
{
if (_disposed)
{
return 0;
}
float currentTime = _timeProvider();
// Check hysteresis unless explicitly bypassed
if (
!ignoreHysteresis
&& UseIntelligentPurging
&& _usageTracker.IsInHysteresisPeriod(currentTime)
)
{
return 0;
}
// For explicit full purge with reason, respect only MinRetainCount (absolute floor)
// not WarmRetainCount, since this is an explicit cleanup operation
int effectiveMinRetain = MinRetainCount;
int purged = 0;
for (int i = _pool.Count - 1; i >= 0 && _pool.Count > effectiveMinRetain; i--)
{
PooledEntry entry = _pool[i];
_pool.RemoveAt(i);
purged++;
_purgeCount++;
InvokeOnPurge(entry.Value, reason);
InvokeOnDispose(entry.Value);
}
// Clear pending flag since we're doing a full purge
_hasPendingPurges = false;
if (purged > 0)
{
_fullPurgeOperations++;
}
return purged;
}
///
public int PurgeForBudget(int count)
{
if (_disposed || count <= 0)
{
return 0;
}
int minRetain = MinRetainCount;
int purged = 0;
for (int i = _pool.Count - 1; i >= 0 && purged < count && _pool.Count > minRetain; i--)
{
PooledEntry entry = _pool[i];
_pool.RemoveAt(i);
purged++;
_purgeCount++;
InvokeOnPurge(entry.Value, PurgeReason.BudgetExceeded);
InvokeOnDispose(entry.Value);
}
return purged;
}
private int PurgeInternal(bool isExplicit, float currentTime)
{
return PurgeInternalCore(isExplicit, currentTime, forceFullPurge: false);
}
private int PurgeInternalCore(bool isExplicit, float currentTime, bool forceFullPurge)
{
if (_disposed)
{
return 0;
}
// Fast-path: empty pool means nothing to purge
if (_pool.Count == 0)
{
_hasPendingPurges = false;
return 0;
}
// Fast-path: no purge criteria configured and not an explicit/forced purge.
// When idle timeout is disabled, max pool size is unbounded, and there are no
// pending purges from a previous gradual operation, the purge loop would iterate
// all items but purge none. Skip all expensive tracking calls and only check
// memory pressure (which self-throttles via interval).
if (!isExplicit && !forceFullPurge)
{
bool hasPurgeCriteria = IdleTimeoutSeconds > 0f || MaxPoolSize > 0;
if (!hasPurgeCriteria && !_hasPendingPurges)
{
MemoryPressureMonitor.Update();
MemoryPressureLevel fastPathPressure = MemoryPressureMonitor.CurrentPressure;
if (fastPathPressure == MemoryPressureLevel.Critical)
{
return PurgeCritical(currentTime);
}
return 0;
}
}
// Time-based throttling: automatic (non-explicit) purge operations are rate-limited
// to avoid O(n) work on every Rent/Return. Inspired by Caffeine's amortized maintenance.
// Pools that are over-capacity bypass the throttle so returns cannot accumulate
// beyond MaxPoolSize within a single tick of a coarse virtual clock — the throttle's
// purpose is to amortize scan cost for healthy pools, not to allow unbounded growth.
if (!isExplicit && !forceFullPurge && !_hasPendingPurges)
{
int configuredMaxPoolSize = MaxPoolSize;
bool appearsOverCapacity =
configuredMaxPoolSize > 0 && _pool.Count > configuredMaxPoolSize;
if (
!appearsOverCapacity
&& currentTime - _lastAutoPurgeTime < MinAutoPurgeIntervalSeconds
)
{
return 0;
}
}
MemoryPressureMonitor.Update();
MemoryPressureLevel pressureLevel = MemoryPressureMonitor.CurrentPressure;
if (pressureLevel == MemoryPressureLevel.Critical)
{
return PurgeCritical(currentTime);
}
float baseIdleTimeout = IdleTimeoutSeconds;
int maxSize = MaxPoolSize;
int minRetain = MinRetainCount;
int warmRetain = WarmRetainCount;
int maxPurgesLimit = forceFullPurge ? 0 : _maxPurgesPerOperation;
bool useIntelligent = UseIntelligentPurging;
PurgeParameters purgeParams = _usageTracker.GetPurgeParameters(
currentTime,
baseIdleTimeout,
minRetain,
warmRetain,
useIntelligent,
pressureLevel
);
float effectiveIdleTimeout = purgeParams.EffectiveIdleTimeout;
int effectiveMinRetain = purgeParams.EffectiveMinRetainCount;
int comfortableSize = purgeParams.ComfortableSize;
bool inHysteresis = purgeParams.InHysteresis;
bool hasIdleTimeout = effectiveIdleTimeout > 0f;
bool hasMaxSize = maxSize > 0;
bool hasMaxPurgesLimit = maxPurgesLimit > 0;
if (inHysteresis && !hasIdleTimeout)
{
return 0;
}
int purged = 0;
bool hitPurgeLimit = false;
bool moreEligibleItems = false;
// Idle-timeout-only path: iterate front-to-back (oldest first) with early termination.
// Items are ordered by ReturnTime (oldest at index 0), so once we hit a non-expired
// item, all remaining items are newer and also not expired.
if (hasIdleTimeout && !hasMaxSize && !isExplicit && !inHysteresis)
{
// Phase 1: count expired items and save values for callback invocation
int expiredCount = 0;
for (int i = 0; i < _pool.Count; i++)
{
if (_pool.Count - expiredCount <= effectiveMinRetain)
{
break;
}
if (hasMaxPurgesLimit && expiredCount >= maxPurgesLimit)
{
hitPurgeLimit = true;
moreEligibleItems = true;
break;
}
if ((currentTime - _pool[i].ReturnTime) >= effectiveIdleTimeout)
{
expiredCount++;
}
else
{
// All subsequent items are newer, none can be expired
break;
}
}
// Phase 2: remove from pool, then invoke callbacks
// Callbacks are invoked after removal to prevent reentrancy issues
// (a callback calling Get() on this pool would see stale entries otherwise).
if (expiredCount > 0)
{
T[] expiredValues = new T[expiredCount];
for (int i = 0; i < expiredCount; i++)
{
expiredValues[i] = _pool[i].Value;
}
_pool.RemoveRange(0, expiredCount);
purged += expiredCount;
_purgeCount += expiredCount;
_idleTimeoutPurges += expiredCount;
for (int i = 0; i < expiredValues.Length; i++)
{
InvokeOnPurge(expiredValues[i], PurgeReason.IdleTimeout);
InvokeOnDispose(expiredValues[i]);
}
}
}
else
{
// Mixed-criteria path: back-to-front iteration for capacity + explicit purges
for (
int i = _pool.Count - 1;
i >= 0
&& (isExplicit || hasIdleTimeout || _pool.Count > comfortableSize)
&& _pool.Count > effectiveMinRetain;
i--
)
{
if (hasMaxPurgesLimit && purged >= maxPurgesLimit)
{
hitPurgeLimit = true;
moreEligibleItems = true;
break;
}
PooledEntry entry = _pool[i];
PurgeReason reason = PurgeReason.Explicit;
bool shouldPurge = false;
if (hasIdleTimeout && (currentTime - entry.ReturnTime) >= effectiveIdleTimeout)
{
reason = PurgeReason.IdleTimeout;
shouldPurge = true;
_idleTimeoutPurges++;
}
else if (!inHysteresis && hasMaxSize && _pool.Count > maxSize)
{
reason = PurgeReason.CapacityExceeded;
shouldPurge = true;
_capacityPurges++;
}
else if (!inHysteresis && isExplicit)
{
shouldPurge = true;
}
if (shouldPurge)
{
// Back-to-front RemoveAt is O(1) for the last element and removes
// the entry before callbacks, making inline callback invocation
// reentrancy-safe (reentrant Get/Return only touches higher indices).
// This differs from the front-to-back idle-timeout path which uses
// batched RemoveRange and deferred callbacks.
_pool.RemoveAt(i);
purged++;
_purgeCount++;
InvokeOnPurge(entry.Value, reason);
InvokeOnDispose(entry.Value);
}
}
}
// Update pending purges flag
if (hitPurgeLimit && moreEligibleItems)
{
_hasPendingPurges = true;
_partialPurgeOperations++;
}
else
{
_hasPendingPurges = false;
if (purged > 0)
{
_fullPurgeOperations++;
}
}
// Advance the auto-purge throttle clock after a completed scan, regardless of
// whether this scan removed items. Advancing on no-op scans preserves the
// fast-path skip for healthy pools in tight Rent/Return loops; correctness for
// over-capacity pools is maintained by the upstream over-capacity bypass, which
// lets returns at the same tick re-enter the scan as needed.
if (currentTime > _lastAutoPurgeTime)
{
_lastAutoPurgeTime = currentTime;
}
return purged;
}
private int PurgeCritical(float currentTime)
{
int minRetain = MinRetainCount;
int purged = 0;
for (int i = _pool.Count - 1; i >= 0 && _pool.Count > minRetain; i--)
{
PooledEntry entry = _pool[i];
_pool.RemoveAt(i);
purged++;
_purgeCount++;
InvokeOnPurge(entry.Value, PurgeReason.MemoryPressure);
InvokeOnDispose(entry.Value);
}
_hasPendingPurges = false;
if (purged > 0)
{
_fullPurgeOperations++;
}
return purged;
}
///
/// Gets the current pool statistics.
///
/// A snapshot of pool statistics.
public PoolStatistics GetStatistics()
{
float currentTime = _timeProvider();
PoolFrequencyStatistics freqStats = _usageTracker.GetFrequencyStatistics(currentTime);
return new PoolStatistics(
currentSize: _pool.Count,
peakSize: _peakSize,
rentCount: _rentCount,
returnCount: _returnCount,
purgeCount: _purgeCount,
idleTimeoutPurges: _idleTimeoutPurges,
capacityPurges: _capacityPurges,
fullPurgeOperations: _fullPurgeOperations,
partialPurgeOperations: _partialPurgeOperations,
rentalsPerMinute: freqStats.RentalsPerMinute,
averageInterRentalTimeSeconds: freqStats.AverageInterRentalTimeSeconds,
lastAccessTime: freqStats.LastAccessTime,
isHighFrequency: freqStats.IsHighFrequency,
isLowFrequency: freqStats.IsLowFrequency,
isUnused: freqStats.IsUnused
);
}
private void InvokeOnPurge(T value, PurgeReason reason)
{
if (OnPurge == null)
{
return;
}
try
{
OnPurge(value, reason);
}
catch
{
// Swallow exceptions from callbacks
}
}
private void InvokeOnDispose(T value)
{
if (_onDispose == null)
{
return;
}
try
{
_onDispose(value);
}
catch
{
// Swallow exceptions from callbacks
}
}
///
/// Disposes the pool. If an onDisposal callback was provided, it is invoked for each pooled instance.
/// Otherwise, the pool is simply cleared.
///
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
GlobalPoolRegistry.Unregister(this);
if (_onDispose == null)
{
_pool.Clear();
return;
}
for (int i = 0; i < _pool.Count; i++)
{
InvokeOnDispose(_pool[i].Value);
}
_pool.Clear();
}
}
#else
///
/// A thread-safe generic object pool that manages reusable instances of type T with configurable auto-purging.
/// This multi-threaded implementation uses locks for thread-safe storage and purging operations.
///
/// The type of objects to pool.
///
///
/// The pool supports automatic purging of idle items and capacity-based eviction.
/// Configure purge behavior using or by setting properties directly.
///
///
/// When is enabled, the pool tracks usage patterns and
/// only purges items that are unlikely to be needed, avoiding GC churn from purge-allocate cycles.
///
///
/// The pool automatically registers itself with on creation
/// and unregisters on disposal, enabling cross-pool operations like .
///
///
/// options = new()
/// {
/// MaxPoolSize = 100,
/// IdleTimeoutSeconds = 60f,
/// Triggers = PurgeTrigger.OnRent | PurgeTrigger.OnReturn
/// };
/// WallstopGenericPool pool = new(() => new MyObject(), options: options);
///
/// // Create a pool with intelligent purging
/// PoolOptions smartOptions = new()
/// {
/// UseIntelligentPurging = true,
/// IdleTimeoutSeconds = 300f,
/// Triggers = PurgeTrigger.OnRent
/// };
/// WallstopGenericPool smartPool = new(() => new MyObject(), options: smartOptions);
/// ]]>
///
///
public sealed class WallstopGenericPool : IDisposable, GlobalPoolRegistry.IPoolStatistics
{
private struct PooledEntry
{
public T Value;
public float ReturnTime;
}
///
/// Gets the current number of instances in the pool.
///
internal int Count
{
get
{
lock (_lock)
{
return _pool.Count;
}
}
}
///
public int CurrentPooledCount
{
get
{
lock (_lock)
{
return _pool.Count;
}
}
}
///
public float LastAccessTime => Volatile.Read(ref _lastAccessTime);
///
/// Gets or sets the maximum number of items to retain in the pool.
/// A value of 0 or less means unbounded (no size limit).
///
public int MaxPoolSize
{
get => Volatile.Read(ref _maxPoolSize);
set => Volatile.Write(ref _maxPoolSize, value);
}
///
/// Gets or sets the minimum number of items to always retain during purge operations.
/// This is the absolute floor - pools never purge below this.
///
public int MinRetainCount
{
get => Volatile.Read(ref _minRetainCount);
set => Volatile.Write(ref _minRetainCount, value);
}
///
/// Gets or sets the warm retain count for active pools.
/// Active pools (accessed within ) keep this many items warm
/// to avoid cold-start allocations. Idle pools purge to .
/// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0).
///
public int WarmRetainCount
{
get => Volatile.Read(ref _warmRetainCount);
set => Volatile.Write(ref _warmRetainCount, value);
}
///
/// Gets or sets the idle timeout in seconds. Items idle longer than this are eligible for purging.
/// A value of 0 or less disables idle timeout purging.
///
public float IdleTimeoutSeconds
{
get => Volatile.Read(ref _idleTimeoutSeconds);
set => Volatile.Write(ref _idleTimeoutSeconds, value);
}
///
/// Gets or sets the interval in seconds between periodic purge checks.
/// Only used when includes .
///
public float PurgeIntervalSeconds
{
get => Volatile.Read(ref _purgeIntervalSeconds);
set => Volatile.Write(ref _purgeIntervalSeconds, value);
}
///
/// Gets or sets when automatic purge operations should be triggered.
///
public PurgeTrigger Triggers
{
get => (PurgeTrigger)Volatile.Read(ref _triggers);
set => Volatile.Write(ref _triggers, (int)value);
}
///
/// Gets or sets the callback invoked when an item is purged from the pool.
/// This property is thread-safe.
///
public Action OnPurge
{
get => Volatile.Read(ref _onPurge);
set => Volatile.Write(ref _onPurge, value);
}
///
/// Gets or sets whether intelligent purging is enabled.
/// When enabled, the pool tracks usage patterns and avoids purging items likely to be needed.
///
public bool UseIntelligentPurging
{
get => Volatile.Read(ref _useIntelligentPurging) != 0;
set => Volatile.Write(ref _useIntelligentPurging, value ? 1 : 0);
}
///
/// Gets or sets the maximum number of items to purge per operation.
/// Limits GC pressure by spreading large purge operations across multiple calls.
/// A value of 0 or less means unlimited (purge all eligible items in one operation).
///
///
///
/// When set to a positive value, purge operations will process at most this many items
/// before returning, setting to true to continue
/// on subsequent operations.
///
///
/// Emergency purges (e.g., ) bypass this limit
/// to ensure memory is freed immediately when the system is under memory pressure.
///
///
public int MaxPurgesPerOperation
{
get => Volatile.Read(ref _maxPurgesPerOperation);
set => Volatile.Write(ref _maxPurgesPerOperation, Math.Max(0, value));
}
///
/// Gets whether there are pending purges that were deferred due to .
/// When true, subsequent Rent/Return/Periodic operations will continue purging.
///
public bool HasPendingPurges => Volatile.Read(ref _hasPendingPurges) != 0;
private readonly Func _producer;
private readonly Action _onGet;
private readonly Action _onRelease;
private readonly Action _onDispose;
private readonly Func _timeProvider;
private readonly Action _returnAction;
private readonly object _lock = new();
private readonly PoolUsageTracker _usageTracker;
private Action _onPurge;
private readonly List _pool = new();
private const float MinAutoPurgeIntervalSeconds = 1.0f;
private int _maxPoolSize;
private int _minRetainCount;
private int _warmRetainCount;
private float _idleTimeoutSeconds;
private float _purgeIntervalSeconds;
private int _triggers;
private int _useIntelligentPurging;
private long _rentCount;
private long _returnCount;
private long _purgeCount;
private long _idleTimeoutPurges;
private long _capacityPurges;
private long _fullPurgeOperations;
private long _partialPurgeOperations;
private int _peakSize;
private float _lastPeriodicPurge;
private float _lastAccessTime;
private int _disposed;
private int _hasPendingPurges;
private int _maxPurgesPerOperation;
private float _lastAutoPurgeTime;
///
/// Creates a new thread-safe generic pool with the specified producer function and optional callbacks.
///
/// Function that creates new instances when the pool is empty.
/// Number of instances to create and add to the pool during initialization. Default is 0.
/// Optional callback invoked when an instance is retrieved from the pool.
/// Optional callback invoked when an instance is returned to the pool.
/// Optional callback invoked when the pool is disposed for each pooled instance.
/// Optional pool configuration for auto-purging behavior.
/// Thrown when producer is null.
public WallstopGenericPool(
Func producer,
int preWarmCount = 0,
Action onGet = null,
Action onRelease = null,
Action onDisposal = null,
PoolOptions options = null
)
{
_producer = producer ?? throw new ArgumentNullException(nameof(producer));
_onGet = onGet;
_onRelease = onRelease;
_onDispose = onDisposal;
_timeProvider = options?.TimeProvider ?? DefaultTimeProvider;
_returnAction = ReturnToPool;
_maxPoolSize = options?.MaxPoolSize ?? PoolOptions.DefaultMaxPoolSize;
_minRetainCount = options?.MinRetainCount ?? PoolOptions.DefaultMinRetainCount;
_idleTimeoutSeconds =
options?.IdleTimeoutSeconds ?? PoolOptions.DefaultIdleTimeoutSeconds;
_purgeIntervalSeconds =
options?.PurgeIntervalSeconds ?? PoolOptions.DefaultPurgeIntervalSeconds;
_triggers = (int)(options?.Triggers ?? PurgeTrigger.Periodic);
OnPurge = options?.OnPurge;
PoolPurgeEffectiveOptions effectiveOptions =
PoolPurgeSettings.GetSizeAwareEffectiveOptions();
bool useIntelligent = options?.UseIntelligentPurging ?? effectiveOptions.Enabled;
_useIntelligentPurging = useIntelligent ? 1 : 0;
_warmRetainCount = options?.WarmRetainCount ?? effectiveOptions.WarmRetainCount;
_maxPurgesPerOperation = Math.Max(
0,
options?.MaxPurgesPerOperation ?? effectiveOptions.MaxPurgesPerOperation
);
float rollingWindow =
options?.RollingWindowSeconds ?? effectiveOptions.RollingWindowSeconds;
float hysteresis = options?.HysteresisSeconds ?? effectiveOptions.HysteresisSeconds;
float spikeThreshold =
options?.SpikeThresholdMultiplier ?? effectiveOptions.SpikeThresholdMultiplier;
float bufferMult = options?.BufferMultiplier ?? effectiveOptions.BufferMultiplier;
_usageTracker = new PoolUsageTracker(
rollingWindow,
hysteresis,
spikeThreshold,
bufferMult
);
if (useIntelligent && _idleTimeoutSeconds <= 0f)
{
_idleTimeoutSeconds = effectiveOptions.IdleTimeoutSeconds;
}
_lastPeriodicPurge = _timeProvider();
float warmTime = _timeProvider();
for (int i = 0; i < preWarmCount; ++i)
{
T value = _producer();
_onGet?.Invoke(value);
_onRelease?.Invoke(value);
_pool.Add(new PooledEntry { Value = value, ReturnTime = warmTime });
}
int warmCount = _pool.Count;
if (warmCount > _peakSize)
{
_peakSize = warmCount;
}
GlobalPoolRegistry.Register(this);
}
// Use Stopwatch for timing instead of Time.realtimeSinceStartup to avoid
// hanging during Unity's early initialization (e.g., during "Open Scene").
// Time.realtimeSinceStartup can block or behave unexpectedly when accessed
// during static initialization before Unity is fully loaded.
private static readonly System.Diagnostics.Stopwatch PoolStopwatch =
System.Diagnostics.Stopwatch.StartNew();
private static float DefaultTimeProvider()
{
return (float)PoolStopwatch.Elapsed.TotalSeconds;
}
///
/// Gets a pooled resource. When disposed, the resource is automatically returned to the pool.
/// If the pool is empty, a new instance is created using the producer function.
/// This method is thread-safe.
///
/// A PooledResource wrapping the retrieved instance.
public PooledResource Get()
{
return Get(out _);
}
///
/// Gets a pooled resource and outputs the value. When disposed, the resource is automatically returned to the pool.
/// If the pool is empty, a new instance is created using the producer function.
/// This method is thread-safe.
///
/// The retrieved instance.
/// A PooledResource wrapping the retrieved instance.
public PooledResource Get(out T value)
{
if (Volatile.Read(ref _disposed) != 0)
{
value = _producer();
_onGet?.Invoke(value);
return new PooledResource(value, _returnAction);
}
float currentTime = _timeProvider();
PurgeTrigger currentTriggers = Triggers;
if ((currentTriggers & PurgeTrigger.OnRent) != 0)
{
PurgeInternal(false, currentTime);
}
if ((currentTriggers & PurgeTrigger.Periodic) != 0)
{
TryPeriodicPurge(currentTime);
}
Interlocked.Increment(ref _rentCount);
Volatile.Write(ref _lastAccessTime, currentTime);
_usageTracker.RecordRent(currentTime);
lock (_lock)
{
if (_pool.Count > 0)
{
int lastIndex = _pool.Count - 1;
value = _pool[lastIndex].Value;
_pool.RemoveAt(lastIndex);
_onGet?.Invoke(value);
return new PooledResource(value, _returnAction);
}
}
value = _producer();
// Update peak size when creating a new item (tracks total items in circulation)
int totalInCirculation = _pool.Count + _usageTracker.CurrentlyRented;
int peak = _peakSize;
while (totalInCirculation > peak)
{
int original = Interlocked.CompareExchange(ref _peakSize, totalInCirculation, peak);
if (original == peak)
{
break;
}
peak = original;
}
_onGet?.Invoke(value);
return new PooledResource(value, _returnAction);
}
private void ReturnToPool(T value)
{
if (Volatile.Read(ref _disposed) != 0)
{
InvokeOnDispose(value);
return;
}
_onRelease?.Invoke(value);
float currentTime = _timeProvider();
_usageTracker.RecordReturn(currentTime);
lock (_lock)
{
_pool.Add(new PooledEntry { Value = value, ReturnTime = currentTime });
Interlocked.Increment(ref _returnCount);
int currentCount = _pool.Count;
int peak = _peakSize;
while (currentCount > peak)
{
int original = Interlocked.CompareExchange(ref _peakSize, currentCount, peak);
if (original == peak)
{
break;
}
peak = original;
}
}
PurgeTrigger currentTriggers = Triggers;
if ((currentTriggers & PurgeTrigger.OnReturn) != 0)
{
PurgeInternal(false, currentTime);
}
if ((currentTriggers & PurgeTrigger.Periodic) != 0)
{
TryPeriodicPurge(currentTime);
}
}
private void TryPeriodicPurge(float currentTime)
{
float interval = PurgeIntervalSeconds;
if (interval <= 0f)
{
return;
}
float lastPurge = Volatile.Read(ref _lastPeriodicPurge);
if (currentTime - lastPurge >= interval)
{
float original = Interlocked.CompareExchange(
ref _lastPeriodicPurge,
currentTime,
lastPurge
);
if (original == lastPurge)
{
PurgeInternal(false, currentTime);
}
}
}
///
/// Explicitly purges eligible items from the pool.
/// This method is thread-safe.
///
/// The number of items purged.
public int Purge()
{
return PurgeInternal(true, _timeProvider());
}
///
/// Explicitly purges eligible items from the pool with a specified reason.
/// This method is thread-safe.
///
/// The reason for purging (used in callbacks).
///
/// If true, bypasses the hysteresis check and purges immediately.
/// Used for emergency purges (e.g., low memory situations).
///
/// The number of items purged.
///
/// This method purges all eligible items without respecting limits.
/// It is treated as an explicit cleanup operation similar to .
/// Use this method when you need immediate, complete cleanup of the pool.
///
public int Purge(PurgeReason reason, bool ignoreHysteresis = false)
{
if (Volatile.Read(ref _disposed) != 0)
{
return 0;
}
float currentTime = _timeProvider();
// Check hysteresis unless explicitly bypassed OR this is an idle timeout purge
// (idle timeout purges are essential hygiene and should proceed during hysteresis)
bool shouldBypassHysteresis = ignoreHysteresis || reason == PurgeReason.IdleTimeout;
if (
!shouldBypassHysteresis
&& UseIntelligentPurging
&& _usageTracker.IsInHysteresisPeriod(currentTime)
)
{
return 0;
}
// For explicit purge with reason, respect only MinRetainCount (absolute floor)
// not WarmRetainCount, since this is an explicit cleanup operation
int effectiveMinRetain = MinRetainCount;
// Fast-path: nothing to purge if pool is at or below the minimum retain floor.
// Reading _pool.Count outside the lock is an intentional benign race:
// List.Count reads a single int field (atomic on all .NET platforms).
// A stale non-zero value merely proceeds to the lock where correctness is guaranteed;
// a stale zero value defers purging to the next cycle (no correctness impact).
if (_pool.Count <= effectiveMinRetain)
{
return 0;
}
// CRITICAL: Do NOT use pooled lists here - that would cause infinite recursion!
// When Get() is called with PurgeTrigger.OnRent, it calls PurgeInternal(),
// which would call Get() again on Buffers.List, causing a stack overflow.
List toPurge = new();
lock (_lock)
{
for (int i = _pool.Count - 1; i >= 0 && _pool.Count > effectiveMinRetain; i--)
{
toPurge.Add(_pool[i]);
_pool.RemoveAt(i);
}
}
int purged = toPurge.Count;
if (purged > 0)
{
Interlocked.Add(ref _purgeCount, purged);
// Track as a full purge operation since this bypasses MaxPurgesPerOperation
Interlocked.Increment(ref _fullPurgeOperations);
}
for (int i = 0; i < toPurge.Count; i++)
{
InvokeOnPurge(toPurge[i].Value, reason);
InvokeOnDispose(toPurge[i].Value);
}
return purged;
}
///
/// Forces a full purge that bypasses limits.
/// Use this when immediate cleanup is required regardless of gradual purge settings.
///
/// The number of items purged.
///
/// This method is useful for:
///
/// - Manual cleanup before application shutdown
/// - Responding to memory pressure warnings
/// - Testing and debugging pool behavior
///
/// Unlike emergency purges (), this still respects
/// and intelligent purging hysteresis.
///
public int ForceFullPurge()
{
return PurgeInternalCore(true, _timeProvider(), forceFullPurge: true);
}
///
/// Forces a full purge with a specified reason, bypassing limits.
///
/// The reason for purging (used in callbacks).
///
/// If true, bypasses the hysteresis check and purges immediately.
/// Used for emergency purges (e.g., low memory situations).
///
/// The number of items purged.
public int ForceFullPurge(PurgeReason reason, bool ignoreHysteresis = false)
{
if (Volatile.Read(ref _disposed) != 0)
{
return 0;
}
float currentTime = _timeProvider();
// Check hysteresis unless explicitly bypassed
if (
!ignoreHysteresis
&& UseIntelligentPurging
&& _usageTracker.IsInHysteresisPeriod(currentTime)
)
{
return 0;
}
// For explicit full purge with reason, respect only MinRetainCount (absolute floor)
// not WarmRetainCount, since this is an explicit cleanup operation
int effectiveMinRetain = MinRetainCount;
// CRITICAL: Do NOT use pooled lists here - that would cause infinite recursion!
List toPurge = new();
lock (_lock)
{
for (int i = _pool.Count - 1; i >= 0 && _pool.Count > effectiveMinRetain; i--)
{
toPurge.Add(_pool[i]);
_pool.RemoveAt(i);
}
}
// Clear pending flag since we're doing a full purge
Volatile.Write(ref _hasPendingPurges, 0);
int purged = toPurge.Count;
if (purged > 0)
{
Interlocked.Add(ref _purgeCount, purged);
Interlocked.Increment(ref _fullPurgeOperations);
}
for (int i = 0; i < toPurge.Count; i++)
{
InvokeOnPurge(toPurge[i].Value, reason);
InvokeOnDispose(toPurge[i].Value);
}
return purged;
}
///
public int PurgeForBudget(int count)
{
if (Volatile.Read(ref _disposed) != 0 || count <= 0)
{
return 0;
}
int minRetain = MinRetainCount;
// CRITICAL: Do NOT use pooled lists here - that would cause infinite recursion!
List toPurge = new();
lock (_lock)
{
for (
int i = _pool.Count - 1;
i >= 0 && toPurge.Count < count && _pool.Count > minRetain;
i--
)
{
toPurge.Add(_pool[i]);
_pool.RemoveAt(i);
}
}
int purged = toPurge.Count;
if (purged > 0)
{
Interlocked.Add(ref _purgeCount, purged);
}
for (int i = 0; i < toPurge.Count; i++)
{
InvokeOnPurge(toPurge[i].Value, PurgeReason.BudgetExceeded);
InvokeOnDispose(toPurge[i].Value);
}
return purged;
}
private int PurgeInternal(bool isExplicit, float currentTime)
{
return PurgeInternalCore(isExplicit, currentTime, forceFullPurge: false);
}
private int PurgeInternalCore(bool isExplicit, float currentTime, bool forceFullPurge)
{
if (Volatile.Read(ref _disposed) != 0)
{
return 0;
}
// Fast-path: empty pool means nothing to purge.
// Reading _pool.Count outside the lock is an intentional benign race:
// List.Count reads a single int field (atomic on all .NET platforms).
// A stale non-zero value merely proceeds to the lock where correctness is guaranteed;
// a stale zero value defers purging to the next cycle (no correctness impact).
if (_pool.Count == 0)
{
Volatile.Write(ref _hasPendingPurges, 0);
return 0;
}
// Fast-path: no purge criteria configured and not an explicit/forced purge.
if (!isExplicit && !forceFullPurge)
{
bool hasPurgeCriteria = IdleTimeoutSeconds > 0f || MaxPoolSize > 0;
if (!hasPurgeCriteria && Volatile.Read(ref _hasPendingPurges) == 0)
{
MemoryPressureMonitor.Update();
MemoryPressureLevel fastPathPressure = MemoryPressureMonitor.CurrentPressure;
if (fastPathPressure == MemoryPressureLevel.Critical)
{
return PurgeCritical(currentTime);
}
return 0;
}
}
// Time-based throttling: automatic (non-explicit) purge operations are rate-limited
// to avoid O(n) work on every Rent/Return. Inspired by Caffeine's amortized maintenance.
// Pools that appear over-capacity bypass the throttle so returns cannot accumulate
// beyond MaxPoolSize within a single tick of a coarse virtual clock — the throttle's
// purpose is to amortize scan cost for healthy pools, not to allow unbounded growth.
// Reading _pool.Count outside the lock is a benign race (identical rationale to the
// empty-pool fast-path above): a stale value merely means we might take the lock
// unnecessarily, or defer one call by a tick; correctness is re-verified inside the lock.
if (!isExplicit && !forceFullPurge && Volatile.Read(ref _hasPendingPurges) == 0)
{
int configuredMaxPoolSize = MaxPoolSize;
int sizeHint = _pool.Count;
bool appearsOverCapacity =
configuredMaxPoolSize > 0 && sizeHint > configuredMaxPoolSize;
if (!appearsOverCapacity)
{
float lastPurge = Volatile.Read(ref _lastAutoPurgeTime);
if (currentTime - lastPurge < MinAutoPurgeIntervalSeconds)
{
return 0;
}
}
}
MemoryPressureMonitor.Update();
MemoryPressureLevel pressureLevel = MemoryPressureMonitor.CurrentPressure;
if (pressureLevel == MemoryPressureLevel.Critical)
{
return PurgeCritical(currentTime);
}
float baseIdleTimeout = IdleTimeoutSeconds;
int maxSize = MaxPoolSize;
int minRetain = MinRetainCount;
int warmRetain = WarmRetainCount;
int maxPurgesLimit = forceFullPurge ? 0 : MaxPurgesPerOperation;
bool useIntelligent = UseIntelligentPurging;
PurgeParameters purgeParams = _usageTracker.GetPurgeParameters(
currentTime,
baseIdleTimeout,
minRetain,
warmRetain,
useIntelligent,
pressureLevel
);
float effectiveIdleTimeout = purgeParams.EffectiveIdleTimeout;
int effectiveMinRetain = purgeParams.EffectiveMinRetainCount;
int comfortableSize = purgeParams.ComfortableSize;
bool inHysteresis = purgeParams.InHysteresis;
bool hasIdleTimeout = effectiveIdleTimeout > 0f;
bool hasMaxSize = maxSize > 0;
bool hasMaxPurgesLimit = maxPurgesLimit > 0;
if (inHysteresis && !hasIdleTimeout)
{
return 0;
}
// CRITICAL: Do NOT use pooled lists here - that would cause infinite recursion!
// When Get() is called with PurgeTrigger.OnRent, it calls PurgeInternal(),
// which would call Get() again on Buffers.List, causing a stack overflow.
List entriesToPurge = null;
List purgeReasons = null;
int purgeCount = 0;
bool hitPurgeLimit = false;
bool moreEligibleItems = false;
lock (_lock)
{
// Idle-timeout-only path: iterate front-to-back (oldest first) with early termination.
if (hasIdleTimeout && !hasMaxSize && !isExplicit && !inHysteresis)
{
int expiredCount = 0;
for (int i = 0; i < _pool.Count; i++)
{
if (_pool.Count - expiredCount <= effectiveMinRetain)
{
break;
}
if (hasMaxPurgesLimit && expiredCount >= maxPurgesLimit)
{
hitPurgeLimit = true;
moreEligibleItems = true;
break;
}
PooledEntry entry = _pool[i];
if ((currentTime - entry.ReturnTime) >= effectiveIdleTimeout)
{
entriesToPurge ??= new List();
purgeReasons ??= new List();
entriesToPurge.Add(entry);
purgeReasons.Add(PurgeReason.IdleTimeout);
expiredCount++;
Interlocked.Increment(ref _idleTimeoutPurges);
}
else
{
break;
}
}
if (expiredCount > 0)
{
_pool.RemoveRange(0, expiredCount);
purgeCount += expiredCount;
}
}
else
{
// Mixed-criteria path: back-to-front iteration
for (
int i = _pool.Count - 1;
i >= 0
&& (isExplicit || hasIdleTimeout || _pool.Count > comfortableSize)
&& _pool.Count > effectiveMinRetain;
i--
)
{
if (hasMaxPurgesLimit && purgeCount >= maxPurgesLimit)
{
hitPurgeLimit = true;
moreEligibleItems = true;
break;
}
PooledEntry entry = _pool[i];
PurgeReason reason = PurgeReason.Explicit;
bool shouldPurge = false;
if (
hasIdleTimeout
&& (currentTime - entry.ReturnTime) >= effectiveIdleTimeout
)
{
reason = PurgeReason.IdleTimeout;
shouldPurge = true;
Interlocked.Increment(ref _idleTimeoutPurges);
}
else if (!inHysteresis && hasMaxSize && _pool.Count > maxSize)
{
reason = PurgeReason.CapacityExceeded;
shouldPurge = true;
Interlocked.Increment(ref _capacityPurges);
}
else if (!inHysteresis && isExplicit)
{
shouldPurge = true;
}
if (shouldPurge)
{
entriesToPurge ??= new List();
purgeReasons ??= new List();
entriesToPurge.Add(entry);
purgeReasons.Add(reason);
_pool.RemoveAt(i);
purgeCount++;
}
}
}
}
// Update pending purges flag
if (hitPurgeLimit && moreEligibleItems)
{
Volatile.Write(ref _hasPendingPurges, 1);
Interlocked.Increment(ref _partialPurgeOperations);
}
else
{
Volatile.Write(ref _hasPendingPurges, 0);
if (purgeCount > 0)
{
Interlocked.Increment(ref _fullPurgeOperations);
}
}
Interlocked.Add(ref _purgeCount, purgeCount);
if (entriesToPurge != null)
{
for (int i = 0; i < entriesToPurge.Count; i++)
{
InvokeOnPurge(entriesToPurge[i].Value, purgeReasons[i]);
InvokeOnDispose(entriesToPurge[i].Value);
}
}
// Advance the auto-purge throttle clock after a completed scan, regardless of
// whether this scan removed items. Advancing on no-op scans is what preserves the
// fast-path skip for healthy pools under high contention; correctness for
// over-capacity pools is maintained by the upstream over-capacity bypass, which
// lets returns at the same tick re-enter the scan as needed.
//
// Use CAS max-semantics to prevent clock regression: two concurrent callers may
// read different currentTime values (the time provider is queried outside the
// lock), serialize through the lock in arbitrary order, and reach this write with
// the earlier timestamp landing last. Without max-semantics, that write would
// regress the throttle clock and re-permit a hot-loop scan.
while (true)
{
float current = Volatile.Read(ref _lastAutoPurgeTime);
if (currentTime <= current)
{
break;
}
float observed = Interlocked.CompareExchange(
ref _lastAutoPurgeTime,
currentTime,
current
);
if (observed == current)
{
break;
}
}
return purgeCount;
}
private int PurgeCritical(float currentTime)
{
int minRetain = MinRetainCount;
List toPurge = new();
lock (_lock)
{
for (int i = _pool.Count - 1; i >= 0 && _pool.Count > minRetain; i--)
{
toPurge.Add(_pool[i]);
_pool.RemoveAt(i);
}
}
Volatile.Write(ref _hasPendingPurges, 0);
int purged = toPurge.Count;
if (purged > 0)
{
Interlocked.Add(ref _purgeCount, purged);
Interlocked.Increment(ref _fullPurgeOperations);
}
for (int i = 0; i < toPurge.Count; i++)
{
InvokeOnPurge(toPurge[i].Value, PurgeReason.MemoryPressure);
InvokeOnDispose(toPurge[i].Value);
}
return purged;
}
///
/// Gets the current pool statistics.
/// This method is thread-safe.
///
/// A snapshot of pool statistics.
public PoolStatistics GetStatistics()
{
int currentSize;
lock (_lock)
{
currentSize = _pool.Count;
}
float currentTime = _timeProvider();
PoolFrequencyStatistics freqStats = _usageTracker.GetFrequencyStatistics(currentTime);
return new PoolStatistics(
currentSize: currentSize,
peakSize: Volatile.Read(ref _peakSize),
rentCount: Interlocked.Read(ref _rentCount),
returnCount: Interlocked.Read(ref _returnCount),
purgeCount: Interlocked.Read(ref _purgeCount),
idleTimeoutPurges: Interlocked.Read(ref _idleTimeoutPurges),
capacityPurges: Interlocked.Read(ref _capacityPurges),
fullPurgeOperations: Interlocked.Read(ref _fullPurgeOperations),
partialPurgeOperations: Interlocked.Read(ref _partialPurgeOperations),
rentalsPerMinute: freqStats.RentalsPerMinute,
averageInterRentalTimeSeconds: freqStats.AverageInterRentalTimeSeconds,
lastAccessTime: freqStats.LastAccessTime,
isHighFrequency: freqStats.IsHighFrequency,
isLowFrequency: freqStats.IsLowFrequency,
isUnused: freqStats.IsUnused
);
}
private void InvokeOnPurge(T value, PurgeReason reason)
{
if (OnPurge == null)
{
return;
}
try
{
OnPurge(value, reason);
}
catch
{
// Swallow exceptions from callbacks
}
}
private void InvokeOnDispose(T value)
{
if (_onDispose == null)
{
return;
}
try
{
_onDispose(value);
}
catch
{
// Swallow exceptions from callbacks
}
}
///
/// Disposes the pool. If an onDisposal callback was provided, it is invoked for each pooled instance.
/// Otherwise, the pool is simply cleared.
///
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
GlobalPoolRegistry.Unregister(this);
List toDispose;
lock (_lock)
{
toDispose = new List(_pool);
_pool.Clear();
}
if (_onDispose == null)
{
return;
}
for (int i = 0; i < toDispose.Count; i++)
{
InvokeOnDispose(toDispose[i].Value);
}
}
}
#endif
///
/// A wrapper around that provides a Wallstop-style
/// auto-disposal pattern using .
///
/// The element type for the arrays.
///
///
/// Key Difference from :
/// This pool uses .NET's which returns arrays
/// that may be larger than the requested size (typically rounded up to a power of 2).
/// Callers MUST use the property (which returns the
/// originally requested length) instead of accessing the underlying array's Length directly.
///
///
/// When to use this pool:
/// Use when array sizes vary widely or unpredictably (e.g., user input,
/// collection sizes, dynamically computed sizes). The shared pool handles size bucketing efficiently,
/// reducing memory fragmentation for variable-size workloads.
///
///
/// When to use instead:
/// Use when array sizes are fixed or highly predictable (e.g., internal
/// PRNG state buffers, algorithm-constant sizes). This avoids the overhead of size bucketing when you
/// always request the same size.
///
///
///
///
/// // Correct usage - use lease.Length, not lease.Array.Length
/// using PooledArray<int> lease = SystemArrayPool<int>.Get(count, out int[] buffer);
/// for (int i = 0; i < lease.Length; i++) // ✓ Use lease.Length
/// {
/// buffer[i] = ProcessItem(i);
/// }
///
/// // WRONG - buffer.Length may be larger than requested
/// for (int i = 0; i < buffer.Length; i++) // ✗ May iterate past valid data
/// {
/// ...
/// }
///
///
public static class SystemArrayPool
{
///
/// Gets a pooled array of at least the specified size. When disposed, the array is returned to the pool.
///
/// The minimum size of the array to retrieve. Must be non-negative.
/// A wrapping an array of at least the specified size.
/// Thrown when minimumLength is negative.
///
/// The returned array may be larger than . Always use
/// to determine the valid portion of the array.
///
public static PooledArray Get(int minimumLength)
{
return Get(minimumLength, out _);
}
///
/// Gets a pooled array of at least the specified size and outputs the array. When disposed, the array is returned to the pool.
///
/// The minimum size of the array to retrieve. Must be non-negative.
/// The retrieved array. May be larger than .
/// A wrapping the array with proper length tracking.
/// Thrown when minimumLength is negative.
///
/// The returned array may be larger than . Always use
/// (or the you passed in)
/// to determine the valid portion of the array.
///
public static PooledArray Get(int minimumLength, out T[] array)
{
if (minimumLength < 0)
{
throw new ArgumentOutOfRangeException(
nameof(minimumLength),
minimumLength,
"Must be non-negative."
);
}
if (minimumLength == 0)
{
array = Array.Empty();
return new PooledArray(array, 0);
}
array = System.Buffers.ArrayPool.Shared.Rent(minimumLength);
return new PooledArray(array, minimumLength);
}
///
/// Gets a pooled array of at least the specified size with optional clearing. When disposed, the array is returned to the pool.
///
/// The minimum size of the array to retrieve. Must be non-negative.
/// If true, the array is cleared to default values before being returned.
/// The retrieved array. May be larger than .
/// A wrapping the array with proper length tracking.
/// Thrown when minimumLength is negative.
///
///
/// When is true, only the portion of the array up to
/// is guaranteed to be cleared. The remainder of the
/// array (if any) may contain stale data.
///
///
/// For security-sensitive scenarios or when using reference types, consider always setting
/// to true to prevent data leakage between uses.
///
///
public static PooledArray Get(int minimumLength, bool clearArray, out T[] array)
{
if (minimumLength < 0)
{
throw new ArgumentOutOfRangeException(
nameof(minimumLength),
minimumLength,
"Must be non-negative."
);
}
if (minimumLength == 0)
{
array = Array.Empty();
return new PooledArray(array, 0);
}
array = System.Buffers.ArrayPool.Shared.Rent(minimumLength);
if (clearArray)
{
Array.Clear(array, 0, minimumLength);
}
return new PooledArray(array, minimumLength);
}
}
///
/// A struct that wraps a pooled array and automatically returns it to the pool when disposed.
/// This struct provides a unified return type for all array pools (,
/// , and ).
///
/// The element type for the array.
///
///
/// Important: The underlying may be larger than the
/// requested size (especially when using ). Always use
/// to determine the valid portion of the array.
///
///
/// This struct implements to enable automatic resource return via
/// 'using' statements. The array is returned to the pool when is called.
///
///
/// Warning: Do NOT use foreach on pooled arrays since
/// .Length may exceed . Use indexed iteration instead.
///
///
///
///
/// // Using with SystemArrayPool (array may be larger than requested)
/// using PooledArray<int> pooled = SystemArrayPool<int>.Get(100, out int[] array);
/// for (int i = 0; i < pooled.Length; i++) // Use pooled.Length, not array.Length
/// {
/// array[i] = i * 2;
/// }
///
/// // Using with WallstopArrayPool (array is exact size)
/// using PooledArray<int> pooled2 = WallstopArrayPool<int>.Get(100, out int[] buffer);
/// for (int i = 0; i < pooled2.Length; i++)
/// {
/// buffer[i] = i * 3;
/// }
///
///
public struct PooledArray : IDisposable
{
///
/// The underlying pooled array. May be larger than when using
/// (due to power-of-2 bucketing), or exactly equal
/// to when using or
/// .
///
public readonly T[] array;
///
/// The originally requested length. Use this instead of .Length
/// to determine the valid portion of the array.
///
public readonly int length;
private readonly Action _onDispose;
private bool _disposed;
///
/// Creates a new wrapping the specified array with the given logical length.
/// Uses the default disposal action that returns the array to .
///
/// The pooled array.
/// The logical length (originally requested size).
internal PooledArray(T[] array, int length)
: this(array, length, null) { }
///
/// Creates a new wrapping the specified array with the given logical length
/// and custom disposal action.
///
/// The pooled array.
/// The logical length (originally requested size).
/// The action to invoke when disposing. If null, uses the default
/// return logic.
internal PooledArray(T[] array, int length, Action onDispose)
{
this.array = array;
this.length = length;
_onDispose = onDispose;
_disposed = false;
}
///
/// Returns the array to the pool. The clearing behavior depends on which pool the array came from:
///
/// - : Array is NOT cleared by default
/// - : Array IS cleared on return
/// - : Array is NOT cleared (for performance)
///
///
///
/// After disposal, the array should not be used as it may be reused by another caller.
/// For reference types when using , consider using
/// with clearArray=true
/// to prevent data leakage.
///
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (array == null || array.Length == 0)
{
return;
}
if (_onDispose != null)
{
_onDispose(array);
}
else
{
System.Buffers.ArrayPool.Shared.Return(array, clearArray: true);
}
}
}
#if SINGLE_THREADED
///
/// A static array pool that provides pooled arrays of specific sizes.
/// Arrays are cleared (set to default values) when returned to the pool.
/// This single-threaded implementation uses Dictionary and List for storage.
///
/// The element type for the arrays.
///
///
/// Unlike , this pool returns arrays of the exact requested size,
/// making it ideal for fixed-size or predictable-size scenarios where memory efficiency is important.
///
///
/// Arrays are automatically cleared when returned to the pool to prevent data leakage.
///
///
/// ⚠️ MEMORY LEAK WARNING: This pool creates a separate pool bucket for EVERY unique
/// size requested. If you pass variable sizes (user input, collection.Count, dynamic values), each unique
/// size creates a new bucket that persists forever, causing unbounded memory growth.
///
///
/// SAFE uses:
///
/// - Compile-time constants: Get(16), Get(64), Get(256)
/// - Algorithm-bounded sizes with small fixed upper limits
/// - PRNG internal state buffers (fixed sizes like 16, 32, 64 bytes)
/// - Sizes from a small, known set of values
///
///
///
/// UNSAFE uses (will leak memory):
///
/// - Get(userInput) — Every unique user value creates a permanent bucket
/// - Get(collection.Count) — Every unique collection size leaks memory
/// - Get(random.Next(1, 1000)) — Creates up to 1000 permanent buckets
/// - Get(dynamicCalculation) — Unbounded sizes = unbounded memory
///
///
///
/// Rule of thumb: If you cannot enumerate ALL possible sizes at compile time,
/// use instead.
///
///
public static class WallstopArrayPool
{
private static readonly Dictionary> Pool = new();
private static readonly Action OnDispose = Release;
///
/// Gets a pooled array of the specified size. When disposed, the array is cleared and returned to the pool.
///
/// The size of the array to retrieve. Must be non-negative.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
public static PooledArray Get(int size)
{
return Get(size, out _);
}
///
/// Gets a pooled array of the specified size and outputs the value. When disposed, the array is cleared and returned to the pool.
///
/// The size of the array to retrieve. Must be non-negative.
/// The retrieved array. Will be exactly elements.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
public static PooledArray Get(int size, out T[] array)
{
switch (size)
{
case < 0:
{
throw new ArgumentOutOfRangeException(
nameof(size),
size,
"Must be non-negative."
);
}
case 0:
{
array = Array.Empty();
return new PooledArray(array, 0, null);
}
}
List pool = Pool.GetOrAdd(size);
if (pool.Count == 0)
{
array = new T[size];
return new PooledArray(array, size, OnDispose);
}
int lastIndex = pool.Count - 1;
array = pool[lastIndex];
pool.RemoveAt(lastIndex);
return new PooledArray(array, size, OnDispose);
}
private static void Release(T[] resource)
{
int length = resource.Length;
Array.Clear(resource, 0, length);
List pool = Pool.GetOrAdd(length);
pool.Add(resource);
}
}
#else
///
/// A thread-safe static array pool that provides pooled arrays of specific sizes.
/// Arrays are cleared (set to default values) when returned to the pool.
/// This multi-threaded implementation uses ConcurrentDictionary and ConcurrentStack for thread-safe storage.
///
/// The element type for the arrays.
///
///
/// Unlike , this pool returns arrays of the exact requested size,
/// making it ideal for fixed-size or predictable-size scenarios where memory efficiency is important.
///
///
/// Arrays are automatically cleared when returned to the pool to prevent data leakage.
///
///
/// ⚠️ MEMORY LEAK WARNING: This pool creates a separate pool bucket for EVERY unique
/// size requested. If you pass variable sizes (user input, collection.Count, dynamic values), each unique
/// size creates a new bucket that persists forever, causing unbounded memory growth.
///
///
/// SAFE uses:
///
/// - Compile-time constants: Get(16), Get(64), Get(256)
/// - Algorithm-bounded sizes with small fixed upper limits
/// - PRNG internal state buffers (fixed sizes like 16, 32, 64 bytes)
/// - Sizes from a small, known set of values
///
///
///
/// UNSAFE uses (will leak memory):
///
/// - Get(userInput) — Every unique user value creates a permanent bucket
/// - Get(collection.Count) — Every unique collection size leaks memory
/// - Get(random.Next(1, 1000)) — Creates up to 1000 permanent buckets
/// - Get(dynamicCalculation) — Unbounded sizes = unbounded memory
///
///
///
/// Rule of thumb: If you cannot enumerate ALL possible sizes at compile time,
/// use instead.
///
///
public static class WallstopArrayPool
{
private static readonly ConcurrentDictionary> _pool = new();
private static readonly Action _onRelease = Release;
///
/// Gets a pooled array of the specified size. When disposed, the array is cleared and returned to the pool.
/// This method is thread-safe.
///
/// The size of the array to retrieve. Must be non-negative.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
public static PooledArray Get(int size)
{
return Get(size, out _);
}
///
/// Gets a pooled array of the specified size and outputs the value. When disposed, the array is cleared and returned to the pool.
/// This method is thread-safe.
///
/// The size of the array to retrieve. Must be non-negative.
/// The retrieved array. Will be exactly elements.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
public static PooledArray Get(int size, out T[] array)
{
switch (size)
{
case < 0:
{
throw new ArgumentOutOfRangeException(
nameof(size),
size,
"Must be non-negative."
);
}
case 0:
{
array = Array.Empty();
return new PooledArray(array, 0, null);
}
}
ConcurrentStack result = _pool.GetOrAdd(size, _ => new ConcurrentStack());
if (!result.TryPop(out array))
{
array = new T[size];
}
return new PooledArray(array, size, _onRelease);
}
private static void Release(T[] resource)
{
int length = resource.Length;
Array.Clear(resource, 0, length);
ConcurrentStack result = _pool.GetOrAdd(length, _ => new ConcurrentStack());
result.Push(resource);
}
}
#endif
#if SINGLE_THREADED
///
/// A fast static array pool optimized for index-based lookup with minimal overhead.
/// Unlike WallstopArrayPool, arrays are NOT cleared when returned to the pool, providing better performance.
/// This single-threaded implementation uses a List of Stacks indexed by array size for O(1) lookups.
///
/// The element type for the arrays. Must be an unmanaged type.
///
///
/// Warning: This pool does NOT clear arrays on release. Arrays may contain
/// data from previous uses. Only use this pool when you will overwrite all array contents
/// before reading, or when stale data is acceptable.
///
///
/// Unlike , this pool returns arrays of the exact requested size.
///
///
/// ⚠️ MEMORY LEAK WARNING: This pool creates a separate pool bucket for EVERY unique
/// size requested. If you pass variable sizes (user input, collection.Count, dynamic values), each unique
/// size creates a new bucket that persists forever, causing unbounded memory growth.
///
///
/// SAFE uses:
///
/// - Compile-time constants: Get(16), Get(64), Get(256)
/// - Algorithm-bounded sizes with small fixed upper limits
/// - PRNG internal state buffers (fixed sizes like 16, 32, 64 bytes)
/// - Sizes from a small, known set of values
///
///
///
/// UNSAFE uses (will leak memory):
///
/// - Get(userInput) — Every unique user value creates a permanent bucket
/// - Get(collection.Count) — Every unique collection size leaks memory
/// - Get(random.Next(1, 1000)) — Creates up to 1000 permanent buckets
/// - Get(dynamicCalculation) — Unbounded sizes = unbounded memory
///
///
///
/// Rule of thumb: If you cannot enumerate ALL possible sizes at compile time,
/// use instead.
///
///
public static class WallstopFastArrayPool
where T : unmanaged
{
private static readonly List> Pool = new();
private static readonly Action OnRelease = Release;
///
/// Gets a pooled array of the specified size. When disposed, the array is returned to the pool WITHOUT being cleared.
///
/// The size of the array to retrieve. Must be non-negative.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
/// Arrays are NOT cleared on return. The caller is responsible for clearing if needed.
public static PooledArray Get(int size)
{
return Get(size, out _);
}
///
/// Gets a pooled array of the specified size and outputs the value. When disposed, the array is returned to the pool WITHOUT being cleared.
///
/// The size of the array to retrieve. Must be non-negative.
/// The retrieved array. Will be exactly elements.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
/// Arrays are NOT cleared on return. The caller is responsible for clearing if needed.
public static PooledArray Get(int size, out T[] array)
{
switch (size)
{
case < 0:
{
throw new ArgumentOutOfRangeException(
nameof(size),
size,
"Must be non-negative."
);
}
case 0:
{
array = Array.Empty();
return new PooledArray(array, 0, null);
}
}
while (Pool.Count <= size)
{
Pool.Add(null);
}
Stack pool = Pool[size];
if (pool == null)
{
pool = new Stack();
Pool[size] = pool;
}
if (!pool.TryPop(out array))
{
array = new T[size];
}
return new PooledArray(array, size, OnRelease);
}
private static void Release(T[] resource)
{
Pool[resource.Length].Push(resource);
}
///
/// Clears all pooled arrays for testing purposes. Internal visibility for test assemblies.
///
internal static void ClearForTesting()
{
for (int i = 0; i < Pool.Count; i++)
{
Pool[i]?.Clear();
}
}
}
#else
///
/// A thread-safe fast static array pool optimized for index-based lookup with minimal overhead.
/// Unlike WallstopArrayPool, arrays are NOT cleared when returned to the pool, providing better performance.
/// This multi-threaded implementation uses a List of ConcurrentStacks with ReaderWriterLockSlim for thread-safe index access.
///
/// The element type for the arrays. Must be an unmanaged type.
///
///
/// Warning: This pool does NOT clear arrays on release. Arrays may contain
/// data from previous uses. Only use this pool when you will overwrite all array contents
/// before reading, or when stale data is acceptable.
///
///
/// Unlike , this pool returns arrays of the exact requested size.
///
///
/// ⚠️ MEMORY LEAK WARNING: This pool creates a separate pool bucket for EVERY unique
/// size requested. If you pass variable sizes (user input, collection.Count, dynamic values), each unique
/// size creates a new bucket that persists forever, causing unbounded memory growth.
///
///
/// SAFE uses:
///
/// - Compile-time constants: Get(16), Get(64), Get(256)
/// - Algorithm-bounded sizes with small fixed upper limits
/// - PRNG internal state buffers (fixed sizes like 16, 32, 64 bytes)
/// - Sizes from a small, known set of values
///
///
///
/// UNSAFE uses (will leak memory):
///
/// - Get(userInput) — Every unique user value creates a permanent bucket
/// - Get(collection.Count) — Every unique collection size leaks memory
/// - Get(random.Next(1, 1000)) — Creates up to 1000 permanent buckets
/// - Get(dynamicCalculation) — Unbounded sizes = unbounded memory
///
///
///
/// Rule of thumb: If you cannot enumerate ALL possible sizes at compile time,
/// use instead.
///
///
public static class WallstopFastArrayPool
where T : unmanaged
{
private static readonly ReaderWriterLockSlim _lock = new();
private static readonly List> _pool = new();
private static readonly Action _onRelease = Release;
///
/// Gets a pooled array of the specified size. When disposed, the array is returned to the pool WITHOUT being cleared.
/// This method is thread-safe.
///
/// The size of the array to retrieve. Must be non-negative.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
/// Arrays are NOT cleared on return. The caller is responsible for clearing if needed.
public static PooledArray Get(int size)
{
return Get(size, out _);
}
///
/// Gets a pooled array of the specified size and outputs the value. When disposed, the array is returned to the pool WITHOUT being cleared.
/// This method is thread-safe.
///
/// The size of the array to retrieve. Must be non-negative.
/// The retrieved array. Will be exactly elements.
/// A wrapping an array of the exact specified size.
/// Thrown when size is negative.
/// Arrays are NOT cleared on return. The caller is responsible for clearing if needed.
public static PooledArray Get(int size, out T[] array)
{
switch (size)
{
case < 0:
{
throw new ArgumentOutOfRangeException(
nameof(size),
size,
"Must be non-negative."
);
}
case 0:
{
array = Array.Empty();
return new PooledArray(array, 0, null);
}
}
bool withinRange;
ConcurrentStack pool = null;
_lock.EnterReadLock();
try
{
withinRange = size < _pool.Count;
if (withinRange)
{
pool = _pool[size];
}
}
finally
{
_lock.ExitReadLock();
}
if (withinRange)
{
if (pool == null)
{
_lock.EnterUpgradeableReadLock();
try
{
pool = _pool[size];
if (pool == null)
{
_lock.EnterWriteLock();
try
{
pool = _pool[size];
if (pool == null)
{
pool = new ConcurrentStack();
_pool[size] = pool;
}
}
finally
{
_lock.ExitWriteLock();
}
}
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
}
else
{
_lock.EnterUpgradeableReadLock();
try
{
if (size < _pool.Count)
{
pool = _pool[size];
if (pool == null)
{
_lock.EnterWriteLock();
try
{
pool = _pool[size];
if (pool == null)
{
pool = new ConcurrentStack();
_pool[size] = pool;
}
}
finally
{
_lock.ExitWriteLock();
}
}
}
else
{
_lock.EnterWriteLock();
try
{
while (_pool.Count <= size)
{
_pool.Add(null);
}
pool = _pool[size];
if (pool == null)
{
pool = new ConcurrentStack();
_pool[size] = pool;
}
}
finally
{
_lock.ExitWriteLock();
}
}
}
finally
{
_lock.ExitUpgradeableReadLock();
}
}
if (!pool.TryPop(out array))
{
array = new T[size];
}
return new PooledArray(array, size, _onRelease);
}
private static void Release(T[] resource)
{
_pool[resource.Length].Push(resource);
}
///
/// Clears all pooled arrays for testing purposes. Internal visibility for test assemblies.
/// Thread-safe implementation.
///
internal static void ClearForTesting()
{
_lock.EnterWriteLock();
try
{
for (int i = 0; i < _pool.Count; i++)
{
_pool[i]?.Clear();
}
}
finally
{
_lock.ExitWriteLock();
}
}
}
#endif
///
/// A readonly struct that wraps a pooled resource and automatically returns it to the pool when disposed.
/// This type is designed to be used with 'using' statements to ensure resources are properly returned.
///
/// The type of the pooled resource.
///
/// This struct implements IDisposable to enable automatic resource return via 'using' statements.
/// The resource is returned to its pool when Dispose is called, typically at the end of a 'using' block.
///
public struct PooledResource : IDisposable
{
///
/// The pooled resource instance. Access this to use the resource.
///
public readonly T resource;
private readonly Action _onDispose;
private bool _initialized;
///
/// Creates a new PooledResource wrapping the specified resource with a disposal action.
///
/// The resource to wrap.
/// The action to invoke when disposing, typically returning the resource to a pool.
public PooledResource(T resource, Action onDispose)
{
_initialized = true;
this.resource = resource;
_onDispose = onDispose;
}
///
/// Disposes the resource by invoking the disposal action, typically returning it to the pool.
/// This method is automatically called at the end of a 'using' block.
///
public void Dispose()
{
if (!_initialized)
{
return;
}
_initialized = false;
_onDispose(resource);
}
}
}