同步定时器以防止重叠

30
我正在编写一个Windows服务,定期(扫描并更新数据库)执行可变长度的活动。我需要该任务频繁运行,但处理代码不能安全地同时运行多次。
如何最简单地设置计时器,在每30秒运行该任务而不重叠执行?(我假设System.Threading.Timer是正确的计时器,但可能是错的)。

我不知道你是否已经解决了这个问题,但使用带锁的Monitor.TryEnter是正确的方法。在间隔时间后,只有当可以获取锁时才会执行代码。只有在线程没有执行你的代码时,你才能获得锁。 - Herman Van Der Blom
7个回答

40

你可以使用一个定时器来完成这个任务,但需要在数据库扫描和更新时进行某种形式的锁定。一个简单的 lock 同步可能足以防止多次运行。

话虽如此,最好是在操作完成后再启动定时器,并仅使用一次,然后停止它。在下一次操作之后重新启动它。这样将会给你30秒(或N秒)的事件间隔,没有重叠的机会,也不需要锁定。

例如:

System.Threading.Timer timer = null;

timer = new System.Threading.Timer((g) =>
  {
      Console.WriteLine(1); //do whatever

      timer.Change(5000, Timeout.Infinite);
  }, null, 0, Timeout.Infinite);

立即工作...完成...等待5秒钟...立即工作...完成...等待5秒钟...


3
我支持这种方法——“最好在操作完成后再启动定时器...” - hitec
4
我支持这种方法。你还可以动态地计算计时器延迟时间,以接近30秒。禁用计时器,获取系统时间,更新数据库,然后初始化下一个计时器在保存的时间之后30秒触发。要设置最小定时器延迟时间以确保安全。 - mghie
我们如何确保操作在5秒内完成?点赞总数超过 @jsw,但它看起来更有效。 - Nuri YILMAZ
1
为什么这个解决方案这么难找,我花了好几个小时。 - gameon67
如果我刷新页面,它会重叠! - mohamed elyamani

29

我会在你的经过时间的代码中使用 Monitor.TryEnter:

if (Monitor.TryEnter(lockobj))
{
  try
  {
    // we got the lock, do your work
  }
  finally
  {
     Monitor.Exit(lockobj);
  }
}
else
{
  // another elapsed has the lock
}

19

我更喜欢使用System.Threading.Timer来处理这种事情,因为我不需要经过事件处理机制:

Timer UpdateTimer = new Timer(UpdateCallback, null, 30000, 30000);

object updateLock = new object();
void UpdateCallback(object state)
{
    if (Monitor.TryEnter(updateLock))
    {
        try
        {
            // do stuff here
        }
        finally
        {
            Monitor.Exit(updateLock);
        }
    }
    else
    {
        // previous timer tick took too long.
        // so do nothing this time through.
    }
}

通过将计时器设置为单次触发并在每次更新后重新启动它,可以消除使用锁的需要:

// Initialize timer as a one-shot
Timer UpdateTimer = new Timer(UpdateCallback, null, 30000, Timeout.Infinite);

void UpdateCallback(object state)
{
    // do stuff here
    // re-enable the timer
    UpdateTimer.Change(30000, Timeout.Infinite);
}

@RollerCosta 请解释一下您为什么认为Option 2会创建多个线程。从我所读的和我的经验来看,它并不会这样做。计时器是单次触发的,意味着它只会触发一次。然后在回调函数中,我将其重新启用,同样作为一个单次触发。 - Jim Mischel
这就是我们在Windows服务中使用的,但它没有起作用。重叠没有得到应有的处理。 - Herman Van Der Blom
@HermanVanDerBlom 我提供了两种不同的解决方案。您的服务中采用了哪一种,它为何无法工作? - Jim Mischel
我使用锁定。这很有效。启动和停止不是因为如果它被放在Windows服务中并且服务已停止,则计时器可能会再次启动,而已经被处理。我派生了一个Timer类,其中包含一个IsDisposed方法和一个新的Start方法,该方法检查Timer是否已被处理。在此解决方案之后,我使用了锁定,并发现了更好的解决方案。情况并不是它被阻止了,而是现在不能有重叠,因此启动/停止不是必要的,感觉不对。 - Herman Van Der Blom

