每隔X分钟在C#中运行一个线程,但仅在该线程尚未运行时才运行。

70
我有一个C#程序,需要每隔 X 分钟分派一个线程,但仅在上次分派的线程(X 分钟之前)没有正在运行时
单纯使用 Timer 是无法解决问题的(因为它会按照固定时间间隔触发事件,无论之前分派的进程是否已完成)。
被分派的进程所需执行的时间变化很大 - 有时可能只需一秒钟,而有时可能需要数小时。如果进程仍在处理上次启动时的任务,则不希望重新启动进程。
有人能提供一些有效的 C# 示例代码吗?

1
哪个计时器类?System.Timers.TimerWindows.Forms.TimerSystem.Threading.Timer - Peter Ritchie
2
Matt Johnson的回答就是你想要的,我不明白你对他的解决方案有什么不喜欢的地方...简单明了。 - Mike Marynowski
别人用TPL做了类似的事情,这是链接:https://dev59.com/lG445IYBdhLWcg3wXZS9 - Keith
2
@KeithPalmer 如果计时器被设置为每5分钟触发一次。它在t=0时第一次触发,与事件相关的进程需要7分钟。因此,在t=7分钟结束。您希望计时器下一次触发的时间是什么时候?在t=7分钟,t=10分钟还是在t=7+5=12分钟? - Cédric Bignon
@KeithPalmer,您可以回答一下我上面的问题吗? 这将有助于我们为您提供正确的解决方案。 - Cédric Bignon
@Cedric Bignon - 我并不在意,只要在前一个事件运行时它不会运行,并且只要它们不能“堆叠”在一起(例如,如果第一个会话需要15分钟,则在完成第一个会话后不应立即运行两个错过的会话)。 - Keith Palmer Jr.
16个回答

62

我认为在这种情况下,可以使用 System.ComponentModel.BackgroundWorker 类,然后每次想要分派(或不分派)新线程时,简单地检查它的 IsBusy 属性。 代码非常简单; 这是一个例子:

class MyClass
{    
    private BackgroundWorker worker;

    public MyClass()
    {
        worker = new BackgroundWorker();
        worker.DoWork += worker_DoWork;
        Timer timer = new Timer(1000);
        timer.Elapsed += timer_Elapsed;
        timer.Start();
    }

    void timer_Elapsed(object sender, ElapsedEventArgs e)
    {
        if(!worker.IsBusy)
            worker.RunWorkerAsync();
    }

    void worker_DoWork(object sender, DoWorkEventArgs e)
    {
        //whatever You want the background thread to do...
    }
}
在这个例子中,我使用了 System.Timers.Timer,但我认为它也适用于其他定时器。BackgroundWorker 类还支持进度报告和取消操作,并使用基于事件驱动的通信模型与调度线程进行通信,因此您不必担心易变的变量等问题...。

编辑

这里是更详细的示例,包括取消和进度报告:

class MyClass
{    
    private BackgroundWorker worker;

    public MyClass()
    {
        worker = new BackgroundWorker()
        {
            WorkerSupportsCancellation = true,
            WorkerReportsProgress = true
        };
        worker.DoWork += worker_DoWork;
        worker.ProgressChanged += worker_ProgressChanged;
        worker.RunWorkerCompleted += worker_RunWorkerCompleted;

        Timer timer = new Timer(1000);
        timer.Elapsed += timer_Elapsed;
        timer.Start();
    }

    void timer_Elapsed(object sender, ElapsedEventArgs e)
    {
        if(!worker.IsBusy)
            worker.RunWorkerAsync();
    }

    void worker_DoWork(object sender, DoWorkEventArgs e)
    {
        BackgroundWorker w = (BackgroundWorker)sender;

        while(/*condition*/)
        {
            //check if cancellation was requested
            if(w.CancellationPending)
            {
                //take any necessary action upon cancelling (rollback, etc.)

                //notify the RunWorkerCompleted event handler
                //that the operation was cancelled
                e.Cancel = true; 
                return;
            }

            //report progress; this method has an overload which can also take
            //custom object (usually representing state) as an argument
            w.ReportProgress(/*percentage*/);

            //do whatever You want the background thread to do...
        }
    }

