Add PeriodicTimer (#53899)
authorStephen Toub <stoub@microsoft.com>
Thu, 24 Jun 2021 14:24:47 +0000 (10:24 -0400)
committerGitHub <noreply@github.com>
Thu, 24 Jun 2021 14:24:47 +0000 (10:24 -0400)
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems
src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs [new file with mode: 0644]
src/libraries/System.Runtime/ref/System.Runtime.cs
src/libraries/System.Runtime/tests/System.Runtime.Tests.csproj
src/libraries/System.Runtime/tests/System/Threading/PeriodicTimerTests.cs [new file with mode: 0644]

index 2af41ff..1186b2c 100644 (file)
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\ThreadStateException.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Timeout.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\TimeoutHelper.cs" />
+    <Compile Include="$(MSBuildThisFileDirectory)System\Threading\PeriodicTimer.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Timer.cs" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\TimerQueue.Portable.cs" Condition="'$(FeaturePortableTimer)' == 'true'" />
     <Compile Include="$(MSBuildThisFileDirectory)System\Threading\Volatile.cs" />
diff --git a/src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs b/src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs
new file mode 100644 (file)
index 0000000..b16c4d0
--- /dev/null
@@ -0,0 +1,220 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Runtime.ExceptionServices;
+using System.Threading.Tasks;
+using System.Threading.Tasks.Sources;
+
+namespace System.Threading
+{
+    /// <summary>Provides a periodic timer that enables waiting asynchronously for timer ticks.</summary>
+    /// <remarks>
+    /// This timer is intended to be used only by a single consumer at a time: only one call to <see cref="WaitForNextTickAsync" />
+    /// may be in flight at any given moment.  <see cref="Dispose"/> may be used concurrently with an active <see cref="WaitForNextTickAsync"/>
+    /// to interrupt it and cause it to return false.
+    /// </remarks>
+    public sealed class PeriodicTimer : IDisposable
+    {
+        /// <summary>The underlying timer.</summary>
+        private readonly TimerQueueTimer _timer;
+        /// <summary>All state other than the _timer, so that the rooted timer's callback doesn't indirectly root itself by referring to _timer.</summary>
+        private readonly State _state;
+
+        /// <summary>Initializes the timer.</summary>
+        /// <param name="period">The time interval between invocations of callback..</param>
+        /// <exception cref="ArgumentOutOfRangeException"><paramref name="period"/> must be represent a number of milliseconds larger than 0 and smaller than <see cref="uint.MaxValue"/>.</exception>
+        public PeriodicTimer(TimeSpan period)
+        {
+            long ms = (long)period.TotalMilliseconds;
+            if (ms < 1 || ms > Timer.MaxSupportedTimeout)
+            {
+                GC.SuppressFinalize(this);
+                throw new ArgumentOutOfRangeException(nameof(period));
+            }
+
+            _state = new State();
+            _timer = new TimerQueueTimer(s => ((State)s!).Signal(), _state, (uint)ms, (uint)ms, flowExecutionContext: false);
+        }
+
+        /// <summary>Wait for the next tick of the timer, or for the timer to be stopped.</summary>
+        /// <param name="cancellationToken">
+        /// A <see cref="CancellationToken"/> to use to cancel the asynchronous wait. If cancellation is requested, it affects only the single wait operation;
+        /// the underlying timer continues firing.
+        /// </param>
+        /// <returns>A task that will be completed due to the timer firing, <see cref="Dispose"/> being called to stop the timer, or cancellation being requested.</returns>
+        /// <remarks>
+        /// The <see cref="PeriodicTimer"/> behaves like an auto-reset event, in that multiple ticks are coalesced into a single tick if they occur between
+        /// calls to <see cref="WaitForNextTickAsync"/>.  Similarly, a call to <see cref="Dispose"/> will void any tick not yet consumed. <see cref="WaitForNextTickAsync"/>
+        /// may only be used by one consumer at a time, and may be used concurrently with a single call to <see cref="Dispose"/>.
+        /// </remarks>
+        public ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken = default) =>
+            _state.WaitForNextTickAsync(this, cancellationToken);
+
+        /// <summary>Stops the timer and releases associated managed resources.</summary>
+        /// <remarks>
+        /// <see cref="Dispose"/> will cause an active wait with <see cref="WaitForNextTickAsync"/> to complete with a value of false.
+        /// All subsequent <see cref="WaitForNextTickAsync"/> invocations will produce a value of false.
+        /// </remarks>
+        public void Dispose()
+        {
+            GC.SuppressFinalize(this);
+            _timer.Close();
+            _state.Signal(stopping: true);
+        }
+
+        ~PeriodicTimer() => Dispose();
+
+        /// <summary>Core implementation for the periodic timer.</summary>
+        private sealed class State : IValueTaskSource<bool>
+        {
+            /// <summary>The associated <see cref="PeriodicTimer"/>.</summary>
+            /// <remarks>
+            /// This should refer to the parent instance only when there's an active waiter, and be null when there
+            /// isn't. The TimerQueueTimer in the PeriodicTimer strongly roots itself, and it references this State
+            /// object:
+            ///     PeriodicTimer (finalizable) --ref--> TimerQueueTimer (rooted) --ref--> State --ref--> null
+            /// If this State object then references the PeriodicTimer, it creates a strongly-rooted cycle that prevents anything from
+            /// being GC'd:
+            ///     PeriodicTimer (finalizable) --ref--> TimerQueueTimer (rooted) --ref--> State --v
+            ///           ^--ref-------------------------------------------------------------------|
+            /// When this field is null, the cycle is broken, and dropping all references to the PeriodicTimer allows the
+            /// PeriodicTimer to be finalized and unroot the TimerQueueTimer. Thus, we keep this field set during<see cref="WaitForNextTickAsync"/>
+            /// so that the timer roots any async continuation chain awaiting it, and then keep it unset otherwise so that everything
+            /// can be GC'd appropriately.
+            /// </remarks>
+            private PeriodicTimer? _owner;
+            /// <summary>Core of the <see cref="IValueTaskSource{TResult}"/> implementation.</summary>
+            private ManualResetValueTaskSourceCore<bool> _mrvtsc;
+            /// <summary>Cancellation registration for any active <see cref="WaitForNextTickAsync"/> call.</summary>
+            private CancellationTokenRegistration _ctr;
+            /// <summary>Whether the timer has been stopped.</summary>
+            private bool _stopped;
+            /// <summary>Whether there's a pending notification to be received.  This could be due to the timer firing, the timer being stopped, or cancellation being requested.</summary>
+            private bool _signaled;
+            /// <summary>Whether there's a <see cref="WaitForNextTickAsync"/> call in flight.</summary>
+            private bool _activeWait;
+
+            /// <summary>Wait for the next tick of the timer, or for the timer to be stopped.</summary>
+            public ValueTask<bool> WaitForNextTickAsync(PeriodicTimer owner, CancellationToken cancellationToken)
+            {
+                lock (this)
+                {
+                    if (_activeWait)
+                    {
+                        // WaitForNextTickAsync should only be used by one consumer at a time.  Failing to do so is an error.
+                        ThrowHelper.ThrowInvalidOperationException();
+                    }
+
+                    // If cancellation has already been requested, short-circuit.
+                    if (cancellationToken.IsCancellationRequested)
+                    {
+                        return ValueTask.FromCanceled<bool>(cancellationToken);
+                    }
+
+                    // If the timer has a pending tick or has been stopped, we can complete synchronously.
+                    if (_signaled)
+                    {
+                        // Reset the signal for subsequent consumers, but only if we're not stopped. Since.
+                        // stopping the timer is one way, any subsequent calls should also complete synchronously
+                        // with false, and thus we leave _signaled pinned at true.
+                        if (!_stopped)
+                        {
+                            _signaled = false;
+                        }
+
+                        return new ValueTask<bool>(!_stopped);
+                    }
+
+                    Debug.Assert(!_stopped, "Unexpectedly stopped without _signaled being true.");
+
+                    // Set up for the wait and return a task that will be signaled when the
+                    // timer fires, stop is called, or cancellation is requested.
+                    _owner = owner;
+                    _activeWait = true;
+                    _ctr = cancellationToken.UnsafeRegister(static (state, cancellationToken) => ((State)state!).Signal(cancellationToken: cancellationToken), this);
+
+                    return new ValueTask<bool>(this, _mrvtsc.Version);
+                }
+            }
+
+            /// <summary>Signal that the timer has either fired or been stopped.</summary>
+            public void Signal(bool stopping = false, CancellationToken cancellationToken = default)
+            {
+                bool completeTask = false;
+
+                lock (this)
+                {
+                    _stopped |= stopping;
+                    if (!_signaled)
+                    {
+                        _signaled = true;
+                        completeTask = _activeWait;
+                    }
+                }
+
+                if (completeTask)
+                {
+                    if (cancellationToken.IsCancellationRequested)
+                    {
+                        // If cancellation is requested just before the UnsafeRegister call, it's possible this will end up being invoked
+                        // as part of the WaitForNextTickAsync call and thus as part of holding the lock.  The goal of completeTask
+                        // was to escape that lock, so that we don't invoke any synchronous continuations from the ValueTask as part
+                        // of completing _mrvtsc.  However, in that case, we also haven't returned the ValueTask to the caller, so there
+                        // won't be any continuations yet, which makes this safe.
+                        _mrvtsc.SetException(ExceptionDispatchInfo.SetCurrentStackTrace(new OperationCanceledException(cancellationToken)));
+                    }
+                    else
+                    {
+                        Debug.Assert(!Monitor.IsEntered(this));
+                        _mrvtsc.SetResult(true);
+                    }
+                }
+            }
+
+            /// <inheritdoc/>
+            bool IValueTaskSource<bool>.GetResult(short token)
+            {
+                // Dispose of the cancellation registration.  This is done outside of the below lock in order
+                // to avoid a potential deadlock due to waiting for a concurrent cancellation callback that might
+                // in turn try to take the lock.  For valid usage, GetResult is only called once _ctr has been
+                // successfully initialized before WaitForNextTickAsync returns to its synchronous caller, and
+                // there should be no race conditions accessing it, as concurrent consumption is invalid. If there
+                // is invalid usage, with GetResult used erroneously/concurrently, the worst that happens is cancellation
+                // may not take effect for the in-flight operation, with its registration erroneously disposed.
+                // Note we use Dispose rather than Unregister (which wouldn't risk deadlock) so that we know that thecancellation callback associated with this operation
+                // won't potentially still fire after we've completed this GetResult and a new operation
+                // has potentially started.
+                _ctr.Dispose();
+
+                lock (this)
+                {
+                    try
+                    {
+                        _mrvtsc.GetResult(token);
+                    }
+                    finally
+                    {
+                        _mrvtsc.Reset();
+                        _ctr = default;
+                        _activeWait = false;
+                        _owner = null;
+                        if (!_stopped)
+                        {
+                            _signaled = false;
+                        }
+                    }
+
+                    return !_stopped;
+                }
+            }
+
+            /// <inheritdoc/>
+            ValueTaskSourceStatus IValueTaskSource<bool>.GetStatus(short token) => _mrvtsc.GetStatus(token);
+
+            /// <inheritdoc/>
+            void IValueTaskSource<bool>.OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) =>
+                _mrvtsc.OnCompleted(continuation, state, token, flags);
+        }
+    }
+}
index 39a1cfc..4f9afb9 100644 (file)
@@ -11651,6 +11651,12 @@ namespace System.Threading
         PublicationOnly = 1,
         ExecutionAndPublication = 2,
     }
