实现 C# 泛型超时

158

我正在寻找实现一种通用方法的好思路,以便在超时情况下执行单行代码(或匿名委托)。

TemperamentalClass tc = new TemperamentalClass();
tc.DoSomething();  // normally runs in 30 sec.  Want to error at 1 min

我正在寻找一种解决方案,可以在我的代码与不稳定的代码(无法更改)交互的许多地方优雅地实现。

此外,如果可能的话,我希望能够停止执行导致错误“超时”的代码。


46
提醒一下看到下面答案的人:很多答案使用Thread.Abort,这可能非常危险。在实现Abort之前,请先阅读有关此事的各种评论。它在某些情况下可能是适当的,但这种情况很少见。如果您不完全了解Abort的作用或者不需要它,请实现以下不使用它的解决方案之一。它们的投票数没有那么高,因为它们不符合我的问题要求。 - chilltemp
7
有关线程.Abort的危险性的详细信息,请阅读Eric Lippert的这篇文章:http://blogs.msdn.com/b/ericlippert/archive/2010/02/22/should-i-specify-a-timeout.aspx - JohnW
7个回答

96

这里真正棘手的部分是通过将执行器线程从操作传递回到可以中止它的位置来终止长时间运行的任务。我使用一个包装委托来实现这一点,该委托将要终止的线程传递给创建 lambda 表达式的方法中的局部变量。

为了您的享受,我提交此示例。您真正感兴趣的方法是 CallWithTimeout。 通过中止它并捕获 ThreadAbortException,这将取消长时间运行的线程:

用法:

class Program
{

    static void Main(string[] args)
    {
        //try the five second method with a 6 second timeout
        CallWithTimeout(FiveSecondMethod, 6000);

        //try the five second method with a 4 second timeout
        //this will throw a timeout exception
        CallWithTimeout(FiveSecondMethod, 4000);
    }

    static void FiveSecondMethod()
    {
        Thread.Sleep(5000);
    }

执行工作的静态方法:

    static void CallWithTimeout(Action action, int timeoutMilliseconds)
    {
        Thread threadToKill = null;
        Action wrappedAction = () =>
        {
            threadToKill = Thread.CurrentThread;
            try
            {
                action();
            }
            catch(ThreadAbortException ex){
               Thread.ResetAbort();// cancel hard aborting, lets to finish it nicely.
            }
        };

        IAsyncResult result = wrappedAction.BeginInvoke(null, null);
        if (result.AsyncWaitHandle.WaitOne(timeoutMilliseconds))
        {
            wrappedAction.EndInvoke(result);
        }
        else
        {
            threadToKill.Abort();
            throw new TimeoutException();
        }
    }

}

3
为什么要捕获ThreadAbortException?据我所知,你实际上无法真正捕获ThreadAbortException(当离开catch块时,它将被重新抛出)。 - csgero
12
Thread.Abort() 使用起来非常危险,不应该用于普通代码中。只有那些能够保证安全的代码,如使用 Cer.Safe、受限执行区域和安全句柄的代码才可以被终止。不应该将其用于任何代码中。 - Pop Catalin
12
尽管Thread.Abort()并不好,但它远远没有进程失控使用PC的所有CPU周期和内存那么糟糕。但你指出对于其他可能认为这段代码有用的人存在潜在问题是正确的。 - chilltemp
24
我无法相信这是被接受的答案,可能有人没有阅读这里的评论,或者回答被接受之前就有评论了,那个人没有检查他的回复页面。Thread.Abort不是一个解决方案,它只会带来更多问题需要解决! - Lasse V. Karlsen
18
你没有阅读评论。正如上面chilltemp所说,他正在调用他无法控制的代码,而且希望它中止。如果他想在自己的进程中运行,除了使用Thread.Abort()之外别无选择。你是对的,Thread.Abort很危险,但像chilltemp所说,还有更糟糕的事情发生! - TheSoftwareJedi
显示剩余15条评论

73

我们在生产中大量使用类似以下代码的内容:

var result = WaitFor<Result>.Run(1.Minutes(), () => service.GetSomeFragileResult());

该实现是开源的,即使在并行计算场景中也能高效工作,并作为Lokad共享库的一部分提供。

/// <summary>
/// Helper class for invoking tasks with timeout. Overhead is 0,005 ms.
/// </summary>
/// <typeparam name="TResult">The type of the result.</typeparam>
[Immutable]
public sealed class WaitFor<TResult>
{
    readonly TimeSpan _timeout;