    void worker_ProgressChanged(object sender, ProgressChangedEventArgs e)
    {
        //display the progress using e.ProgressPercentage and/or e.UserState
    }

    void worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    {
        if(e.Cancelled)
        {
            //do something
        }
        else
        {
            //do something else
        }
    }
}

然后,为了取消进一步的执行,只需调用worker.CancelAsync()。请注意,这是完全由用户处理的取消机制(它不支持线程中止或任何类似的功能)。


@Giorgi 我以前在使用窗体(和窗体的计时器)时经常使用BackgroundWorker,从未遇到任何问题。 - Grx70
2
@Giorgi 我认为你的方法也能够奏效,但是我提供了一个示例,充分利用了 BackgroundWorker 来实现它。 - Grx70
@Giorgi:请注意,您的worker_DoWork()方法已经具有BackgroundWorker实例。这是通过sender参数传递的对象引用。您可以在作者刚刚添加的编辑代码示例中看到这一点。如果您只是要使用它的状态,我认为CancellationToken并没有比普通的volatile bool字段更有用。当处理Task对象的取消时,它更加有用。 - Peter Duniho
1
太棒了,但在“详细示例”中,我不理解“while循环”的目的。 - Basheer AL-MOMANI
1
@BasheerAL-MOMANI 为了在工作进行中报告/取消_"while"_,您需要将其拆分成较小的批次,然后重复检查取消-报告进度-处理批次例程。否则,您只能在完成整个工作之前/之后报告/取消,这是没有意义的。如果您在编译时知道批次数量,当然可以展开循环,但通常情况下不是这种情况。至于while - 这只是一个示例,您也可以使用forforeach或任何其他类型的迭代。 - Grx70

23

你可以通过维护一个易变的布尔型变量来实现你所要求的:

private volatile bool _executing;

private void TimerElapsed(object state)
{
    if (_executing)
        return;

    _executing = true;

    try
    {
        // do the real work here
    }
    catch (Exception e)
    {
        // handle your error
    }
    finally
    {
        _executing = false;
    }
}

2
这是基于OP问题措辞的答案。 - Mike Marynowski
最好使用锁或互锁。请参见:https://dev59.com/VHVC5IYBdhLWcg3w4Vb6 - Umer Azaz
3
这是一种相当安全的使用volatile来防止并发的方法。最好的做法是使用lockInterlock,但是由于时间间隔(几分钟),这种方法足够安全。 - Paul Turner
@Giorgi:就我所知,上述方法在你的情况下也可能是一个合适的解决方案。正如这里的另一个答案所指出的那样,有许多替代方案。关键是选择最适合您需求的方案。 - Peter Duniho
@Giorgi - 很难在没有看到你的代码的情况下作出判断。考虑发布一个新问题。如果我要猜的话,我会说在适当的时候将标志设置回false可能会有一些挑战。 - Matt Johnson-Pint
显示剩余2条评论

10
你可以在定时器的已过时间回调函数中禁用和启用它。
public void TimerElapsed(object sender, EventArgs e)
{
  _timer.Stop();

  //Do Work

  _timer.Start();
}

6
需要注意的是,计时器现在将每次触发的时间间隔变为“(X分钟)+(执行此方法所需的时间)”,而不是每隔X分钟就触发一次并跳过事件仍在执行的时间。 - SomeWritesReserved
所以,只是为了明确 - 这实际上并没有做我想要的事情,对吗?它不是每隔X分钟运行一次,除非已经在运行,而是运行一次,等待X分钟,然后再次运行。对吗? - Keith Palmer Jr.

8

您可以使用System.Threading.Timer,并在处理数据/方法之前将Timeout设置为Infinite,然后当它完成时重新启动Timer以准备下一次调用。

    private System.Threading.Timer _timerThread;
    private int _period = 2000;

    public MainWindow()
    {
        InitializeComponent();

        _timerThread = new System.Threading.Timer((o) =>
         {
             // Stop the timer;
             _timerThread.Change(-1, -1);

             // Process your data
             ProcessData();

             // start timer again (BeginTime, Interval)
             _timerThread.Change(_period, _period);
         }, null, 0, _period);
    }

    private void ProcessData()
    {
        // do stuff;
    }

