Implementar C # Generic Timeout

157

Estoy buscando buenas ideas para implementar una forma genérica de ejecutar una sola línea (o delegado anónimo) de código con un tiempo de espera.

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

Estoy buscando una solución que se pueda implementar con elegancia en muchos lugares donde mi código interactúa con el código temperamental (que no puedo cambiar).

Además, me gustaría que el código ofensivo de "tiempo de espera agotado" deje de ejecutarse si es posible.

frío
fuente
46
Solo un recordatorio para cualquiera que esté mirando las respuestas a continuación: Muchos de ellos usan Thread.Abort, lo que puede ser muy malo. Lea los diversos comentarios sobre esto antes de implementar Abortar en su código. Puede ser apropiado en ocasiones, pero son raros. Si no comprende exactamente lo que Abort hace o no necesita, implemente una de las soluciones a continuación que no lo utiliza. Son las soluciones que no tienen tantos votos porque no se ajustan a las necesidades de mi pregunta.
chilltemp
Gracias por el asesoramiento. +1 voto.
QueueHammer el
77
Para más detalles sobre los peligros del hilo. Abortar, lea este artículo de Eric Lippert: blogs.msdn.com/b/ericlippert/archive/2010/02/22/…
JohnW

Respuestas:

95

La parte realmente difícil aquí fue matar la tarea de larga duración al pasar el hilo ejecutor de la Acción de regreso a un lugar donde podría abortarse. Logré esto con el uso de un delegado envuelto que pasa el hilo para matar a una variable local en el método que creó el lambda.

Presento este ejemplo, para su disfrute. El método que realmente le interesa es CallWithTimeout. Esto cancelará el hilo de larga ejecución abortándolo y tragándose la ThreadAbortException :

Uso:

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);
    }

El método estático que hace el trabajo:

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

}
TheSoftwareJedi
fuente
3
¿Por qué la captura (ThreadAbortException)? AFAIK realmente no puede atrapar una ThreadAbortException (se volverá a lanzar cuando se deje el bloque de captura).
csgero
12
Thread.Abort () es muy peligroso de usar, no se debe usar con código normal, solo se debe abortar el código que se garantiza que es seguro, como el código Cer.Safe, que usa regiones de ejecución restringidas y controladores seguros. No debe hacerse para ningún código.
Pop Catalin
12
Si bien Thread.Abort () es malo, no es tan malo como un proceso que se descontrola y utiliza cada ciclo de CPU y byte de memoria que tiene la PC. Pero tiene razón al señalar los posibles problemas a cualquier otra persona que pueda pensar que este código es útil.
chilltemp el
24
No puedo creer que esta sea la respuesta aceptada, alguien no debe estar leyendo los comentarios aquí, o la respuesta fue aceptada antes de los comentarios y esa persona no revisa su página de respuestas. Thread.Abort no es una solución, ¡es solo otro problema que debes resolver!
Lasse V. Karlsen
18
Tú eres el que no lee los comentarios. Como dice chilltemp anteriormente, está llamando a un código sobre el que NO tiene control, y quiere que se cancele. No tiene otra opción que Thread.Abort () si quiere que esto se ejecute dentro de su proceso. Tienes razón en que Thread. Abort es malo, pero como dice chilltemp, ¡otras cosas son peores!
TheSoftwareJedi
73

Estamos usando código como este en gran medida en la producción :

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

La implementación es de código abierto, funciona eficientemente incluso en escenarios de computación paralela y está disponible como parte de las Bibliotecas Compartidas de 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);
    }
}

Este código todavía tiene errores, puedes probar con este pequeño programa de prueba:

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

Hay una condición de carrera. Es claramente posible que se genere una ThreadAbortException después de que WaitFor<int>.Run()se llame al método . No encontré una forma confiable de solucionar esto, sin embargo, con la misma prueba no puedo reprobar ningún problema con la respuesta aceptada de TheSoftwareJedi .

ingrese la descripción de la imagen aquí

Rinat Abdullin
fuente
3
Esto es lo que implementé, puede manejar parámetros y valores de retorno, que prefiero y necesito. Gracias Rinat
Gabriel Mongeon
77
¿Qué es [inmutable]?
raklos
2
Solo un atributo que usamos para marcar clases inmutables (Mono Cecil verifica la inmutabilidad en pruebas unitarias)
Rinat Abdullin
9
Este es un punto muerto a punto de ocurrir (me sorprende que aún no lo hayas observado). Su llamada awatchThread.Abort () está dentro de un bloqueo, que también debe adquirirse en el bloque finalmente. Esto significa que mientras el bloque finalmente está esperando el bloqueo (porque el watchThread lo tiene entre Wait () que regresa y Thread.Abort ()), la llamadawatchThread.Abort () también bloqueará indefinidamente la espera del final (que nunca será). Therad.Abort () puede bloquear si se está ejecutando una región protegida de código, lo que causa puntos muertos, consulte: msdn.microsoft.com/en-us/library/ty8d3wta.aspx
trickdev
1
trickdev, muchas gracias. Por alguna razón, la aparición de un punto muerto parece ser muy poco frecuente, pero de todos modos hemos arreglado el código :-)
Joannes Vermorel
15