4
从.NET 6开始,有一个新的计时器可用,即PeriodicTimer。这是一个轻量级的异步启用计时器,当严格禁止重叠执行时成为完美工具。您可以通过编写带有循环的异步方法并调用它来启动循环来使用此计时器:
private Task _operation;
private CancellationTokenSource _operationCancellation = new();

//...
_operation = StartTimer();
//...

private async Task StartTimer()
{
    PeriodicTimer timer = new(TimeSpan.FromSeconds(30));
    while (true)
    {
        await timer.WaitForNextTickAsync(_operationCancellation.Token);
        try
        {
            DoSomething();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex);
        }
    }
}

除了使用 CancellationTokenSource,您还可以通过 释放 PeriodicTimer 来停止循环。在这种情况下,await timer.WaitForNextTickAsync() 将返回 false

可能会以小于 30 秒的更短时间间隔连续调用 DoSomething,但不可能以重叠方式调用它,除非您意外启动了两个异步循环。

该计时器不支持禁用和重新启用。如果需要此功能,您可以查看第三方 Nito.AsyncEx.PauseTokenSource 组件。

如果您的目标是 .NET 6 之前的 .NET 版本,则可以查看此问题以获取替代方法:定期运行异步方法并指定时间间隔


2

不要使用锁定(这可能会导致所有的定时扫描等待并最终堆积)。您可以在一个线程中启动扫描/更新,然后只需检查线程是否仍然存在。

Thread updateDBThread = new Thread(MyUpdateMethod);

...

private void timer_Elapsed(object sender, ElapsedEventArgs e)
{
    if(!updateDBThread.IsAlive)
        updateDBThread.Start();
}

是的,但如果您在经过的时间内运行了扫描/更新,则无法掌握它以检查其是否存活。 - Steven Evers

1
您可以按照以下方式使用AutoResetEvent:
// Somewhere else in the code
using System;
using System.Threading;

// In the class or whever appropriate
static AutoResetEvent autoEvent = new AutoResetEvent(false);

void MyWorkerThread()
{
   while(1)
   {
     // Wait for work method to signal.
        if(autoEvent.WaitOne(30000, false))
        {
            // Signalled time to quit
            return;
        }
        else
        {
            // grab a lock
            // do the work
            // Whatever...
        }
   }
}

一个稍微“更智能”的解决方案如下所示的伪代码:
using System;
using System.Diagnostics;
using System.Threading;

// In the class or whever appropriate
static AutoResetEvent autoEvent = new AutoResetEvent(false);

void MyWorkerThread()
{
  Stopwatch stopWatch = new Stopwatch();
  TimeSpan Second30 = new TimeSpan(0,0,30);
  TimeSpan SecondsZero = new TimeSpan(0);
  TimeSpan waitTime = Second30 - SecondsZero;
  TimeSpan interval;

  while(1)
  {
    // Wait for work method to signal.
    if(autoEvent.WaitOne(waitTime, false))
    {
        // Signalled time to quit
        return;
    }
    else
    {
        stopWatch.Start();
        // grab a lock
        // do the work
        // Whatever...
        stopwatch.stop();
        interval = stopwatch.Elapsed;
        if (interval < Seconds30)
        {
           waitTime = Seconds30 - interval;
        }
        else
        {
           waitTime = SecondsZero;
        }
     }
   }
 }

两者之一的优点是,您可以通过发出信号来关闭线程。


编辑

我应该补充说明的是,这段代码假定您只运行一个MyWorkerThreads(),否则它们将会并发运行。


1

当我想要单一执行时,我使用了互斥锁:

    private void OnMsgTimer(object sender, ElapsedEventArgs args)
    {
        // mutex creates a single instance in this application
        bool wasMutexCreatedNew = false;
        using(Mutex onlyOne = new Mutex(true, GetMutexName(), out wasMutexCreatedNew))
        {
            if (wasMutexCreatedNew)
            {
                try
                {
                      //<your code here>
                }
                finally
                {
                    onlyOne.ReleaseMutex();
                }
            }
        }

    }

抱歉我来晚了...您需要在GetMutexName()方法调用中提供互斥锁名称。

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