ProcessData();会在单独的线程中执行(而不是UI线程)吗? - Ajendra Prasad

4
这个问题已经有了很多好的答案,包括一个稍微新一点的答案,它基于TPL的一些特性。但我觉得这里存在一些缺陷:
  1. TPL-based解决方案a) 并没有完全包含在这里,而是指向另一个答案,b) 没有展示如何使用async/await实现单个方法中的定时机制,c) 引用的实现相当复杂,有些混淆了与特定问题相关的关键点。
  2. 这里的原始问题在所需实现的具体参数方面有些模糊(虽然部分在评论中得到澄清)。同时,其他读者可能有类似但不完全相同的需求,没有一个答案涵盖可能需要的各种设计选项。
  3. 我特别喜欢使用Taskasync/await来实现周期性行为,因为它可以简化代码。尤其是async/await特性在处理回调实现细节时非常有价值,将自然、线性逻辑保留在单个方法中。但是没有一个答案展示这种简洁性。

因此,在这个理由的推动下,我要添加另一个答案到这个问题...


对我来说,首先要考虑的是“需要的确切行为是什么?” 这里的问题以一个基本前提开始:即定时器启动的周期任务不应在并发情况下运行,即使该任务比定时器的周期时间更长。但是有多种方式可以满足这个前提条件,包括:

  1. 在执行任务时甚至不运行定时器。
  2. 运行定时器(我要介绍的剩余选项都假设定时器在执行任务期间继续运行),但如果任务的执行时间超过了定时器周期,则在上一个定时器滴答声完成后立即再次运行任务。
  3. 仅在定时器滴答声上启动执行任务。如果任务的执行时间超过了定时器周期,则不会在当前任务执行时启动新任务,甚至在当前任务完成后也不会在下一个定时器滴答声之前启动新任务。
  4. 如果任务的执行时间超过定时器间隔,则不仅在上一个定时器滴答声完成后立即再次运行任务,而且还要根据需要多次运行任务,直到任务已经“赶上”。换句话说,随着时间的推移,尽最大努力执行任务每一个定时器滴答声。

根据评论,我有印象#3选项最接近OP的原始请求,虽然#1选项也可能有效。但对其他人来说,#2和#4选项可能更可取。

