From 9be6ce557bf57fda401b53c554f702e31bbce191 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:47:12 +0000 Subject: [PATCH 1/4] Implement allocation-free scoped ref counting Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/dc652e79-402a-48ab-b01d-7744991e75c4 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedTests.cs | 23 ++++++++ BitFaster.Caching/Lifetime.cs | 33 +++++++++-- BitFaster.Caching/Scoped.cs | 68 ++++++++++++++-------- 3 files changed, 95 insertions(+), 29 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedTests.cs b/BitFaster.Caching.UnitTests/ScopedTests.cs index 34a09338..19d4e756 100644 --- a/BitFaster.Caching.UnitTests/ScopedTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedTests.cs @@ -86,5 +86,28 @@ public void WhenScopedIsCreatedFromCacheItemHasExpectedLifetime() valueFactory.Disposable.IsDisposed.Should().BeTrue(); } + +#if NETCOREAPP3_1_OR_GREATER + [Fact] + public void WhenLifetimeIsCreatedInternalReferenceCountingDoesNotAllocateOnHeap() + { + var scope = new Scoped(new Disposable()); + + using (scope.CreateLifetime()) + { + } + + long before = GC.GetAllocatedBytesForCurrentThread(); + + for (int i = 0; i < 256; i++) + { + using var lifetime = scope.CreateLifetime(); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + allocated.Should().BeLessThan(256 * 80L); + } +#endif } } diff --git a/BitFaster.Caching/Lifetime.cs b/BitFaster.Caching/Lifetime.cs index 378d3eea..8dbd5f96 100644 --- a/BitFaster.Caching/Lifetime.cs +++ b/BitFaster.Caching/Lifetime.cs @@ -4,6 +4,11 @@ namespace BitFaster.Caching { + internal interface ILifetimeReleaser + { + void ReleaseLifetime(); + } + /// /// Represents the lifetime of a value. The value is alive and valid for use until the /// lifetime is disposed. @@ -11,8 +16,11 @@ namespace BitFaster.Caching /// The type of value public sealed class Lifetime : IDisposable { - private readonly Action onDisposeAction; - private readonly ReferenceCount refCount; + private readonly Action? onDisposeAction; + private readonly ReferenceCount? refCount; + private readonly ILifetimeReleaser? releaser; + private readonly T value = default!; + private readonly int referenceCount; private bool isDisposed; /// @@ -26,15 +34,22 @@ public Lifetime(ReferenceCount value, Action onDisposeAction) this.onDisposeAction = onDisposeAction; } + internal Lifetime(T value, int referenceCount, ILifetimeReleaser releaser) + { + this.value = value; + this.referenceCount = referenceCount; + this.releaser = releaser; + } + /// /// Gets the value. /// - public T Value => this.refCount.Value; + public T Value => this.refCount is null ? this.value : this.refCount.Value; /// /// Gets the count of Lifetime instances referencing the same value. /// - public int ReferenceCount => this.refCount.Count; + public int ReferenceCount => this.refCount is null ? this.referenceCount : this.refCount.Count; /// /// Terminates the lifetime and performs any cleanup required to release the value. @@ -43,7 +58,15 @@ public void Dispose() { if (!this.isDisposed) { - this.onDisposeAction(); + if (this.onDisposeAction is null) + { + this.releaser!.ReleaseLifetime(); + } + else + { + this.onDisposeAction(); + } + this.isDisposed = true; } } diff --git a/BitFaster.Caching/Scoped.cs b/BitFaster.Caching/Scoped.cs index 384dc775..131b49b0 100644 --- a/BitFaster.Caching/Scoped.cs +++ b/BitFaster.Caching/Scoped.cs @@ -13,10 +13,13 @@ namespace BitFaster.Caching /// The type of scoped value. [DebuggerTypeProxy(typeof(Scoped<>.ScopedDebugView))] [DebuggerDisplay("{FormatDebug(),nq}")] - public sealed class Scoped : IScoped, IDisposable where T : IDisposable + public sealed class Scoped : IScoped, IDisposable, ILifetimeReleaser where T : IDisposable { - private ReferenceCount refCount; - private int disposed = 0; + private const int DisposedFlag = unchecked((int)0x80000000); + private const int ReferenceCountMask = int.MaxValue; + + private readonly T value; + private int state = 1; /// /// Initializes a new Scoped value. @@ -24,13 +27,13 @@ public sealed class Scoped : IScoped, IDisposable where T : IDisposable /// The value to scope. public Scoped(T value) { - this.refCount = new ReferenceCount(value); + this.value = value; } /// /// Gets a value indicating whether the scope is disposed. /// - public bool IsDisposed => Volatile.Read(ref this.disposed) == 1; + public bool IsDisposed => Volatile.Read(ref this.state) < 0; /// /// Attempts to create a lifetime for the scoped value. The lifetime guarantees the value is alive until @@ -42,19 +45,17 @@ public bool TryCreateLifetime([MaybeNullWhen(false)] out Lifetime lifetime) { while (true) { - var oldRefCount = this.refCount; + int oldState = Volatile.Read(ref this.state); - // If old ref count is 0, the scoped object has been disposed and there was a race. - if (IsDisposed || oldRefCount.Count == 0) + if (oldState < 0) { lifetime = default; return false; } - if (oldRefCount == Interlocked.CompareExchange(ref this.refCount, oldRefCount.IncrementCopy(), oldRefCount)) + if (Interlocked.CompareExchange(ref this.state, oldState + 1, oldState) == oldState) { - // When Lifetime is disposed, it calls DecrementReferenceCount - lifetime = new Lifetime(oldRefCount, this.DecrementReferenceCount); + lifetime = new Lifetime(this.value, oldState & ReferenceCountMask, this); return true; } } @@ -74,22 +75,23 @@ public Lifetime CreateLifetime() return lifetime; } - private void DecrementReferenceCount() + void ILifetimeReleaser.ReleaseLifetime() { while (true) { - var oldRefCount = this.refCount; + int oldState = Volatile.Read(ref this.state); + int oldReferenceCount = oldState & ReferenceCountMask; + int newReferenceCount = oldReferenceCount - 1; + int newState = (oldState & DisposedFlag) | newReferenceCount; - if (oldRefCount == Interlocked.CompareExchange(ref this.refCount, oldRefCount.DecrementCopy(), oldRefCount)) + if (Interlocked.CompareExchange(ref this.state, newState, oldState) == oldState) { - // Note this.refCount may be stale. - // Old count == 1, thus new ref count is 0, dispose the value. - if (oldRefCount.Count == 1) + if (newReferenceCount == 0 && (oldState & DisposedFlag) != 0) { - oldRefCount.Value?.Dispose(); + this.value.Dispose(); } - break; + return; } } } @@ -100,10 +102,28 @@ private void DecrementReferenceCount() /// public void Dispose() { - // Dispose exactly once, decrement via dispose exactly once - if (Interlocked.CompareExchange(ref this.disposed, 1, 0) == 0) + while (true) { - DecrementReferenceCount(); + int oldState = Volatile.Read(ref this.state); + + if ((oldState & DisposedFlag) != 0) + { + return; + } + + int oldReferenceCount = oldState & ReferenceCountMask; + int newReferenceCount = oldReferenceCount - 1; + int newState = DisposedFlag | newReferenceCount; + + if (Interlocked.CompareExchange(ref this.state, newState, oldState) == oldState) + { + if (newReferenceCount == 0) + { + this.value.Dispose(); + } + + return; + } } } @@ -115,7 +135,7 @@ internal string FormatDebug() return "[Disposed Scope]"; } - return this.refCount.Value?.ToString() ?? "[null]"; + return this.value?.ToString() ?? "[null]"; } [ExcludeFromCodeCoverage] @@ -133,7 +153,7 @@ public ScopedDebugView(Scoped scoped) public bool IsDisposed => this.scoped.IsDisposed; - public T Value => this.scoped.refCount.Value; + public T Value => this.scoped.value; } } } From e8c25d3c22b52c07a55590bf5927ccc0221acac5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:53:19 +0000 Subject: [PATCH 2/4] Handle disposed default scoped values Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/dc652e79-402a-48ab-b01d-7744991e75c4 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching/Scoped.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BitFaster.Caching/Scoped.cs b/BitFaster.Caching/Scoped.cs index 131b49b0..fd886592 100644 --- a/BitFaster.Caching/Scoped.cs +++ b/BitFaster.Caching/Scoped.cs @@ -88,7 +88,7 @@ void ILifetimeReleaser.ReleaseLifetime() { if (newReferenceCount == 0 && (oldState & DisposedFlag) != 0) { - this.value.Dispose(); + this.value?.Dispose(); } return; @@ -119,7 +119,7 @@ public void Dispose() { if (newReferenceCount == 0) { - this.value.Dispose(); + this.value?.Dispose(); } return; From f2bdfe8d7eff0a8365f902030fbdd6328cc636b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:59:01 +0000 Subject: [PATCH 3/4] Clarify scoped allocation threshold Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/dc652e79-402a-48ab-b01d-7744991e75c4 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/BitFaster.Caching.UnitTests/ScopedTests.cs b/BitFaster.Caching.UnitTests/ScopedTests.cs index 19d4e756..85f714e0 100644 --- a/BitFaster.Caching.UnitTests/ScopedTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedTests.cs @@ -8,6 +8,10 @@ namespace BitFaster.Caching.UnitTests { public class ScopedTests { +#if NETCOREAPP3_1_OR_GREATER + private const long MaxExpectedBytesPerLifetime = 80L; +#endif + [Fact] public void WhenScopeIsCreatedThenScopeDisposedValueIsDisposed() { @@ -106,7 +110,7 @@ public void WhenLifetimeIsCreatedInternalReferenceCountingDoesNotAllocateOnHeap( long allocated = GC.GetAllocatedBytesForCurrentThread() - before; - allocated.Should().BeLessThan(256 * 80L); + allocated.Should().BeLessThan(256 * MaxExpectedBytesPerLifetime); } #endif } From 8ac6ddd2113bfe76cd8e24f099c3652c9910a7a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:04:15 +0000 Subject: [PATCH 4/4] Rename scoped allocation regression test Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/dc652e79-402a-48ab-b01d-7744991e75c4 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BitFaster.Caching.UnitTests/ScopedTests.cs b/BitFaster.Caching.UnitTests/ScopedTests.cs index 85f714e0..49de2f47 100644 --- a/BitFaster.Caching.UnitTests/ScopedTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedTests.cs @@ -93,7 +93,7 @@ public void WhenScopedIsCreatedFromCacheItemHasExpectedLifetime() #if NETCOREAPP3_1_OR_GREATER [Fact] - public void WhenLifetimeIsCreatedInternalReferenceCountingDoesNotAllocateOnHeap() + public void CreateLifetime_WhenCalledRepeatedly_DoesNotAllocateForReferenceCounting() { var scope = new Scoped(new Disposable());