【问题标题】:Creating an awaitable System.Timers.Timer创建一个等待的 System.Timers.Timer
【发布时间】:2019-07-02 01:10:20
【问题描述】:

我有一个想法,就是创建一个可以等待的计时器,而不是引发事件。我还没有想到任何实际的应用程序,并且可能不是非常有用的东西,但我想看看它是否至少可以作为练习。可以这样使用:

var timer = new System.Timers.Timer();
timer.Interval = 100;
timer.Enabled = true;
for (int i = 0; i < 10; i++)
{
    var signalTime = await timer;
    Console.WriteLine($"Awaited {i}, SignalTime: {signalTime:HH:mm:ss.fff}");
}

定时器等待10次,预期输出为:

等待 0,信号时间:06:08:51.674
等待 1,信号时间:06:08:51.783
等待 2,信号时间:06:08:51.891
等待 3,信号时间:06:08:52.002
等待 4,信号时间:06:08:52.110
等待 5,信号时间:06:08:52.218
等待 6,信号时间:06:08:52.332
等待 7,信号时间:06:08:52.438
等待 8,信号时间:06:08:52.546
等待 9,SignalTime:06:08:52.660

在这种情况下,一个简单的await Task.Delay(100) 会做同样的事情,但是计时器可以灵活地控制程序另一部分的时间间隔(有可能thread safety issues 的警告)。

关于实现,我发现an article 描述了如何使各种事物等待,例如TimeSpanintDateTimeOffsetProcess。看来我必须编写一个返回TaskAwaiter 的扩展方法,但我不确定具体要做什么。有人知道吗?

public static TaskAwaiter GetAwaiter(this System.Timers.Timer timer)
{
    // TODO
}

更新:我使用accepted answer执行的实际输出更新了示例代码和预期输出。

【问题讨论】:

  • @AlexeiLevenkov duplicate question 展示了如何使用Task.Delay 以预定义的时间间隔重复一个动作。在我的情况下,我想使用可以在应用程序执行期间动态控制(禁用、重新启用、更改间隔等)的Timer。我无法使用duplicate question 中提出的解决方案来解决我的问题。
  • 它显示了实现完整类所需的所有信息(包括如何处理间隔变化......也许并不完全表明你可以使用几乎无限的间隔来Enabled = false......但是这应该有点容易)。你应该edit这篇文章,并附上你在评论中提供的信息,以便重新打开它......但我担心它看起来像“为我写代码”(我肯定会这样看你问其他人进行编辑)。单独用于经过适当测试的生产就绪异步计时器的代码可能会突破 SO 答案的界限......
  • @AlexeiLevenkov 我的问题不是关于如何编写一个新类,而是关于如何通过实现扩展方法使现有的System.Timers.Timer 类可等待。这在我的问题中明确说明。你确定我可以通过应用重复问题的single answer 中提供的知识来实现​​这个目标吗?您可能会争辩说我的问题缺乏我的努力,但我向您保证,我确实在发布之前努力搜索重复...
  • 我想我理解你想要实现的目标,但它似乎也改变了计时器的整个前提(实际上它甚至不再是计时器)它是一个共享的 task.delay。这会更容易从头开始编写(IMO)。但是,您可能可以使用扩展方法和 TaskCompletionSource 以及一些线程安全性来侥幸逃脱,尽管这似乎有点做作
  • @TheodorZoulias 抱歉,我完全误解了你的问题。现在我看到了你根据答案提出的问题......对我来说你为什么要这样做仍然没有意义......但我想你有你的理由。

标签: c# .net timer async-await


【解决方案1】:

看来我必须编写一个返回TaskAwaiter的扩展方法,但我不确定具体要做什么。

返回等待者的最简单方法是获取Task,然后在其上调用GetAwaiter。您也可以创建自定义等待者,但这涉及更多。

所以问题变成了“我如何获得在引发事件时完成的任务?”答案是use TaskCompletionSource&lt;T&gt;

public static class TimerExtensions
{
    public static Task<DateTime> NextEventAsync(this Timer timer)
    {
        var tcs = new TaskCompletionSource<DateTime>();
        ElapsedEventHandler handler = null;
        handler = (_, e) =>
        {
            timer.Elapsed -= handler;
            tcs.TrySetResult(e.SignalTime);
        };
        timer.Elapsed += handler;
        return tcs.Task;
    }