+    public sealed class PeriodicTimer : System.IDisposable
+    {
+        public PeriodicTimer(System.TimeSpan period) { }
+        public System.Threading.Tasks.ValueTask<bool> WaitForNextTickAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; }
+        public void Dispose() { }
+    }
     public static partial class Timeout
     {
         public const int Infinite = -1;
index 2ca6739..db3b3dc 100644 (file)
     <Compile Include="System\Security\SecurityAttributeTests.cs" />
     <Compile Include="System\Security\SecurityExceptionTests.cs" />
     <Compile Include="System\Text\StringBuilderTests.cs" />
+    <Compile Include="System\Threading\PeriodicTimerTests.cs" />
     <Compile Include="System\Threading\WaitHandleTests.cs" />
     <Compile Include="System\Type\TypePropertyTests.cs" />
     <Compile Include="System\Type\TypeTests.cs" />
diff --git a/src/libraries/System.Runtime/tests/System/Threading/PeriodicTimerTests.cs b/src/libraries/System.Runtime/tests/System/Threading/PeriodicTimerTests.cs
new file mode 100644 (file)
index 0000000..92367b8
--- /dev/null
@@ -0,0 +1,203 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace System.Threading.Tests
+{
+    public class PeriodicTimerTests
+    {
+        [Fact]
+        public void Ctor_InvalidArguments_Throws()
+        {
+            AssertExtensions.Throws<ArgumentOutOfRangeException>("period", () => new PeriodicTimer(TimeSpan.FromMilliseconds(-1)));
+            AssertExtensions.Throws<ArgumentOutOfRangeException>("period", () => new PeriodicTimer(TimeSpan.Zero));
+            AssertExtensions.Throws<ArgumentOutOfRangeException>("period", () => new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue)));
+        }
+
+        [Theory]
+        [InlineData(1)]
+        [InlineData(uint.MaxValue - 1)]
+        public void Ctor_ValidArguments_Succeeds(uint milliseconds)
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(milliseconds));
+        }
+
+        [Fact]
+        public async Task Dispose_Idempotent()
+        {
+            var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1));
+
+            Assert.True(await timer.WaitForNextTickAsync());
+
+            for (int i = 0; i < 2; i++)
+            {
+                timer.Dispose();
+                Assert.False(timer.WaitForNextTickAsync().Result);
+
+                ((IDisposable)timer).Dispose();
+                Assert.False(timer.WaitForNextTickAsync().Result);
+            }
+        }
+
+        [Fact]
+        public async Task WaitForNextTickAsync_TimerFires_ReturnsTrue()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1));
+            await Task.Delay(100);
+            for (int i = 0; i < 3; i++)
+            {
+                Assert.True(await timer.WaitForNextTickAsync());
+            }
+            timer.Dispose();
+            Assert.False(timer.WaitForNextTickAsync().Result);
+        }
+
+        [Fact]
+        public async Task WaitForNextTickAsync_Dispose_ReturnsFalse()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+            ValueTask<bool> task = timer.WaitForNextTickAsync();
+            timer.Dispose();
+            Assert.False(await task);
+        }
+
+        [Fact]
+        public async Task WaitForNextTickAsync_ConcurrentDispose_ReturnsFalse()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+
+            _ = Task.Run(async delegate
+            {
+                await Task.Delay(1);
+                timer.Dispose();
+            });
+
+            Assert.False(await timer.WaitForNextTickAsync());
+        }
+
+        [Fact]
+        public async Task WaitForNextTickAsync_ConcurrentDisposeAfterTicks_EventuallyReturnsFalse()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1));
+
+            for (int i = 0; i < 5; i++)
+            {
+                Assert.True(await timer.WaitForNextTickAsync());
+            }
+
+            _ = Task.Run(async delegate
+            {
+                await Task.Delay(1);
+                timer.Dispose();
+            });
+
+            while (await timer.WaitForNextTickAsync());
+        }
+
+        [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsPreciseGcSupported))]
+        public void PeriodicTimer_NoActiveOperations_TimerNotRooted()
+        {
+            WeakReference<PeriodicTimer> timer = Create();
+
+            WaitForTimerToBeCollected(timer, expected: true);
+
+            [MethodImpl(MethodImplOptions.NoInlining)]
+            static WeakReference<PeriodicTimer> Create() =>
+                new WeakReference<PeriodicTimer>(new PeriodicTimer(TimeSpan.FromMilliseconds(1)));
+        }
+
+        [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsPreciseGcSupported))]
+        public async Task PeriodicTimer_ActiveOperations_TimerRooted()
+        {
+            (WeakReference<PeriodicTimer> timer, ValueTask<bool> task) = Create();
+
+            WaitForTimerToBeCollected(timer, expected: false);
+
+            Assert.True(await task);
+
+            WaitForTimerToBeCollected(timer, expected: true);
+
+            [MethodImpl(MethodImplOptions.NoInlining)]
+            static (WeakReference<PeriodicTimer>, ValueTask<bool>) Create()
+            {
+                var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1));
+                ValueTask<bool> task = timer.WaitForNextTickAsync();
+                return (new WeakReference<PeriodicTimer>(timer), task);
+            }
+        }
+
+        [Fact]
+        public void WaitForNextTickAsync_WaitAlreadyInProgress_Throws()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+
+            ValueTask<bool> task = timer.WaitForNextTickAsync();
+            Assert.False(task.IsCompleted);
+
+            Assert.Throws<InvalidOperationException>(() => timer.WaitForNextTickAsync());
+
+            Assert.False(task.IsCompleted);
+
+            timer.Dispose();
+            Assert.True(task.IsCompleted);
+            Assert.False(task.Result);
+        }
+
+        [Fact]
+        public void WaitForNextTickAsync_CanceledBeforeWait_CompletesSynchronously()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+
+            var cts = new CancellationTokenSource();
+            cts.Cancel();
+
+            ValueTask<bool> task = timer.WaitForNextTickAsync(cts.Token);
+            Assert.True(task.IsCanceled);
+            Assert.Equal(cts.Token, Assert.ThrowsAny<OperationCanceledException>(() => task.Result).CancellationToken);
+        }
+
+        [Fact]
+        public void WaitForNextTickAsync_CanceledAfterWait_CancelsOperation()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue - 1));
+
+            var cts = new CancellationTokenSource();
+
+            ValueTask<bool> task = timer.WaitForNextTickAsync(cts.Token);
+            cts.Cancel();
+
+            Assert.Equal(cts.Token, Assert.ThrowsAny<OperationCanceledException>(() => task.Result).CancellationToken);
+        }
+
+        [Fact]
+        public async Task WaitForNextTickAsync_CanceledWaitThenWaitAgain_Succeeds()
+        {
+            using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1));
+
+            ValueTask<bool> task = timer.WaitForNextTickAsync(new CancellationToken(true));
+            Assert.ThrowsAny<OperationCanceledException>(() => task.Result);
+
+            var cts = new CancellationTokenSource();
+            task = timer.WaitForNextTickAsync(cts.Token);
+            cts.Cancel();
+            Assert.Equal(cts.Token, Assert.ThrowsAny<OperationCanceledException>(() => task.Result).CancellationToken);
+
+            for (int i = 0; i < 10; i++)
+            {
+                Assert.True(await timer.WaitForNextTickAsync());
+            }
+        }
+
+        private static void WaitForTimerToBeCollected(WeakReference<PeriodicTimer> timer, bool expected)
+        {
+            Assert.Equal(expected, SpinWait.SpinUntil(() =>
+            {
+                GC.Collect();
+                return !timer.TryGetTarget(out _);
+            }, TimeSpan.FromSeconds(expected ? 5 : 0.5)));
+        }
+    }
+}