Skip to content

Commit 5c2ff17

Browse files
authored
Add PeriodicTimer (#53899)
1 parent b317d06 commit 5c2ff17

5 files changed

Lines changed: 431 additions & 0 deletions

File tree

src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,7 @@
10691069
<Compile Include="$(MSBuildThisFileDirectory)System\Threading\ThreadStateException.cs" />
10701070
<Compile Include="$(MSBuildThisFileDirectory)System\Threading\Timeout.cs" />
10711071
<Compile Include="$(MSBuildThisFileDirectory)System\Threading\TimeoutHelper.cs" />
1072+
<Compile Include="$(MSBuildThisFileDirectory)System\Threading\PeriodicTimer.cs" />
10721073
<Compile Include="$(MSBuildThisFileDirectory)System\Threading\Timer.cs" />
10731074
<Compile Include="$(MSBuildThisFileDirectory)System\Threading\TimerQueue.Portable.cs" Condition="'$(FeaturePortableTimer)' == 'true'" />
10741075
<Compile Include="$(MSBuildThisFileDirectory)System\Threading\Volatile.cs" />
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using System.Runtime.ExceptionServices;
6+
using System.Threading.Tasks;
7+
using System.Threading.Tasks.Sources;
8+
9+
namespace System.Threading
10+
{
11+
/// <summary>Provides a periodic timer that enables waiting asynchronously for timer ticks.</summary>
12+
/// <remarks>
13+
/// This timer is intended to be used only by a single consumer at a time: only one call to <see cref="WaitForNextTickAsync" />
14+
/// may be in flight at any given moment. <see cref="Dispose"/> may be used concurrently with an active <see cref="WaitForNextTickAsync"/>
15+
/// to interrupt it and cause it to return false.
16+
/// </remarks>
17+
public sealed class PeriodicTimer : IDisposable
18+
{
19+
/// <summary>The underlying timer.</summary>
20+
private readonly TimerQueueTimer _timer;
21+
/// <summary>All state other than the _timer, so that the rooted timer's callback doesn't indirectly root itself by referring to _timer.</summary>
22+
private readonly State _state;
23+
24+
/// <summary>Initializes the timer.</summary>
25+
/// <param name="period">The time interval between invocations of callback..</param>
26+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="period"/> must be represent a number of milliseconds larger than 0 and smaller than <see cref="uint.MaxValue"/>.</exception>
27+
public PeriodicTimer(TimeSpan period)
28+
{
29+
long ms = (long)period.TotalMilliseconds;
30+
if (ms < 1 || ms > Timer.MaxSupportedTimeout)
31+
{
32+
GC.SuppressFinalize(this);
33+
throw new ArgumentOutOfRangeException(nameof(period));
34+
}
35+
36+
_state = new State();
37+
_timer = new TimerQueueTimer(s => ((State)s!).Signal(), _state, (uint)ms, (uint)ms, flowExecutionContext: false);
38+
}
39+
40+
/// <summary>Wait for the next tick of the timer, or for the timer to be stopped.</summary>
41+
/// <param name="cancellationToken">
42+
/// A <see cref="CancellationToken"/> to use to cancel the asynchronous wait. If cancellation is requested, it affects only the single wait operation;
43+
/// the underlying timer continues firing.
44+
/// </param>
45+
/// <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>
46+
/// <remarks>
47+
/// The <see cref="PeriodicTimer"/> behaves like an auto-reset event, in that multiple ticks are coalesced into a single tick if they occur between
48+
/// calls to <see cref="WaitForNextTickAsync"/>. Similarly, a call to <see cref="Dispose"/> will void any tick not yet consumed. <see cref="WaitForNextTickAsync"/>
49+
/// may only be used by one consumer at a time, and may be used concurrently with a single call to <see cref="Dispose"/>.
50+
/// </remarks>
51+
public ValueTask<bool> WaitForNextTickAsync(CancellationToken cancellationToken = default) =>
52+
_state.WaitForNextTickAsync(this, cancellationToken);
53+
54+
/// <summary>Stops the timer and releases associated managed resources.</summary>
55+
/// <remarks>
56+
/// <see cref="Dispose"/> will cause an active wait with <see cref="WaitForNextTickAsync"/> to complete with a value of false.
57+
/// All subsequent <see cref="WaitForNextTickAsync"/> invocations will produce a value of false.
58+
/// </remarks>
59+
public void Dispose()
60+
{
61+
GC.SuppressFinalize(this);
62+
_timer.Close();
63+
_state.Signal(stopping: true);
64+
}
65+
66+
~PeriodicTimer() => Dispose();
67+
68+
/// <summary>Core implementation for the periodic timer.</summary>
69+
private sealed class State : IValueTaskSource<bool>
70+
{
71+
/// <summary>The associated <see cref="PeriodicTimer"/>.</summary>
72+
/// <remarks>
73+
/// This should refer to the parent instance only when there's an active waiter, and be null when there
74+
/// isn't. The TimerQueueTimer in the PeriodicTimer strongly roots itself, and it references this State
75+
/// object:
76+
/// PeriodicTimer (finalizable) --ref--> TimerQueueTimer (rooted) --ref--> State --ref--> null
77+
/// If this State object then references the PeriodicTimer, it creates a strongly-rooted cycle that prevents anything from
78+
/// being GC'd:
79+
/// PeriodicTimer (finalizable) --ref--> TimerQueueTimer (rooted) --ref--> State --v
80+
/// ^--ref-------------------------------------------------------------------|
81+
/// When this field is null, the cycle is broken, and dropping all references to the PeriodicTimer allows the
82+
/// PeriodicTimer to be finalized and unroot the TimerQueueTimer. Thus, we keep this field set during<see cref="WaitForNextTickAsync"/>
83+
/// so that the timer roots any async continuation chain awaiting it, and then keep it unset otherwise so that everything
84+
/// can be GC'd appropriately.
85+
/// </remarks>
86+
private PeriodicTimer? _owner;
87+
/// <summary>Core of the <see cref="IValueTaskSource{TResult}"/> implementation.</summary>
88+
private ManualResetValueTaskSourceCore<bool> _mrvtsc;
89+
/// <summary>Cancellation registration for any active <see cref="WaitForNextTickAsync"/> call.</summary>
90+
private CancellationTokenRegistration _ctr;
91+
/// <summary>Whether the timer has been stopped.</summary>
92+
private bool _stopped;
93+
/// <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>
94+
private bool _signaled;
95+
/// <summary>Whether there's a <see cref="WaitForNextTickAsync"/> call in flight.</summary>
96+
private bool _activeWait;
97+
98+
/// <summary>Wait for the next tick of the timer, or for the timer to be stopped.</summary>
99+
public ValueTask<bool> WaitForNextTickAsync(PeriodicTimer owner, CancellationToken cancellationToken)
100+
{
101+
lock (this)
102+
{
103+
if (_activeWait)
104+
{
105+
// WaitForNextTickAsync should only be used by one consumer at a time. Failing to do so is an error.
106+
ThrowHelper.ThrowInvalidOperationException();
107+
}
108+
109+
// If cancellation has already been requested, short-circuit.
110+
if (cancellationToken.IsCancellationRequested)
111+
{
112+
return ValueTask.FromCanceled<bool>(cancellationToken);
113+
}
114+
115+
// If the timer has a pending tick or has been stopped, we can complete synchronously.
116+
if (_signaled)
117+
{
118+
// Reset the signal for subsequent consumers, but only if we're not stopped. Since.
119+
// stopping the timer is one way, any subsequent calls should also complete synchronously
120+
// with false, and thus we leave _signaled pinned at true.
121+
if (!_stopped)
122+
{
123+
_signaled = false;
124+
}
125+
126+
return new ValueTask<bool>(!_stopped);
127+
}
128+
129+
Debug.Assert(!_stopped, "Unexpectedly stopped without _signaled being true.");
130+
131+
// Set up for the wait and return a task that will be signaled when the
132+
// timer fires, stop is called, or cancellation is requested.
133+
_owner = owner;
134+
_activeWait = true;
135+
_ctr = cancellationToken.UnsafeRegister(static (state, cancellationToken) => ((State)state!).Signal(cancellationToken: cancellationToken), this);
136+
137+
return new ValueTask<bool>(this, _mrvtsc.Version);
138+
}
139+
}
140+
141+
/// <summary>Signal that the timer has either fired or been stopped.</summary>
142+
public void Signal(bool stopping = false, CancellationToken cancellationToken = default)
143+
{
144+
bool completeTask = false;
145+
146+
lock (this)
147+
{
148+
_stopped |= stopping;
149+
if (!_signaled)
150+
{
151+
_signaled = true;
152+
completeTask = _activeWait;
153+
}
154+
}
155+
156+
if (completeTask)
157+
{
158+
if (cancellationToken.IsCancellationRequested)
159+
{
160+
// If cancellation is requested just before the UnsafeRegister call, it's possible this will end up being invoked
161+
// as part of the WaitForNextTickAsync call and thus as part of holding the lock. The goal of completeTask
162+
// was to escape that lock, so that we don't invoke any synchronous continuations from the ValueTask as part
163+
// of completing _mrvtsc. However, in that case, we also haven't returned the ValueTask to the caller, so there
164+
// won't be any continuations yet, which makes this safe.
165+
_mrvtsc.SetException(ExceptionDispatchInfo.SetCurrentStackTrace(new OperationCanceledException(cancellationToken)));
166+
}
167+
else
168+
{
169+
Debug.Assert(!Monitor.IsEntered(this));
170+
_mrvtsc.SetResult(true);
171+
}
172+
}
173+
}
174+
175+
/// <inheritdoc/>
176+
bool IValueTaskSource<bool>.GetResult(short token)
177+
{
178+
// Dispose of the cancellation registration. This is done outside of the below lock in order
179+
// to avoid a potential deadlock due to waiting for a concurrent cancellation callback that might
180+
// in turn try to take the lock. For valid usage, GetResult is only called once _ctr has been
181+
// successfully initialized before WaitForNextTickAsync returns to its synchronous caller, and
182+
// there should be no race conditions accessing it, as concurrent consumption is invalid. If there
183+
// is invalid usage, with GetResult used erroneously/concurrently, the worst that happens is cancellation
184+
// may not take effect for the in-flight operation, with its registration erroneously disposed.
185+
// Note we use Dispose rather than Unregister (which wouldn't risk deadlock) so that we know that thecancellation callback associated with this operation
186+
// won't potentially still fire after we've completed this GetResult and a new operation
187+
// has potentially started.
188+
_ctr.Dispose();
189+
190+
lock (this)
191+
{
192+
try
193+
{
194+
_mrvtsc.GetResult(token);
195+
}
196+
finally
197+
{
198+
_mrvtsc.Reset();
199+
_ctr = default;
200+
_activeWait = false;
201+
_owner = null;
202+
if (!_stopped)
203+
{
204+
_signaled = false;
205+
}
206+
}
207+
208+
return !_stopped;
209+
}
210+
}
211+
212+
/// <inheritdoc/>
213+
ValueTaskSourceStatus IValueTaskSource<bool>.GetStatus(short token) => _mrvtsc.GetStatus(token);
214+
215+
/// <inheritdoc/>
216+
void IValueTaskSource<bool>.OnCompleted(Action<object?> continuation, object? state, short token, ValueTaskSourceOnCompletedFlags flags) =>
217+
_mrvtsc.OnCompleted(continuation, state, token, flags);
218+
}
219+
}
220+
}

src/libraries/System.Runtime/ref/System.Runtime.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11651,6 +11651,12 @@ public enum LazyThreadSafetyMode
1165111651
PublicationOnly = 1,
1165211652
ExecutionAndPublication = 2,
1165311653
}
11654+
public sealed class PeriodicTimer : System.IDisposable
11655+
{
11656+
public PeriodicTimer(System.TimeSpan period) { }
11657+
public System.Threading.Tasks.ValueTask<bool> WaitForNextTickAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; }
11658+
public void Dispose() { }
11659+
}
1165411660
public static partial class Timeout
1165511661
{
1165611662
public const int Infinite = -1;

src/libraries/System.Runtime/tests/System.Runtime.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@
233233
<Compile Include="System\Security\SecurityAttributeTests.cs" />
234234
<Compile Include="System\Security\SecurityExceptionTests.cs" />
235235
<Compile Include="System\Text\StringBuilderTests.cs" />
236+
<Compile Include="System\Threading\PeriodicTimerTests.cs" />
236237
<Compile Include="System\Threading\WaitHandleTests.cs" />
237238
<Compile Include="System\Type\TypePropertyTests.cs" />
238239
<Compile Include="System\Type\TypeTests.cs" />

0 commit comments

Comments
 (0)