    public static TaskAwaiter<DateTime> GetAwaiter(this Timer timer)
    {
        return timer.NextEventAsync().GetAwaiter();
    }
}

因此,这将使您的示例代码按预期工作。但是,有一个重要的警告:每个await 都会调用GetAwaiter,它订阅下一个Elapsed 事件。并且 Elapsed 事件处理程序在 await 完成之前被删除。因此,从事件触发到下次等待计时器时,没有 处理程序,您的消费代码很容易错过一些事件。

如果这是不可接受的,那么您应该使用IObservable&lt;T&gt;,它是围绕订阅然后接收事件模型设计的,或者使用像 Channels 这样的东西来缓冲事件并通过异步流使用它们。

【讨论】:

  • 谢谢@StephenCleary!未被注意到的事件的小/罕见机会似乎不是很重要,因为这些是重复发生的事件,不会传达额外的信息。我更担心可能的内存泄漏问题,所以我通过观察托管内存来测试您的代码,同时进行大约 1000 次等待,结果是内存消耗没有增加。所以我认为你的解决方案是完美的!现在我希望我能找到这个东西的一些实际应用。 :-)
【解决方案2】:

Task.Delay 仍然是供您使用的正确构建块。

代码

//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
        }
    }
}

对消费者的使用:

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;

在控制器上的使用:

//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();

结果 [编辑前,当前版本未测试]

等待 0,信号时间:03:56:04.777
等待 1,信号时间:03:56:05.712
等待 2,信号时间:03:56:06.717
等待 3,信号时间:03:56:07.709
等待 4,信号时间:03:56:08.710
等待 5,信号时间:03:56:09.710
等待 6,信号时间:03:56:10.710
等待 7,信号时间:03:56:11.709
等待 8,信号时间:03:56:11.709
等待 9,信号时间:03:56:12.709

正如您在上面看到的,它们都接近 710 毫秒标记,表明这是一个绝对间隔(与调用 DelayNext 时的持续时间无关)


“控制器”有可能与“游走者”一起持有AbsolutePollIntervals 的共享引用。通过对AbsolutePollIntervals 的一些扩展,控制器可以更改间隔和开始时间。也可以创建一个 QueuedPollIntervals 实现,其中控制器将不同的时间间隔排入队列,这些时间间隔由游标者在 DelayNext() 上出列


2020 年 9 月 20 日更新:完成。根据 Theodor 的隐含挑战,我实现了一些“控制器”的想法;)

此版本尚未经过测试,因此仅用于传达想法。此外,任何生产版本都需要更好的并发处理。

【讨论】:

  • 感谢托德的回答。 AbsolutePollIntervals 是一个有趣的类,具有独特的功能。我的问题不是关于具有精确间隔的异步延迟,而是关于以交互方式控制这些间隔的方法。例如,在启动普通Task.Delay(1000) 的等待后,无法更改延迟的持续时间。您可以取消任务,也可以等待它完成。使用可等待的Timer 的想法是通过使用其属性IntervalEnabled 使这个交互式控件成为可能。
  • @TheodorZoulias 明白了。我将进一步扩展我的答案,并将其他好东西留给其他人。
  • 请记住,恕我直言 Stephen Cleary 的 answer 已经 100% 足够了。很难打败它。 :-)
  • @TheodorZoulias 已更新。注意:这个更新版本未经测试,但它传达了这个想法。这对其他人可能更有效,它使用 Task.Delay 而不是 Timer,并且有一些有趣的控件可用于实现新事物。
【解决方案3】:

.NET 6 中引入了一个新类System.Threading.PeriodicTimer 类,它是一个具有恒定周期的轻量级可等待计时器。这是一个例子:

var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
for (int i = 0; i < 10; i++)
{
    await timer.WaitForNextTickAsync();
    Console.WriteLine($"Awaited {i}");
}

滴答间隔在构造函数中配置,不能更改。如果你想改变它,你必须创建一个新的PeriodicTimer实例。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-10-18
    • 1970-01-01
    • 2019-02-16
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多