Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scoped cache broken due to operator== ABA problem #409

Merged
merged 5 commits into from
Oct 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions BitFaster.Caching.UnitTests/ReferenceCountTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
using FluentAssertions;
using System;
using System.Collections.Generic;
using System.Text;
using Xunit;

namespace BitFaster.Caching.UnitTests
Expand All @@ -17,6 +14,15 @@ public void WhenOtherIsEqualEqualsReturnsTrue()
a.Should().Be(b);
}

[Fact]
public void WhenOtherIsEqualReferenceEqualsReturnsFalse()
{
var a = new ReferenceCount<object>(new object());
var b = a.IncrementCopy().DecrementCopy();

a.Should().NotBeSameAs(b);
}

[Fact]
public void WhenOtherIsNotEqualEqualsReturnsFalse()
{
Expand Down
17 changes: 17 additions & 0 deletions BitFaster.Caching.UnitTests/ScopedCacheTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,22 @@ public void GetOrAddDisposedScopeThrows()

getOrAdd.Should().Throw<InvalidOperationException>();
}

[Fact]
public async Task WhenSoakScopedGetOrAddValueIsAlwaysAlive()
{
for (int i = 0; i < 10; i++)
{
await Threaded.Run(4, () => {
for (int j = 0; j < 100000; j++)
{
using (var l = this.cache.ScopedGetOrAdd(j, k => new Scoped<Disposable>(new Disposable(k))))
{
l.Value.IsDisposed.Should().BeFalse($"ref count {l.ReferenceCount}");
}
}
});
}
}
}
}
25 changes: 25 additions & 0 deletions BitFaster.Caching.UnitTests/ScopedTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Text;
using Xunit;
using System.Threading.Tasks;

namespace BitFaster.Caching.UnitTests
{
Expand Down Expand Up @@ -87,5 +88,29 @@ public void WhenScopedIsCreatedFromCacheItemHasExpectedLifetime()

valueFactory.Disposable.IsDisposed.Should().BeTrue();
}

[Fact]
public async Task WhenSoakCreateLifetimeScopeIsDisposedCorrectly()
{
for (int i = 0; i < 10; i++)
{
var scope = new Scoped<Disposable>(new Disposable(i));

await Threaded.Run(4, () => {
for (int i = 0; i < 100000; i++)
{
using (var l = scope.CreateLifetime())
{
l.Value.IsDisposed.Should().BeFalse();
}
}
});

scope.IsDisposed.Should().BeFalse();
scope.Dispose();
scope.TryCreateLifetime(out _).Should().BeFalse();
scope.IsDisposed.Should().BeTrue();
}
}
}
}
14 changes: 7 additions & 7 deletions BitFaster.Caching/ReferenceCount.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,21 @@ public override int GetHashCode()
}

/// <summary>
/// Determines whether two ReferenceCount instances have the same value.
/// Determines whether two ReferenceCount instances are the exact same value via a reference equality check.
/// </summary>
/// <param name="left">The left ReferernceCount to compare, or null.</param>
/// <param name="right">The right ReferernceCount to compare, or null.</param>
/// <param name="left">The left ReferenceCount to compare, or null.</param>
/// <param name="right">The right ReferenceCount to compare, or null.</param>
/// <returns>true if the value of left is the same as the value of right; otherwise, false.</returns>
public static bool operator ==(ReferenceCount<TValue> left, ReferenceCount<TValue> right)
{
return EqualityComparer<ReferenceCount<TValue>>.Default.Equals(left, right);
return object.ReferenceEquals(left, right);
}

/// <summary>
/// Determines whether two ReferenceCount instances have different values.
/// Determines whether two ReferenceCount instances are different via a reference equality check.
/// </summary>
/// <param name="left">The left ReferernceCount to compare, or null.</param>
/// <param name="right">The right ReferernceCount to compare, or null.</param>
/// <param name="left">The left ReferenceCount to compare, or null.</param>
/// <param name="right">The right ReferenceCount to compare, or null.</param>
/// <returns>true if the value of left is different from the value of right; otherwise, false.</returns>
public static bool operator !=(ReferenceCount<TValue> left, ReferenceCount<TValue> right)
{
Expand Down
18 changes: 10 additions & 8 deletions BitFaster.Caching/Scoped.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace BitFaster.Caching
public sealed class Scoped<T> : IScoped<T>, IDisposable where T : IDisposable
{
private ReferenceCount<T> refCount;
private bool isDisposed;
private int disposed = 0;

/// <summary>
/// Initializes a new Scoped value.
Expand All @@ -30,7 +30,7 @@ public Scoped(T value)
/// <summary>
/// Gets a value indicating whether the scope is disposed.
/// </summary>
public bool IsDisposed => isDisposed;
public bool IsDisposed => Volatile.Read(ref this.disposed) == 1;

/// <summary>
/// Attempts to create a lifetime for the scoped value. The lifetime guarantees the value is alive until
Expand All @@ -45,7 +45,7 @@ public bool TryCreateLifetime(out Lifetime<T> lifetime)
var oldRefCount = this.refCount;

// If old ref count is 0, the scoped object has been disposed and there was a race.
if (this.isDisposed || oldRefCount.Count == 0)
if (IsDisposed || oldRefCount.Count == 0)
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is another potential race as follows:

  1. Thread A: check IsDisposed returns false
  2. Thread B: Dispose, IsDisposed now true
  3. Thread A: IncrementCount, return lifetime containing disposed value

I am not able to provoke this case with the soak test.

{
lifetime = default;
return false;
Expand Down Expand Up @@ -82,9 +82,11 @@ private void DecrementReferenceCount()

if (oldRefCount == Interlocked.CompareExchange(ref this.refCount, oldRefCount.DecrementCopy(), oldRefCount))
{
if (this.refCount.Count == 0)
// Note this.refCount may be stale.
// Old count == 1, thus new ref count is 0, dispose the value.
if (oldRefCount.Count == 1)
{
this.refCount.Value?.Dispose();
oldRefCount.Value?.Dispose();
}

break;
Expand All @@ -98,10 +100,10 @@ private void DecrementReferenceCount()
/// </summary>
public void Dispose()
{
if (!this.isDisposed)
// Dispose exactly once, decrement via dispose exactly once
if (Interlocked.CompareExchange(ref this.disposed, 1, 0) == 0)
{
this.DecrementReferenceCount();
this.isDisposed = true;
DecrementReferenceCount();
}
}

Expand Down
9 changes: 2 additions & 7 deletions BitFaster.Caching/ScopedCacheDefaults.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;


namespace BitFaster.Caching
{
internal static class ScopedCacheDefaults
{
internal const int MaxRetry = 5;
internal const int MaxRetry = 64;
internal static readonly string RetryFailureMessage = $"Exceeded {MaxRetry} attempts to create a lifetime.";
}
}
2 changes: 1 addition & 1 deletion BitFaster.Caching/Throw.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ internal static class Throw
private static InvalidOperationException CreateScopedRetryFailure() => new InvalidOperationException(ScopedCacheDefaults.RetryFailureMessage);

[MethodImpl(MethodImplOptions.NoInlining)]
private static ObjectDisposedException CreateObjectDisposedException<T>() => new ObjectDisposedException(nameof(T));
private static ObjectDisposedException CreateObjectDisposedException<T>() => new ObjectDisposedException(typeof(T).Name);

[ExcludeFromCodeCoverage]
private static string GetArgumentString(ExceptionArgument argument)
Expand Down
Loading