From b8896081ecdda7a5a1df2eb1b8a4cc069c455d4a Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 8 Jun 2021 17:18:46 -0400 Subject: [PATCH] Add PeriodicTimer --- .../System.Private.CoreLib.Shared.projitems | 1 + .../src/System/Threading/PeriodicTimer.cs | 220 ++++++++++++++++++ .../System.Runtime/ref/System.Runtime.cs | 6 + .../tests/System.Runtime.Tests.csproj | 1 + .../System/Threading/PeriodicTimerTests.cs | 203 ++++++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs create mode 100644 src/libraries/System.Runtime/tests/System/Threading/PeriodicTimerTests.cs diff --git a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems index 2af41ff13424b..1186b2c606f63 100644 --- a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems +++ b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems @@ -1069,6 +1069,7 @@ + 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 index 0000000000000..b16c4d067b7ed --- /dev/null +++ b/src/libraries/System.Private.CoreLib/src/System/Threading/PeriodicTimer.cs @@ -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 +{ + /// Provides a periodic timer that enables waiting asynchronously for timer ticks. + /// + /// This timer is intended to be used only by a single consumer at a time: only one call to + /// may be in flight at any given moment. may be used concurrently with an active + /// to interrupt it and cause it to return false. + /// + public sealed class PeriodicTimer : IDisposable + { + /// The underlying timer. + private readonly TimerQueueTimer _timer; + /// All state other than the _timer, so that the rooted timer's callback doesn't indirectly root itself by referring to _timer. + private readonly State _state; + + /// Initializes the timer. + /// The time interval between invocations of callback.. + /// must be represent a number of milliseconds larger than 0 and smaller than . + 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); + } + + /// Wait for the next tick of the timer, or for the timer to be stopped. + /// + /// A to use to cancel the asynchronous wait. If cancellation is requested, it affects only the single wait operation; + /// the underlying timer continues firing. + /// + /// A task that will be completed due to the timer firing, being called to stop the timer, or cancellation being requested. + /// + /// The behaves like an auto-reset event, in that multiple ticks are coalesced into a single tick if they occur between + /// calls to . Similarly, a call to will void any tick not yet consumed. + /// may only be used by one consumer at a time, and may be used concurrently with a single call to . + /// + public ValueTask WaitForNextTickAsync(CancellationToken cancellationToken = default) => + _state.WaitForNextTickAsync(this, cancellationToken); + + /// Stops the timer and releases associated managed resources. + /// + /// will cause an active wait with to complete with a value of false. + /// All subsequent invocations will produce a value of false. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + _timer.Close(); + _state.Signal(stopping: true); + } + + ~PeriodicTimer() => Dispose(); + + /// Core implementation for the periodic timer. + private sealed class State : IValueTaskSource + { + /// The associated . + /// + /// 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 + /// 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. + /// + private PeriodicTimer? _owner; + /// Core of the implementation. + private ManualResetValueTaskSourceCore _mrvtsc; + /// Cancellation registration for any active call. + private CancellationTokenRegistration _ctr; + /// Whether the timer has been stopped. + private bool _stopped; + /// 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. + private bool _signaled; + /// Whether there's a call in flight. + private bool _activeWait; + + /// Wait for the next tick of the timer, or for the timer to be stopped. + public ValueTask 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(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(!_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(this, _mrvtsc.Version); + } + } + + /// Signal that the timer has either fired or been stopped. + 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); + } + } + } + + /// + bool IValueTaskSource.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; + } + } + + /// + ValueTaskSourceStatus IValueTaskSource.GetStatus(short token) => _mrvtsc.GetStatus(token); + + /// + void IValueTaskSource.OnCompleted(Action continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) => + _mrvtsc.OnCompleted(continuation, state, token, flags); + } + } +} diff --git a/src/libraries/System.Runtime/ref/System.Runtime.cs b/src/libraries/System.Runtime/ref/System.Runtime.cs index 39a1cfc4b816c..4f9afb90503f3 100644 --- a/src/libraries/System.Runtime/ref/System.Runtime.cs +++ b/src/libraries/System.Runtime/ref/System.Runtime.cs @@ -11651,6 +11651,12 @@ public enum LazyThreadSafetyMode PublicationOnly = 1, ExecutionAndPublication = 2, } + public sealed class PeriodicTimer : System.IDisposable + { + public PeriodicTimer(System.TimeSpan period) { } + public System.Threading.Tasks.ValueTask WaitForNextTickAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; } + public void Dispose() { } + } public static partial class Timeout { public const int Infinite = -1; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests.csproj b/src/libraries/System.Runtime/tests/System.Runtime.Tests.csproj index 2ca6739e9c6c1..db3b3dcd1b5da 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests.csproj +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests.csproj @@ -233,6 +233,7 @@ + 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 index 0000000000000..92367b8a46746 --- /dev/null +++ b/src/libraries/System.Runtime/tests/System/Threading/PeriodicTimerTests.cs @@ -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("period", () => new PeriodicTimer(TimeSpan.FromMilliseconds(-1))); + AssertExtensions.Throws("period", () => new PeriodicTimer(TimeSpan.Zero)); + AssertExtensions.Throws("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 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 timer = Create(); + + WaitForTimerToBeCollected(timer, expected: true); + + [MethodImpl(MethodImplOptions.NoInlining)] + static WeakReference Create() => + new WeakReference(new PeriodicTimer(TimeSpan.FromMilliseconds(1))); + } + + [ConditionalFact(typeof(PlatformDetection), nameof(PlatformDetection.IsPreciseGcSupported))] + public async Task PeriodicTimer_ActiveOperations_TimerRooted() + { + (WeakReference timer, ValueTask task) = Create(); + + WaitForTimerToBeCollected(timer, expected: false); + + Assert.True(await task); + + WaitForTimerToBeCollected(timer, expected: true); + + [MethodImpl(MethodImplOptions.NoInlining)] + static (WeakReference, ValueTask) Create() + { + var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1)); + ValueTask task = timer.WaitForNextTickAsync(); + return (new WeakReference(timer), task); + } + } + + [Fact] + public void WaitForNextTickAsync_WaitAlreadyInProgress_Throws() + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue - 1)); + + ValueTask task = timer.WaitForNextTickAsync(); + Assert.False(task.IsCompleted); + + Assert.Throws(() => 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 task = timer.WaitForNextTickAsync(cts.Token); + Assert.True(task.IsCanceled); + Assert.Equal(cts.Token, Assert.ThrowsAny(() => task.Result).CancellationToken); + } + + [Fact] + public void WaitForNextTickAsync_CanceledAfterWait_CancelsOperation() + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(uint.MaxValue - 1)); + + var cts = new CancellationTokenSource(); + + ValueTask task = timer.WaitForNextTickAsync(cts.Token); + cts.Cancel(); + + Assert.Equal(cts.Token, Assert.ThrowsAny(() => task.Result).CancellationToken); + } + + [Fact] + public async Task WaitForNextTickAsync_CanceledWaitThenWaitAgain_Succeeds() + { + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(1)); + + ValueTask task = timer.WaitForNextTickAsync(new CancellationToken(true)); + Assert.ThrowsAny(() => task.Result); + + var cts = new CancellationTokenSource(); + task = timer.WaitForNextTickAsync(cts.Token); + cts.Cancel(); + Assert.Equal(cts.Token, Assert.ThrowsAny(() => task.Result).CancellationToken); + + for (int i = 0; i < 10; i++) + { + Assert.True(await timer.WaitForNextTickAsync()); + } + } + + private static void WaitForTimerToBeCollected(WeakReference timer, bool expected) + { + Assert.Equal(expected, SpinWait.SpinUntil(() => + { + GC.Collect(); + return !timer.TryGetTarget(out _); + }, TimeSpan.FromSeconds(expected ? 5 : 0.5))); + } + } +}