在下面的代码示例中,我使用了五种不同的方法来实现这些选项(其中两种实现了选项#3,但方式略有不同)。当然,人们会根据自己的需求选择适当的实现方式。您可能不需要在一个程序中使用所有五种方法! :)
关键点是,在所有这些实现中,它们以一种自然而简单的方式执行任务,以周期性但非并发的方式。也就是说,它们有效地实现了基于计时器的执行模型,同时确保任务始终只由一个线程按照主要请求执行。
此示例还说明了如何使用CancellationTokenSource来中断周期任务,并利用await以一种清晰简单的方式处理基于异常的模型。
class Program
{
    const int timerSeconds = 5, actionMinSeconds = 1, actionMaxSeconds = 7;

    static Random _rnd = new Random();

    static void Main(string[] args)
    {
        Console.WriteLine("Press any key to interrupt timer and exit...");
        Console.WriteLine();

        CancellationTokenSource cancelSource = new CancellationTokenSource();

        new Thread(() => CancelOnInput(cancelSource)).Start();

        Console.WriteLine(
            "Starting at {0:HH:mm:ss.f}, timer interval is {1} seconds",
            DateTime.Now, timerSeconds);
        Console.WriteLine();
        Console.WriteLine();

        // NOTE: the call to Wait() is for the purpose of this
        // specific demonstration in a console program. One does
        // not normally use a blocking wait like this for asynchronous
        // operations.

        // Specify the specific implementation to test by providing the method
        // name as the second argument.
        RunTimer(cancelSource.Token, M1).Wait();
    }

    static async Task RunTimer(
        CancellationToken cancelToken, Func<Action, TimeSpan, Task> timerMethod)
    {
        Console.WriteLine("Testing method {0}()", timerMethod.Method.Name);
        Console.WriteLine();

        try
        {
            await timerMethod(() =>
            {
                cancelToken.ThrowIfCancellationRequested();
                DummyAction();
            }, TimeSpan.FromSeconds(timerSeconds));
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine();
            Console.WriteLine("Operation cancelled");
        }
    }

    static void CancelOnInput(CancellationTokenSource cancelSource)
    {
        Console.ReadKey();
        cancelSource.Cancel();
    }

    static void DummyAction()
    {
        int duration = _rnd.Next(actionMinSeconds, actionMaxSeconds + 1);

        Console.WriteLine("dummy action: {0} seconds", duration);
        Console.Write("    start: {0:HH:mm:ss.f}", DateTime.Now);
        Thread.Sleep(TimeSpan.FromSeconds(duration));
        Console.WriteLine(" - end: {0:HH:mm:ss.f}", DateTime.Now);
    }

    static async Task M1(Action taskAction, TimeSpan timer)
    {
        // Most basic: always wait specified duration between
        // each execution of taskAction
        while (true)
        {
            await Task.Delay(timer);
            await Task.Run(() => taskAction());
        }
    }

    static async Task M2(Action taskAction, TimeSpan timer)
    {
        // Simple: wait for specified interval, minus the duration of
        // the execution of taskAction. Run taskAction immediately if
        // the previous execution too longer than timer.

        TimeSpan remainingDelay = timer;

        while (true)
        {
            if (remainingDelay > TimeSpan.Zero)
            {
                await Task.Delay(remainingDelay);
            }

            Stopwatch sw = Stopwatch.StartNew();
            await Task.Run(() => taskAction());
            remainingDelay = timer - sw.Elapsed;
        }
    }

    static async Task M3a(Action taskAction, TimeSpan timer)
    {
        // More complicated: only start action on time intervals that
        // are multiples of the specified timer interval. If execution
        // of taskAction takes longer than the specified timer interval,
        // wait until next multiple.

        // NOTE: this implementation may drift over time relative to the
        // initial start time, as it considers only the time for the executed
        // action and there is a small amount of overhead in the loop. See
        // M3b() for an implementation that always executes on multiples of
        // the interval relative to the original start time.

        TimeSpan remainingDelay = timer;

        while (true)
        {
            await Task.Delay(remainingDelay);

            Stopwatch sw = Stopwatch.StartNew();
            await Task.Run(() => taskAction());

            long remainder = sw.Elapsed.Ticks % timer.Ticks;

            remainingDelay = TimeSpan.FromTicks(timer.Ticks - remainder);
        }
    }

    static async Task M3b(Action taskAction, TimeSpan timer)
    {
        // More complicated: only start action on time intervals that
        // are multiples of the specified timer interval. If execution
        // of taskAction takes longer than the specified timer interval,
        // wait until next multiple.

        // NOTE: this implementation computes the intervals based on the
        // original start time of the loop, and thus will not drift over
        // time (not counting any drift that exists in the computer's clock
        // itself).

        TimeSpan remainingDelay = timer;
        Stopwatch swTotal = Stopwatch.StartNew();

        while (true)
        {
            await Task.Delay(remainingDelay);
            await Task.Run(() => taskAction());

            long remainder = swTotal.Elapsed.Ticks % timer.Ticks;

            remainingDelay = TimeSpan.FromTicks(timer.Ticks - remainder);
        }
    }

    static async Task M4(Action taskAction, TimeSpan timer)
    {
        // More complicated: this implementation is very different from
        // the others, in that while each execution of the task action
        // is serialized, they are effectively queued. In all of the others,
        // if the task is executing when a timer tick would have happened,
        // the execution for that tick is simply ignored. But here, each time
        // the timer would have ticked, the task action will be executed.
        //
        // If the task action takes longer than the timer for an extended
        // period of time, it will repeatedly execute. If and when it
        // "catches up" (which it can do only if it then eventually
        // executes more quickly than the timer period for some number
        // of iterations), it reverts to the "execute on a fixed
        // interval" behavior.

        TimeSpan nextTick = timer;
        Stopwatch swTotal = Stopwatch.StartNew();

        while (true)
        {
            TimeSpan remainingDelay = nextTick - swTotal.Elapsed;

            if (remainingDelay > TimeSpan.Zero)
            {
                await Task.Delay(remainingDelay);
            }

            await Task.Run(() => taskAction());
            nextTick += timer;
        }
    }
}

最后一点说明:我在跟踪一个重复问题时找到了这个Q&A。在那个问题中,与此处不同,OP明确指出他们正在使用System.Windows.Forms.Timer类。当然,这个类主要用于其具有在UI线程中引发Tick事件的好特性。

现在,无论是这里还是那个问题都涉及到在后台线程中实际执行任务,因此该计时器类的UI线程亲和性行为在这些情况下并没有特别的用处。这里的代码是实现匹配“启动后台任务”范例的,但它很容易被更改,使得taskAction委托直接被调用,而不是在Task中运行并等待。使用async/await的好处,除了上面提到的结构优势之外,还在于它保留了从System.Windows.Forms.Timer类中所需的线程亲和性行为。


@Giorgi:看起来你已经成功地使用了这里的其他答案来解决你的最初问题。但是,我想提醒你注意一下这种方法,以防你发现async/await 的实现方式更好。在我看来,它是一个很好的、现代化的替代方案,可以取代老旧而可靠的System.Windows.Forms.Timer。 :) - Peter Duniho