    /// <summary>
    /// Initializes a new instance of the <see cref="WaitFor{T}"/> class, 
    /// using the specified timeout for all operations.
    /// </summary>
    /// <param name="timeout">The timeout.</param>
    public WaitFor(TimeSpan timeout)
    {
        _timeout = timeout;
    }

    /// <summary>
    /// Executes the spcified function within the current thread, aborting it
    /// if it does not complete within the specified timeout interval. 
    /// </summary>
    /// <param name="function">The function.</param>
    /// <returns>result of the function</returns>
    /// <remarks>
    /// The performance trick is that we do not interrupt the current
    /// running thread. Instead, we just create a watcher that will sleep
    /// until the originating thread terminates or until the timeout is
    /// elapsed.
    /// </remarks>
    /// <exception cref="ArgumentNullException">if function is null</exception>
    /// <exception cref="TimeoutException">if the function does not finish in time </exception>
    public TResult Run(Func<TResult> function)
    {
        if (function == null) throw new ArgumentNullException("function");

        var sync = new object();
        var isCompleted = false;

        WaitCallback watcher = obj =>
            {
                var watchedThread = obj as Thread;

                lock (sync)
                {
                    if (!isCompleted)
                    {
                        Monitor.Wait(sync, _timeout);
                    }
                }
                   // CAUTION: the call to Abort() can be blocking in rare situations
                    // http://msdn.microsoft.com/en-us/library/ty8d3wta.aspx
                    // Hence, it should not be called with the 'lock' as it could deadlock
                    // with the 'finally' block below.

                    if (!isCompleted)
                    {
                        watchedThread.Abort();
                    }
        };

        try
        {
            ThreadPool.QueueUserWorkItem(watcher, Thread.CurrentThread);
            return function();
        }
        catch (ThreadAbortException)
        {
            // This is our own exception.
            Thread.ResetAbort();

            throw new TimeoutException(string.Format("The operation has timed out after {0}.", _timeout));
        }
        finally
        {
            lock (sync)
            {
                isCompleted = true;
                Monitor.Pulse(sync);
            }
        }
    }

    /// <summary>
    /// Executes the spcified function within the current thread, aborting it
    /// if it does not complete within the specified timeout interval.
    /// </summary>
    /// <param name="timeout">The timeout.</param>
    /// <param name="function">The function.</param>
    /// <returns>result of the function</returns>
    /// <remarks>
    /// The performance trick is that we do not interrupt the current
    /// running thread. Instead, we just create a watcher that will sleep
    /// until the originating thread terminates or until the timeout is
    /// elapsed.
    /// </remarks>
    /// <exception cref="ArgumentNullException">if function is null</exception>
    /// <exception cref="TimeoutException">if the function does not finish in time </exception>
    public static TResult Run(TimeSpan timeout, Func<TResult> function)
    {
        return new WaitFor<TResult>(timeout).Run(function);
    }
}

这段代码仍然存在漏洞,您可以尝试使用此小型测试程序:

      static void Main(string[] args) {

         // Use a sb instead of Console.WriteLine() that is modifying how synchronous object are working
         var sb = new StringBuilder();

         for (var j = 1; j < 10; j++) // do the experiment 10 times to have chances to see the ThreadAbortException
         for (var ii = 8; ii < 15; ii++) {
            int i = ii;
            try {

               Debug.WriteLine(i);
               try {
                  WaitFor<int>.Run(TimeSpan.FromMilliseconds(10), () => {
                     Thread.Sleep(i);
                     sb.Append("Processed " + i + "\r\n");
                     return i;
                  });
               }
               catch (TimeoutException) {
                  sb.Append("Time out for " + i + "\r\n");
               }

               Thread.Sleep(10);  // Here to wait until we get the abort procedure
            }
            catch (ThreadAbortException) {
               Thread.ResetAbort();
               sb.Append(" *** ThreadAbortException on " + i + " *** \r\n");
            }
         }

         Console.WriteLine(sb.ToString());
      }
   }

存在竞态条件。很明显,在调用方法WaitFor<int>.Run()之后可能会引发ThreadAbortException异常。我没有找到可靠的解决方法,但是使用相同的测试,我无法重现任何问题,而TheSoftwareJedi的答案被接受。

enter image description here


