在C#中创建“仅运行一次”延迟函数的最佳方法

53
我正在尝试创建一个函数,该函数接收一个Action和一个Timeout,并在Timeout后执行Action。该函数应该是非阻塞的,并且必须是线程安全的。我也非常想避免使用Thread.Sleep()。
到目前为止,我能做到最好的是这样的:
long currentKey = 0;
ConcurrentDictionary<long, Timer> timers = new ConcurrentDictionary<long, Timer>();

protected void Execute(Action action, int timeout_ms)
{
    long currentKey = Interlocked.Increment(ref currentKey);
    Timer t = new Timer(
      (key) =>
         {
           action();
           Timer lTimer;
           if(timers.TryRemove((long)key, out lTimer))
           {
               lTimer.Dispose();
           }
         }, currentKey, Timeout.Infinite, Timeout.Infinite
      );

     timers[currentKey] = t;
     t.Change(timeout_ms, Timeout.Infinite);
}

问题在于从回调函数中调用Dispose()可能不安全。我不确定直接结束是否安全,即使定时器在执行其lambda表达式时仍被认为是活动的,但即使如此,我仍然更愿意妥善处理它。
“延迟一次触发”似乎是一个常见的问题,应该有一种简单的方法来解决这个问题,可能是System.Threading中的某个其他库,但目前我能想到的唯一解决方案是修改上述代码,并在间隔上运行专门的清理任务。您有什么建议吗?

1
我有点看不懂为什么你不用最简单的解决方案来解决这个问题,也就是Thread.Sleep? - jeroenh
你能解释一下为什么你真的、真的想避免使用Thread.Sleep()吗? - Andrew Savinykh
1
使用此代码的任何人请注意!Timer API 中存在一个错误,如果定时器过于细粒度(即在 .Change() 方法之后太快调用 Dispose()),可能会导致未管理的内存泄漏。截至本评论,.NET 4.0 是最新版本,希望在将来的版本中修复此问题。 - Chuu
7
如果只有一两个操作,使用Thread.Sleep可能可行。但是如果你有100个操作呢?你真的想创建100个什么也不做,只睡眠的线程吗?这将浪费100兆字节的堆栈空间(以及其他资源)。 - Jim Mischel
1
@JimMischel 我已经学会了 :-); 感谢您指出。 - jeroenh
12个回答

87

我不知道您正在使用哪个版本的C#。但我认为您可以通过使用Task库来完成此操作。然后它可能看起来像这样。

public class PauseAndExecuter
{
    public async Task Execute(Action action, int timeoutInMilliseconds)
    {
        await Task.Delay(timeoutInMilliseconds);
        action();
    }
}

1
我的问题是在4.5版之前提交的,但是假设Task.Delay不能像在线程池上睡眠那样耗尽某些关键资源,那么这个解决方案绝对是最好的。我仍然在使用.NET 4.0,有没有更熟悉4.5版的人能够确认它没有像许多其他线程池/睡眠解决方案那样存在问题? - Chuu
根据这个问题,Task.Delay在内部使用计时器,因此我认为将其作为正确答案是安全的。 - Chuu
1
哎呀,这就是你能得到的最简单的东西了! - jklemmack
相信返回类型应该是Task,否则void无法被等待。 - Geoffrey Hudik
3
您甚至可以使用以下代码更简单地实现:Task.Delay(timeoutInMilliseconds).ContinueWith(t => yourAction()); - Do-do-new
显示剩余4条评论

31

在 .Net 4 中没有内置的方法可以很好地完成这项任务。 Thread.Sleep 或者 AutoResetEvent.WaitOne(timeout) 都不是好的选择 - 它们会占用线程池资源,我曾经尝试过但遭到失败!

最轻量级的解决方案是使用定时器 - 特别是如果你有很多任务要投放进去。

首先创建一个简单的定时任务类:

class ScheduledTask
{
    internal readonly Action Action;
    internal System.Timers.Timer Timer;
    internal EventHandler TaskComplete;

    public ScheduledTask(Action action, int timeoutMs)
    {
        Action = action;
        Timer = new System.Timers.Timer() { Interval = timeoutMs };
        Timer.Elapsed += TimerElapsed;            
    }

    private void TimerElapsed(object sender, System.Timers.ElapsedEventArgs e)
    {
        Timer.Stop();
        Timer.Elapsed -= TimerElapsed;
        Timer = null;

        Action();
        TaskComplete(this, EventArgs.Empty);
    }
}

然后创建一个调度器类 - 再次,非常简单:

class Scheduler
{        
    private readonly ConcurrentDictionary<Action, ScheduledTask> _scheduledTasks = new ConcurrentDictionary<Action, ScheduledTask>();

    public void Execute(Action action, int timeoutMs)
    {
        var task = new ScheduledTask(action, timeoutMs);
        task.TaskComplete += RemoveTask;
        _scheduledTasks.TryAdd(action, task);
        task.Timer.Start();
    }

    private void RemoveTask(object sender, EventArgs e)
    {
        var task = (ScheduledTask) sender;
        task.TaskComplete -= RemoveTask;
        ScheduledTask deleted;
        _scheduledTasks.TryRemove(task.Action, out deleted);
    }
}

它可以被称为如下 - 并且非常轻量级:

var scheduler = new Scheduler();

scheduler.Execute(() => MessageBox.Show("hi1"), 1000);
scheduler.Execute(() => MessageBox.Show("hi2"), 2000);
scheduler.Execute(() => MessageBox.Show("hi3"), 3000);
scheduler.Execute(() => MessageBox.Show("hi4"), 4000);