Bueno, podría hacer cosas con los delegados (BeginInvoke, con una devolución de llamada configurando un indicador, y el código original esperando ese indicador o tiempo de espera), pero el problema es que es muy difícil cerrar el código en ejecución. Por ejemplo, matar (o pausar) un hilo es peligroso ... así que no creo que haya una manera fácil de hacerlo de manera robusta.

Publicaré esto, pero tenga en cuenta que no es lo ideal: no detiene la tarea de larga duración y no se limpia correctamente en caso de falla.

    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();
        }
    }
Marc Gravell
fuente
2
Estoy perfectamente feliz matando algo que me ha ido mal. Todavía es mejor que dejar que coma ciclos de CPU hasta el próximo reinicio (esto es parte de un servicio de Windows).
chilltemp
@Marc: Soy un gran admirador tuyo. Pero, esta vez, me pregunto, por qué no usaste result.AsyncWaitHandle como lo menciona TheSoftwareJedi. ¿Alguna ventaja de usar ManualResetEvent sobre AsyncWaitHandle?
Anand Patel
1
@ Y bueno, esto fue hace algunos años, así que no puedo responder de memoria, pero "fácil de entender" cuenta mucho en código roscado
Marc Gravell
13

Algunos cambios menores a la gran respuesta de Pop Catalin:

  • Func en lugar de acción
  • Lanzar excepción en mal valor de tiempo de espera
  • Llamar a EndInvoke en caso de tiempo de espera

Se han agregado sobrecargas para apoyar al trabajador de señalización para cancelar la ejecución:

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
}
George Tsiokos
fuente
Invocar (e => {// ... if (error) e.Cancel = true; return 5;}, TimeSpan.FromSeconds (5));
George Tsiokos
1
Vale la pena señalar que en esta respuesta el método de "tiempo de espera agotado" se deja en ejecución a menos que se pueda modificar para elegir cortésmente salir cuando se marca con "cancelar".
David Eison
David, para eso se creó específicamente el tipo CancellationToken (.NET 4.0). En esta respuesta, usé CancelEventArgs para que el trabajador pudiera sondear args. Cancelar para ver si debería salir, aunque esto debería volver a implementarse con CancellationToken para .NET 4.0.
George Tsiokos
Una nota de uso sobre esto que me confundió por un momento: necesita dos bloques try / catch si su código de Función / Acción puede arrojar una excepción después del tiempo de espera. Necesita una prueba / captura alrededor de la llamada a Invoke para detectar TimeoutException. Necesita un segundo dentro de su Función / Acción para capturar y tragar / registrar cualquier excepción que pueda ocurrir después de los tiempos de espera. De lo contrario, la aplicación finalizará con una excepción no controlada (mi caso de uso es probar ping una conexión WCF en un tiempo de espera más estricto que el especificado en app.config)
fiat
Absolutamente, dado que el código dentro de la función / acción puede lanzar, debe estar dentro de un try / catch. Por convención, estos métodos no intentan probar / capturar la función / acción. Es un mal diseño para atrapar y descartar la excepción. Al igual que con todo el código asíncrono, corresponde al usuario del método intentar / atrapar.
George Tsiokos
10

Así es como lo haría:

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!");
    }
}
Pop Catalin
fuente
3
esto no detiene la tarea de ejecución
TheSoftwareJedi
2
No es seguro detener todas las tareas, pueden llegar todo tipo de problemas, puntos muertos, pérdida de recursos, corrupción del estado ... No debería hacerse en el caso general.
Pop Catalin
7

Acabo de eliminar esto ahora, por lo que podría necesitar alguna mejora, pero haré lo que quieras. Es una aplicación de consola simple, pero demuestra los principios necesarios.

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.");
        }
    }

}
Jason Jackson
fuente
1
Agradable. Lo único que agregaría es que preferiría lanzar System.TimeoutException en lugar de solo System.Exception
Joel Coehoorn el
Ah, sí: y lo envolvería en su propia clase también.
Joel Coehoorn
2

¿Qué pasa con el uso de 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");
}

fuente
1
Eso notificaría al método de llamada de un problema, pero no anularía el hilo ofensor.
chilltemp
1
No estoy seguro de que sea correcto. No está claro en la documentación qué sucede con el subproceso de trabajo cuando transcurre el tiempo de espera de Unirse.
Matthew Lowe