3
我已经实现了这个,它可以处理参数和返回值,这是我所需要的。谢谢 Rinat。 - Gabriel Mongeon
2
只是一个我们用来标记不可变类的属性(不可变性在单元测试中由Mono Cecil进行验证)。 - Rinat Abdullin
9
这是一个即将发生死锁的情况(我很惊讶你还没有观察到)。你对watchedThread.Abort() 的调用位于一个需要在finally块中获取的锁内。这意味着当finally块正在等待锁时(因为watchedThread在Wait()返回和Thread.Abort()之间持有它),watchedThread.Abort() 调用也会无限期地阻塞等待finally完成(然而finally永远不会完成)。如果正在运行受保护的代码区域,Therad.Abort() 可能会阻塞 - 导致死锁,请参见- http://msdn.microsoft.com/en-us/library/ty8d3wta.aspx - trickdev
1
@RinatAbdullin 这真的很棒。我在我们的代码库中找到了这个归功于你;我想它被复制并粘贴到了许多代码库中!如果我将其转化为一个微不足道的NuGet包,以便其他人可以在不进行复制粘贴的情况下使用它,你会感觉如何? - Brondahl
1
也许使用 TimeSpan.FromMinutes(1) 可以减少混淆?或者也可以移除 [Immutable] - Hugh Jeffner
显示剩余7条评论

15

你可以使用委托(BeginInvoke,使用回调设置标志-原始代码等待该标志或超时)来完成一些操作,但问题是很难关闭正在运行的代码。例如,杀死(或暂停)线程是危险的...所以我认为没有一种简单的方法可以做到这一点,使其更加稳定。

我会发布这个内容,但请注意它并不理想-它不能停止长时间运行的任务,并且在失败时无法正确清理。

    static void Main()
    {
        DoWork(OK, 5000);
        DoWork(Nasty, 5000);
    }
    static void OK()
    {
        Thread.Sleep(1000);
    }
    static void Nasty()
    {
        Thread.Sleep(10000);
    }
    static void DoWork(Action action, int timeout)
    {
        ManualResetEvent evt = new ManualResetEvent(false);
        AsyncCallback cb = delegate {evt.Set();};
        IAsyncResult result = action.BeginInvoke(cb, null);
        if (evt.WaitOne(timeout))
        {
            action.EndInvoke(result);
        }
        else
        {
            throw new TimeoutException();
        }
    }
    static T DoWork<T>(Func<T> func, int timeout)
    {
        ManualResetEvent evt = new ManualResetEvent(false);
        AsyncCallback cb = delegate { evt.Set(); };
        IAsyncResult result = func.BeginInvoke(cb, null);
        if (evt.WaitOne(timeout))
        {
            return func.EndInvoke(result);
        }
        else
        {
            throw new TimeoutException();
        }
    }

2
我很乐意杀掉失控的程序。这比让它占用 CPU 周期直到下一次重启要好得多(这是 Windows 服务的一部分)。 - chilltemp
@Marc:我是你的忠实粉丝。但是这次我想知道,为什么你没有像TheSoftwareJedi提到的那样使用result.AsyncWaitHandle呢?使用ManualResetEvent比AsyncWaitHandle有什么好处吗? - Anand Patel
1
@Anand 嗯,这是几年前的事了,所以我无法从记忆中回答 - 但在线程代码中,“易于理解”很重要。 - Marc Gravell

13

对Pop Catalin的优秀回答进行了一些小改动:

  • 使用Func代替Action
  • 在超时值不良的情况下抛出异常
  • 在超时情况下调用EndInvoke

已添加重载以支持通知工作线程取消执行:

public static T Invoke<T> (Func<CancelEventArgs, T> function, TimeSpan timeout) {
    if (timeout.TotalMilliseconds <= 0)
        throw new ArgumentOutOfRangeException ("timeout");

    CancelEventArgs args = new CancelEventArgs (false);
    IAsyncResult functionResult = function.BeginInvoke (args, null, null);
    WaitHandle waitHandle = functionResult.AsyncWaitHandle;
    if (!waitHandle.WaitOne (timeout)) {
        args.Cancel = true; // flag to worker that it should cancel!
        /* •————————————————————————————————————————————————————————————————————————•
           | IMPORTANT: Always call EndInvoke to complete your asynchronous call.   |
           | http://msdn.microsoft.com/en-us/library/2e08f6yc(VS.80).aspx           |
           | (even though we arn't interested in the result)                        |
           •————————————————————————————————————————————————————————————————————————• */
        ThreadPool.UnsafeRegisterWaitForSingleObject (waitHandle,
            (state, timedOut) => function.EndInvoke (functionResult),
            null, -1, true);
        throw new TimeoutException ();
    }
    else
        return function.EndInvoke (functionResult);
}

