diff --git a/PerfectNumbers.Core.Tests/PollingSemaphoreTests.cs b/PerfectNumbers.Core.Tests/PollingSemaphoreTests.cs new file mode 100644 index 0000000..bf599d2 --- /dev/null +++ b/PerfectNumbers.Core.Tests/PollingSemaphoreTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using PerfectNumbers.Core; +using Xunit; + +namespace PerfectNumbers.Core.Tests; + +public class PollingSemaphoreTests +{ + [Fact] + [Trait("Category", "Fast")] + public void TryEnter_AllowsConfiguredConcurrency() + { + var semaphore = new PollingSemaphore(2, TimeSpan.Zero); + + semaphore.TryEnter().Should().BeTrue(); + semaphore.TryEnter().Should().BeTrue(); + semaphore.TryEnter().Should().BeFalse(); + + semaphore.Release(); + semaphore.Release(); + } + + [Fact] + [Trait("Category", "Fast")] + public async Task Wait_BlocksUntilRelease() + { + var semaphore = new PollingSemaphore(1, TimeSpan.FromMilliseconds(1)); + + semaphore.TryEnter().Should().BeTrue(); + + Task waiter = Task.Run(() => semaphore.Wait(TimeSpan.FromMilliseconds(200))); + + await Task.Delay(20); + semaphore.Release(); + + bool entered = await waiter; + entered.Should().BeTrue(); + + semaphore.Release(); + } + + [Fact] + [Trait("Category", "Fast")] + public void Wait_ReturnsFalseWhenTimeoutHits() + { + var semaphore = new PollingSemaphore(1, TimeSpan.FromMilliseconds(2)); + + semaphore.TryEnter().Should().BeTrue(); + + bool acquired = semaphore.Wait(TimeSpan.FromMilliseconds(30)); + + acquired.Should().BeFalse(); + + semaphore.Release(); + } +} diff --git a/PerfectNumbers.Core/PollingSemaphore.cs b/PerfectNumbers.Core/PollingSemaphore.cs new file mode 100644 index 0000000..04aed76 --- /dev/null +++ b/PerfectNumbers.Core/PollingSemaphore.cs @@ -0,0 +1,103 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace PerfectNumbers.Core; + +/// +/// Lightweight semaphore-like guard that uses spinning and optional sleeps instead of locks. +/// It allows a small transient oversubscription while contending threads back off between checks. +/// +public sealed class PollingSemaphore +{ + private readonly int _maximumConcurrency; + private readonly TimeSpan _pauseBetweenChecks; + private readonly bool _hasPause; + private int _currentHolders; + + public PollingSemaphore(int maximumConcurrency, TimeSpan pauseBetweenChecks) + { + if (maximumConcurrency <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maximumConcurrency)); + } + + if (pauseBetweenChecks < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(pauseBetweenChecks)); + } + + _maximumConcurrency = maximumConcurrency; + _pauseBetweenChecks = pauseBetweenChecks; + _hasPause = pauseBetweenChecks > TimeSpan.Zero; + } + + public bool TryEnter() + { + int newValue = Interlocked.Increment(ref _currentHolders); + + if (newValue <= _maximumConcurrency) + { + return true; + } + + Interlocked.Decrement(ref _currentHolders); + return false; + } + + public bool Wait(TimeSpan timeout, CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfLessThan(timeout, Timeout.InfiniteTimeSpan); + + Stopwatch? stopwatch = timeout == Timeout.InfiniteTimeSpan ? null : Stopwatch.StartNew(); + SpinWait spinner = new(); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (TryEnter()) + { + return true; + } + + if (stopwatch is not null && stopwatch.Elapsed >= timeout) + { + return false; + } + + if (spinner.Count < 10) + { + spinner.SpinOnce(); + continue; + } + + if (_hasPause) + { + Thread.Sleep(_pauseBetweenChecks); + spinner.Reset(); + } + else + { + Thread.Yield(); + } + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Wait(CancellationToken cancellationToken) => Wait(Timeout.InfiniteTimeSpan, cancellationToken); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Wait() => Wait(Timeout.InfiniteTimeSpan, CancellationToken.None); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Release() + { + int newValue = Interlocked.Decrement(ref _currentHolders); + + if (newValue < 0) + { + Interlocked.Increment(ref _currentHolders); + throw new SynchronizationLockException("Release called more times than Wait."); + } + } +}