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/7] 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/7] 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/7] 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/7] 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()); From f4c713868adff85c6a23a8ad2c4cb8d38b3a3776 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:00:38 +0000 Subject: [PATCH 5/7] Simplify lifetime releaser state Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/cf0e178d-3066-4213-9b58-36f0ec1cea78 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedTests.cs | 10 ++++++++++ BitFaster.Caching/Lifetime.cs | 19 +++++++++---------- BitFaster.Caching/Scoped.cs | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedTests.cs b/BitFaster.Caching.UnitTests/ScopedTests.cs index 49de2f47..8e642b5d 100644 --- a/BitFaster.Caching.UnitTests/ScopedTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedTests.cs @@ -53,6 +53,16 @@ public void WhenScopeIsCreatedThenLifetimeDisposedScopeDisposesValue() disposable.IsDisposed.Should().BeTrue(); } + [Fact] + public void CreateLifetime_WhenCalled_ReferenceCountIncludesCreatedLifetime() + { + var scope = new Scoped(new Disposable()); + + using var lifetime = scope.CreateLifetime(); + + lifetime.ReferenceCount.Should().Be(2); + } + [Fact] public void WhenScopeIsDisposedCreateScopeThrows() { diff --git a/BitFaster.Caching/Lifetime.cs b/BitFaster.Caching/Lifetime.cs index 8dbd5f96..4b462049 100644 --- a/BitFaster.Caching/Lifetime.cs +++ b/BitFaster.Caching/Lifetime.cs @@ -16,9 +16,7 @@ internal interface ILifetimeReleaser /// The type of value public sealed class Lifetime : IDisposable { - private readonly Action? onDisposeAction; - private readonly ReferenceCount? refCount; - private readonly ILifetimeReleaser? releaser; + private readonly object releaser = default!; private readonly T value = default!; private readonly int referenceCount; private bool isDisposed; @@ -30,8 +28,9 @@ public sealed class Lifetime : IDisposable /// The action to perform when the lifetime is terminated. public Lifetime(ReferenceCount value, Action onDisposeAction) { - this.refCount = value; - this.onDisposeAction = onDisposeAction; + this.value = value.Value; + this.referenceCount = value.Count; + this.releaser = onDisposeAction; } internal Lifetime(T value, int referenceCount, ILifetimeReleaser releaser) @@ -44,12 +43,12 @@ internal Lifetime(T value, int referenceCount, ILifetimeReleaser releaser) /// /// Gets the value. /// - public T Value => this.refCount is null ? this.value : this.refCount.Value; + public T Value => this.value; /// /// Gets the count of Lifetime instances referencing the same value. /// - public int ReferenceCount => this.refCount is null ? this.referenceCount : this.refCount.Count; + public int ReferenceCount => this.referenceCount; /// /// Terminates the lifetime and performs any cleanup required to release the value. @@ -58,13 +57,13 @@ public void Dispose() { if (!this.isDisposed) { - if (this.onDisposeAction is null) + if (this.releaser is ILifetimeReleaser lifetimeReleaser) { - this.releaser!.ReleaseLifetime(); + lifetimeReleaser.ReleaseLifetime(); } else { - this.onDisposeAction(); + ((Action)this.releaser)(); } this.isDisposed = true; diff --git a/BitFaster.Caching/Scoped.cs b/BitFaster.Caching/Scoped.cs index fd886592..f77e3bd3 100644 --- a/BitFaster.Caching/Scoped.cs +++ b/BitFaster.Caching/Scoped.cs @@ -55,7 +55,7 @@ public bool TryCreateLifetime([MaybeNullWhen(false)] out Lifetime lifetime) if (Interlocked.CompareExchange(ref this.state, oldState + 1, oldState) == oldState) { - lifetime = new Lifetime(this.value, oldState & ReferenceCountMask, this); + lifetime = new Lifetime(this.value, (oldState & ReferenceCountMask) + 1, this); return true; } } From c6bb1cdfed954fae66fcb35b6f7a8cb24c3a4d57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:14:08 +0000 Subject: [PATCH 6/7] Harden lifetime releaser dispatch Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/cf0e178d-3066-4213-9b58-36f0ec1cea78 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching/Lifetime.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/BitFaster.Caching/Lifetime.cs b/BitFaster.Caching/Lifetime.cs index 4b462049..f384d481 100644 --- a/BitFaster.Caching/Lifetime.cs +++ b/BitFaster.Caching/Lifetime.cs @@ -16,7 +16,7 @@ internal interface ILifetimeReleaser /// The type of value public sealed class Lifetime : IDisposable { - private readonly object releaser = default!; + private readonly object releaser; private readonly T value = default!; private readonly int referenceCount; private bool isDisposed; @@ -61,9 +61,13 @@ public void Dispose() { lifetimeReleaser.ReleaseLifetime(); } + else if (this.releaser is Action onDisposeAction) + { + onDisposeAction(); + } else { - ((Action)this.releaser)(); + Throw.InvalidOp("Unsupported lifetime releaser."); } this.isDisposed = true; From 11a8f9fada6f06bfe648d445e4602cb250f6bcb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 07:20:41 +0000 Subject: [PATCH 7/7] Keep lifetime releasers strongly typed Agent-Logs-Url: https://github.com/bitfaster/BitFaster.Caching/sessions/cf0e178d-3066-4213-9b58-36f0ec1cea78 Co-authored-by: bitfaster <12851828+bitfaster@users.noreply.github.com> --- BitFaster.Caching.UnitTests/ScopedTests.cs | 2 +- BitFaster.Caching/Lifetime.cs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/BitFaster.Caching.UnitTests/ScopedTests.cs b/BitFaster.Caching.UnitTests/ScopedTests.cs index 8e642b5d..82397379 100644 --- a/BitFaster.Caching.UnitTests/ScopedTests.cs +++ b/BitFaster.Caching.UnitTests/ScopedTests.cs @@ -54,7 +54,7 @@ public void WhenScopeIsCreatedThenLifetimeDisposedScopeDisposesValue() } [Fact] - public void CreateLifetime_WhenCalled_ReferenceCountIncludesCreatedLifetime() + public void CreateLifetime_AfterIncrement_ReturnsIncrementedReferenceCount() { var scope = new Scoped(new Disposable()); diff --git a/BitFaster.Caching/Lifetime.cs b/BitFaster.Caching/Lifetime.cs index f384d481..2b13bfcf 100644 --- a/BitFaster.Caching/Lifetime.cs +++ b/BitFaster.Caching/Lifetime.cs @@ -16,7 +16,8 @@ internal interface ILifetimeReleaser /// The type of value public sealed class Lifetime : IDisposable { - private readonly object releaser; + private readonly Action? releaseAction; + private readonly ILifetimeReleaser? releaser; private readonly T value = default!; private readonly int referenceCount; private bool isDisposed; @@ -30,7 +31,7 @@ public Lifetime(ReferenceCount value, Action onDisposeAction) { this.value = value.Value; this.referenceCount = value.Count; - this.releaser = onDisposeAction; + this.releaseAction = onDisposeAction; } internal Lifetime(T value, int referenceCount, ILifetimeReleaser releaser) @@ -61,13 +62,9 @@ public void Dispose() { lifetimeReleaser.ReleaseLifetime(); } - else if (this.releaser is Action onDisposeAction) - { - onDisposeAction(); - } else { - Throw.InvalidOp("Unsupported lifetime releaser."); + this.releaseAction!(); } this.isDisposed = true;