4
使用我在这里发布的PeriodicTaskFactory。
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();

Task task = PeriodicTaskFactory.Start(() =>
{
    Console.WriteLine(DateTime.Now);
    Thread.Sleep(5000);
}, intervalInMilliseconds: 1000, synchronous: true, cancelToken: cancellationTokenSource.Token);

Console.WriteLine("Press any key to stop iterations...");
Console.ReadKey(true);

cancellationTokenSource.Cancel();

Console.WriteLine("Waiting for the task to complete...");

Task.WaitAny(task);

下面的输出显示,即使将间隔设置为1000毫秒,每个迭代也要等到任务操作的工作完成才开始。这是使用可选参数synchronous: true实现的。
Press any key to stop iterations...
9/6/2013 1:01:52 PM
9/6/2013 1:01:58 PM
9/6/2013 1:02:04 PM
9/6/2013 1:02:10 PM
9/6/2013 1:02:16 PM
Waiting for the task to complete...
Press any key to continue . . .

更新

如果您想要使用PeriodicTaskFactory的“跳过事件”行为,只需不使用同步选项并实现类似Bob在这里https://dev59.com/n2cs5IYBdhLWcg3wynDD#18665948所做的Monitor.TryEnter。

Task task = PeriodicTaskFactory.Start(() =>
{
    if (!Monitor.TryEnter(_locker)) { return; }  // Don't let  multiple threads in here at the same time.

    try
    {
        Console.WriteLine(DateTime.Now);
        Thread.Sleep(5000);
    }
    finally
    {
        Monitor.Exit(_locker);
    }

}, intervalInMilliseconds: 1000, synchronous: false, cancelToken: cancellationTokenSource.Token);
< p> PeriodicTaskFactory 的好处在于返回一个可用于所有TPL API的任务,例如 Task.Wait ,延续等。