我可以在我的程序中使用它吗? - Danon
当然可以,Danon,请记得如果这个答案对你有用的话,给它点赞! - James Harcourt
在调度器类中,Execute方法应该是公共的而不是受保护的。 - Geoffrey Hudik
1
ScheduledTask 中的 ActionTimer 字段应该是 private,而不是 internal。另外,在使用完 Timer 后,应该将其释放。 - vgru
您不必手动停止计时器,有AutoReset属性可以为您完成。 - Alex from Jitbit

6
我使用以下方法来安排特定时间的任务:

我使用这种方法来安排特定时间的任务:

public void ScheduleExecute(Action action, DateTime ExecutionTime)
{
    Task WaitTask = Task.Delay(ExecutionTime.Subtract(DateTime.Now));
    WaitTask.ContinueWith(() => action());
    WaitTask.Start();
}

需要注意的是,由于int32的最大值限制,此方法仅适用于约24天左右。


4
不要将毫秒传递给 Task.Delay(),使用从 Subtract() 方法返回的 TimeSpan - g t
1
Task.Delay(timeoutInMilliseconds).ContinueWith(t => yourAction()); - Do-do-new

5

我的例子:

void startTimerOnce()
{
   Timer tmrOnce = new Timer();
   tmrOnce.Tick += tmrOnce_Tick;
   tmrOnce.Interval = 2000;
   tmrOnce.Start();
}

void tmrOnce_Tick(object sender, EventArgs e)
{
   //...
   ((Timer)sender).Dispose();
}

3

这样对我来说似乎很有效。它允许我在15秒延迟后调用_connection.Start()。 -1毫秒参数只是表示不重复。

// Instance or static holder that won't get garbage collected (thanks chuu)
System.Threading.Timer t;

// Then when you need to delay something
var t = new System.Threading.Timer(o =>
            {
                _connection.Start(); 
            },
            null,
            TimeSpan.FromSeconds(15),
            TimeSpan.FromMilliseconds(-1));

1
如果 t 超出范围,计时器将变得适合进行垃圾回收。如果被回收,计时器将不会触发。 - Chuu
你说得完全正确,我应该明确说明在哪里使用它,并将其声明为类级别的t。我会编辑回复并尝试让它更清晰明了。 - naskew
建议:将“TimeSpan.FromMilliseconds(-1)”替换为“Timeout.Infinite”。 - tkpb

2
如果您不太关心时间的细微差别,可以创建一个定时器,每秒钟触发一次,并检查需要排队到线程池上的已过期操作。只需使用计时器类来检查超时即可。
您可以使用当前的方法,只是您的字典将以Stopwatch为键,Action为值。然后,您只需对所有KeyValuePairs进行迭代,找到过期的Stopwatch,将Action排队,然后删除它。使用LinkedList会获得更好的性能和内存使用情况(因为您每次都需要枚举整个列表,并且删除项目更容易)。

1
这与 CreateTimerQueue api 的工作方式非常相似,这也是 System.Threading.Timers 的封装。 - Chuu
净效果是相同的,只不过你只需要处理一个计时器句柄。 - Garo Yeriazarian

2
使用一次性计时器的模型绝对是正确的方式,你肯定不想为每个计时器创建一个新线程。你可以有一个单独的线程和一个基于时间优先级队列的操作,但这是不必要的复杂性。
在回调函数中调用Dispose可能不是一个好主意,尽管我很想试试。我记得过去做过这件事,并且效果还不错。但它是一种奇怪的做法,我承认。
你可以从集合中删除计时器而不是将其处理掉。没有对该对象的引用,它将有资格进行垃圾回收,这意味着Dispose方法将被终结器调用。只是可能不那么及时。但这不应该成为问题。你只是在短时间内泄漏一个句柄。只要你不长时间地拥有数千个未处理的计时器,这就不会成为问题。
另一个选择是拥有一个定时器队列,保持分配状态,但处于停用状态(即超时和间隔设置为Timeout.Infinite)。当你需要定时器时,从队列中取出一个,设置它并将其添加到你的集合中。当超时到期时,清除计时器并将其放回队列。如果必须,您可以动态增长队列,甚至可以定期修剪它。
这将防止你泄漏每个事件的一个计时器。相反,你会有一个计时器池(就像线程池一样,对吧?)。

2

使用微软的反应式框架 (NuGet "System.Reactive"),你就可以做到这一点:

protected void Execute(Action action, int timeout_ms)
{
    Scheduler.Default.Schedule(TimeSpan.FromMilliseconds(timeout_ms), action);
}

1

2
这里的问题是生命周期管理,而不是定时器本身。具体来说,您必须在定时器触发之前保持对其的引用,否则如果gc收集它,它将被禁用;并且需要在它触发后释放引用,以便gc可以收集它。这是假设您自己管理生命周期的情况,使用“async”解决方案则无需如此。 - Chuu

1

treze的代码运行良好。这可能对那些必须使用较旧的.NET版本的人有所帮助:

private static volatile List<System.Threading.Timer> _timers = new List<System.Threading.Timer>();
private static object lockobj = new object();
public static void SetTimeout(Action action, int delayInMilliseconds)
{
    System.Threading.Timer timer = null;
    var cb = new System.Threading.TimerCallback((state) =>
    {
        lock (lockobj)
            _timers.Remove(timer);
        timer.Dispose();
        action();
    });
    lock (lockobj)
        _timers.Add(timer = new System.Threading.Timer(cb, null, delayInMilliseconds, System.Threading.Timeout.Infinite));
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接