public static T Invoke<T> (Func<T> function, TimeSpan timeout) {
    return Invoke (args => function (), timeout); // ignore CancelEventArgs
}

public static void Invoke (Action<CancelEventArgs> action, TimeSpan timeout) {
    Invoke<int> (args => { // pass a function that returns 0 & ignore result
        action (args);
        return 0;
    }, timeout);
}

public static void TryInvoke (Action action, TimeSpan timeout) {
    Invoke (args => action (), timeout); // ignore CancelEventArgs
}

Invoke(e => { // ... if (error) e.Cancel = true; return 5; }, TimeSpan.FromSeconds(5)); - George Tsiokos
1
值得指出的是,在这个答案中,“超时”方法会一直运行,除非它被修改为在标记为“取消”时可以礼貌地选择退出。 - David Eison
David,这就是 CancellationToken 类型(.NET 4.0)专门创建的目的。在这个答案中,我使用了 CancelEventArgs,以便工作者可以轮询 args.Cancel 来查看是否应该退出,尽管这应该使用 .NET 4.0 的 CancellationToken 重新实现。 - George Tsiokos
关于此问题的一个使用提示让我有些困惑了一会儿:如果您的Function/Action代码可能在超时后抛出异常,则需要两个try/catch块。您需要在调用Invoke周围放置一个try/catch块来捕获TimeoutException。您需要一个第二个try/catch块,位于您的Function/Action内部,以捕获和忽略/记录任何可能在超时后发生的异常。否则,应用程序将以未处理的异常终止(我的用例是在比app.config中指定的更严格的超时时间下进行ping测试WCF连接)。 - fiat
当然可以 - 因为函数/操作内部的代码可能会抛出异常,所以必须放在try/catch块中。按照惯例,这些方法不尝试try/catch函数/操作。捕获并丢弃异常是一种糟糕的设计。与所有异步代码一样,由方法的用户来尝试try/catch。 - George Tsiokos

10

我会这样做:

public static class Runner
{
    public static void Run(Action action, TimeSpan timeout)
    {
        IAsyncResult ar = action.BeginInvoke(null, null);
        if (ar.AsyncWaitHandle.WaitOne(timeout))
            action.EndInvoke(ar); // This is necesary so that any exceptions thrown by action delegate is rethrown on completion
        else
            throw new TimeoutException("Action failed to complete using the given timeout!");
    }
}

3
这不会停止正在执行的任务。 - TheSoftwareJedi
2
并非所有的任务都可以安全停止,各种问题可能会出现,如死锁、资源泄漏、状态损坏等。在一般情况下不应该这样做。 - Pop Catalin

7

我刚刚完成了这个,可能需要改进,但可以满足你的需求。它是一个简单的控制台应用程序,但演示了所需的原则。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;


namespace TemporalThingy
{
    class Program
    {
        static void Main(string[] args)
        {
            Action action = () => Thread.Sleep(10000);
            DoSomething(action, 5000);
            Console.ReadKey();
        }

        static void DoSomething(Action action, int timeout)
        {
            EventWaitHandle waitHandle = new EventWaitHandle(false, EventResetMode.ManualReset);
            AsyncCallback callback = ar => waitHandle.Set();
            action.BeginInvoke(callback, null);

            if (!waitHandle.WaitOne(timeout))
                throw new Exception("Failed to complete in the timeout specified.");
        }
    }

}

1
不错。我唯一想补充的是,他可能更喜欢抛出 System.TimeoutException 而不仅仅是 System.Exception。 - Joel Coehoorn
哦,是的:并且我也会将它包装在自己的类中。 - Joel Coehoorn

2
使用Thread.Join(int timeout)怎么样?
public static void CallWithTimeout(Action act, int millisecondsTimeout)
{
    var thread = new Thread(new ThreadStart(act));
    thread.Start();
    if (!thread.Join(millisecondsTimeout))
        throw new Exception("Timed out");
}

1
这将通知调用方法存在问题,但不会中止有错误的线程。 - chilltemp
1
我不确定那是正确的。从文档中也不清楚当Join超时时,工作线程会发生什么。 - Matthew Lowe

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