Task.Delay
is still the correct building-block for you to use.
Code
//Uses absolute time from the first time called to synchronise the caller to begin on the next pulse
//If the client takes 2.5xinterval to perform work, the next pulse will be on the 3rd interval
class AbsolutePollIntervals
{
TimeSpan interval = TimeSpan.Zero;
public AbsolutePollIntervals(TimeSpan Interval)
{
this.interval = Interval;
}
//Call this if you want the timer to start before you await the first time
public void Headstart()
{
started = DateTime.UtcNow;
}
public void StopCurrentEarly()
{
cts.Cancel(); //Interrupts the Task.Delay in DelayNext early
}
public void RaiseExceptionOnCurrent(Exception ex)
{
nextException = ex; //This causes the DelayNext function to throw this exception to caller
cts.Cancel();
}
public void RepeatCurrent()
{
delayAgain = true; //This cuases DelayNext to loop again. Use this with SetNextInterval, if you wanted to extend the delay
cts.Cancel();
}
public void SetNextInterval(TimeSpan interval)
{
started = DateTime.MinValue; //No headstart
this.interval = interval;
}
Exception nextException = null;
DateTime started = DateTime.MinValue;
CancellationTokenSource cts = null;
bool delayAgain = false;
public async Task DelayNext()
{
while (true)
{
if (started == DateTime.MinValue) started = DateTime.UtcNow;
var reference = DateTime.UtcNow;
var diff = reference.Subtract(started);
var remainder = diff.TotalMilliseconds % interval.TotalMilliseconds;
var nextWait = interval.TotalMilliseconds - remainder;
cts = new CancellationTokenSource();
await Task.Delay((int)nextWait, cts.Token);
cts.Dispose();
if (nextException != null)
{
var ex = nextException; //So we can null this field before throwing
nextException = null;
throw ex;
}
if (delayAgain == false)
break;
else
delayAgain = false; //reset latch, and let it continue around another round
}
}
}
Usage on consumer:
var pacer = new AbsolutePollIntervals(TimeSpan.FromSeconds(1));
for (int i = 0; i < 10; i++)
{
await pacer.DelayNext();
Console.WriteLine($"Awaited {i}, SignalTime: {DateTime.UtcNow:HH:mm:ss.fff}");
}
return;
Usage on the controller:
//Interrupt the consumer early with no exception
pacer.StopCurrentEarly();
//Interrupt the consumer early with an exception
pacer.RaiseExceptionOnCurrent(new Exception("VPN Disconnected"));
//Extend the time of the consumer by particular amount
pacer.SetNextInterval(TimeSpan.FromSeconds(20));
pacer.RepeatCurrent();
Result [before Edit, current version not tested]
Awaited 0, SignalTime: 03:56:04.777
Awaited 1, SignalTime: 03:56:05.712
Awaited 2, SignalTime: 03:56:06.717
Awaited 3, SignalTime: 03:56:07.709
Awaited 4, SignalTime: 03:56:08.710
Awaited 5, SignalTime: 03:56:09.710
Awaited 6, SignalTime: 03:56:10.710
Awaited 7, SignalTime: 03:56:11.709
Awaited 8, SignalTime: 03:56:11.709
Awaited 9, SignalTime: 03:56:12.709
As you can see above, they all land close to the 710ms mark, showing that this is an absolute interval (not relative to the duration from when DelayNext is called)
It would be possible for a "controller" to hold a shared reference of AbsolutePollIntervals
with a "bludger". With a few extensions to the AbsolutePollIntervals
, the controller could change the interval and the start time. It would also be possible to create a QueuedPollIntervals implementation where the controller enqueues different time intervals that are dequeued by the bludger upon DelayNext()
Update 2020-09-20: Done. I implemented some of the "controller" ideas as per Theodor's implicit challenge ;)
This version hasn't been tested, so it's only there to convey the idea. Also, it will need better concurrency care for any production version.