目标不是在上一个任务完成时开始倒计时下一个任务。目标是每隔X个时间段执行一次操作,但跳过在另一个任务仍在运行时触发的任何迭代。为了满足OP的要求,您的定时应该是每五到六秒执行一次,而不是每六到七秒执行一次。 - Servy
1
我不同意。我读了OP的评论,我的解决方案可以满足他的要求。他的要求是:“我并不在乎,只要它不在上一个事件仍在运行时运行,并且只要它们不能“堆叠”在一起(例如,如果第一个会话需要15分钟,则在完成第一个会话后不应立即运行错过的两个会话)。" - Jim
这条评论针对与您类似的答案,明确表示它并不是被要求的。使用“锁”也是错误的;它不应该“赶上”并运行任何被跳过的迭代;顶部答案是一个有效的答案。它只是确保在另一个正在运行时运行的任何迭代都不会执行任何操作。 - Servy
好吧,根据我上面发布的内容,他的要求实际上是在你所引用的评论之后提出的,因此你引用的评论是一个更早期的要求。截至1月30日,只要下一次迭代不会在前一次正在运行时运行,并且它们不会“堆叠”,他就不在意了。这取代了去年9月你提到的评论。因此,除非原帖作者有所说明,否则我的解决方案是一个可行的替代方案。 - Jim
1
我必须同意Jim的观点。虽然这种方法是解决问题的不同方式,但它仍然解决了问题。某些任务每X毫秒运行一次,并且在前一个任务完成之前不会运行。也许原帖作者不喜欢这种方法,但它值得作为可能实现问题的良好替代方案来考虑。 - Bob Horn

3

在任务完成之前,您可以停止计时器,然后在任务完成后重新启动它,这可以使您的任务按照固定时间间隔周期性执行。

public void myTimer_Elapsed(object sender, EventArgs e)
{
    myTimer.Stop();
    // Do something you want here.
    myTimer.Start();
}

3

有至少20种不同的方法可以完成这个任务,从使用定时器和信号量,到使用易失性变量,再到使用TPL,或者使用像Quartz这样的开源调度工具等等。

创建线程是一个昂贵的操作,所以为什么不只创建一个并让它在后台运行呢?由于它会在大部分时间处于空闲状态,因此对系统没有实际影响。周期性地唤醒它来执行任务,然后在一段时间后让它进入睡眠状态。无论任务需要多长时间,完成后都要等待“waitForWork”时间段后才能开始下一个任务。

    //wait 5 seconds for testing purposes
    static TimeSpan waitForWork = new TimeSpan(0, 0, 0, 5, 0);
    static ManualResetEventSlim shutdownEvent = new ManualResetEventSlim(false);
    static void Main(string[] args)
    {
        System.Threading.Thread thread = new Thread(DoWork);
        thread.Name = "My Worker Thread, Dude";
        thread.Start();

        Console.ReadLine();
        shutdownEvent.Set();
        thread.Join();
    }

    public static void DoWork()
    {
        do
        {
            //wait for work timeout or shudown event notification
            shutdownEvent.Wait(waitForWork);

            //if shutting down, exit the thread
            if(shutdownEvent.IsSet)
                return;

            //TODO: Do Work here


        } while (true);

    }

3
您可以使用System.Threading.Timer。诀窍是仅设置初始时间。当先前的间隔完成或作业完成时,会再次设置初始时间(当作业所需时间超过间隔时会发生这种情况)。以下是示例代码。
class Program
{


    static System.Threading.Timer timer;
    static bool workAvailable = false;
    static int timeInMs = 5000;
    static object o = new object(); 

    static void Main(string[] args)
    {
        timer = new Timer((o) =>
            {
                try
                {
                    if (workAvailable)
                    {
                        // do the work,   whatever is required.
                        // if another thread is started use Thread.Join to wait for the thread to finish
                    }
                }
                catch (Exception)
                {
                    // handle
                }
                finally
                {
                    // only set the initial time, do not set the recurring time
                    timer.Change(timeInMs, Timeout.Infinite);
                }
            });

        // only set the initial time, do not set the recurring time
        timer.Change(timeInMs, Timeout.Infinite);
    }

3
为什么不使用计时器与 Monitor.TryEnter()?如果在上一个线程完成之前再次调用 OnTimerElapsed(),它将被丢弃,并且另一次尝试将不会再发生,直到计时器再次触发。
private static readonly object _locker = new object();

    private void OnTimerElapsed(object sender, ElapsedEventArgs e)
    {
        if (!Monitor.TryEnter(_locker)) { return; }  // Don't let  multiple threads in here at the same time.

        try
        {
            // do stuff
        }
        finally
        {
            Monitor.Exit(_locker);
        }
    }

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