c#中的超时终止

时间:2024-07-08 13:45:15

在C#中,可以使用CancellationTokenTask的超时机制来实现调用方法时的超时终止。

用Task.Delay(int)模拟耗时操作

        static async Task Main(string[] args)
        {
            using (var cts = new CancellationTokenSource(1 * 1000))
            {
                await doSomething(cts.Token);
            }


            Console.WriteLine("Press any key to end...");
            Console.ReadLine();
            Console.WriteLine();
        }

        static async Task doSomething(CancellationToken cancellationToken)
        {
            try
            {  
                int x = new Random().Next(4, 7);
                Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} 大约需要{x}秒才能执行结束");

                //模拟耗时操作
                await Task.Delay(x * 1000, cancellationToken); 


                Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} 执行doSomething结束");

            }
            catch (OperationCanceledException ex)
            {//如果被取消,则抛出异常
                Console.WriteLine($"{DateTime.Now.ToString("HH:mm:ss.fff")} 超时了:" + ex.Message);
            }

        }

二 

        如果我们要设置超时的方法本身不支持异步或超时参数时,可以通过使用TaskCancellationToken来实现,但这需要一些间接的方式。       

        假设,方法doOperate(定义如下)执行时间较长,我们要在调用它时,设置超时操作。

 
    static bool doOperate(int x, string y, out string error)  
    {  
        // 模拟长时间操作  
        System.Threading.Thread.Sleep(5000); // 假设操作需要5秒  
        error = null; // 假设没有错误  
        Console.WriteLine($"Hello {y}");
        return true; // 假设操作成功  
    }  

        从doOperate的定义中,我们可以看到:doOperate方法并没有设计为异步且不接受超时或取消令牌。

        这种情况下,如果我们想设置在调用doOperate方法时超时,一种常用的方法是将doOperate的调用放在一个单独的任务中,并使用Task.DelayTask.WhenAny来等待doOperate或超时时间结束。

        这里我们可以使用Task.Run来封装doOperate的调用。

        但请注意,这可能会引入线程池的使用,如果doOperate是CPU密集型的操作,可能会影响系统性能。如果doOperate主要是IO操作(比如文件访问或数据库查询),这种方法的影响会比较小。


    static void Main(string[] args)
    {
        MethodX();
    }

    static void MethodX()
    {
        string error = null;
        bool result = CallDoOperateWithTimeout(3*1000, out error);

        if (!result)
        {
            Console.WriteLine($"Operation failed: {error ?? "Timeout occurred."}");
        }
        else
        {
            Console.WriteLine("Operation completed successfully.");
        }
    }

    static bool CallDoOperateWithTimeout(int timeoutMilliseconds, out string error)
    {
        var cts = new CancellationTokenSource(timeoutMilliseconds);
 
        string tempError = null;
        bool iResult = false;

        Task task = Task.Run(() =>
        {
            try
            {
                doOperate(42, "someInput", out tempError);
                iResult = true;
            }
            catch (Exception ex)
            {
                tempError = ex.Message;
                iResult = false;
            }
        }, cts.Token);

        try
        {
            task.Wait(cts.Token); // 等待任务完成或抛出异常  
            error = tempError;
            return iResult;
        }
        catch (OperationCanceledException)
        {
            error = "Operation timed out.";
            return false;
        }
        catch (Exception ex)
        {
            error = ex.Message;
            return false;
        }
    }

        执行后,我们会发现,确实提示”Operation failed: Operation timed out.“,但是也打印了”Hello somInput“——即doOperate方法并没有被终止掉,这是因为:

        doOperate 方法本身并不是真正的异步方法(即,它并没有使用 async 关键字,也没有 await 任何异步操作)。相反,它使用了 Thread.Sleep 来模拟长时间的操作,这是一个阻塞调用,会阻塞执行它的线程直到指定的时间过去。

        当我们从 Main 方法或任何其他同步上下文中启动这个 Task 时,虽然 Task 本身是在一个单独的线程上执行的,但 doOperate 方法内的 Thread.Sleep 会阻塞那个单独的线程。与此同时,Main 方法中的代码会继续执行到 task.Wait(cts.Token),它等待 Task 完成或超时。

        如果 Task(即 doOperate 方法的执行)在超时之前还没有完成(在这个例子中是5秒),CancellationTokenSource 的 Token 将会被触发来请求取消操作。但是,请注意,doOperate 方法内部并没有检查 CancellationToken 的状态,因此它不会提前退出。因此,Thread.Sleep 将会继续执行直到其完成,随后 doOperate 方法将输出 "Hello {y}" 并返回 true

        然而,在 Main 方法中,由于已经超过了超时时间,task.Wait(cts.Token) 会抛出一个 OperationCanceledException(或者,如果 Task 实际上在超时之后完成了,则不会抛出异常,但在这个例子中它不会)。这个异常会被捕获,并且错误消息会被设置为 "Operation timed out."

        要解决这个问题并让 doOperate 方法能够响应取消请求,您需要在 doOperate 方法内部定期检查 CancellationToken 的状态。

        但是,由于 doOperate 使用了 Thread.Sleep,而 Thread.Sleep 是不支持取消的,您需要使用其他方法来模拟异步操作,比如使用 Task.Delay(它可以接受一个 CancellationToken)或者实现您自己的异步逻辑(比如轮询某种条件或等待某个事件)。

        然而,在这个特定的例子中,由于 doOperate 方法是同步的并且使用了 Thread.Sleep,所以我们无法直接让它响应取消请求。

        如果我们想要让 doOperate 能够被取消,我们需要重写doOperate 以使用异步模式,或者找到一种方法来避免在需要响应取消的场景中使用 Thread.Sleep

        如果我们只是想在超时后停止等待 doOperate 的结果,并且不关心 doOperate 方法是否实际完成,那么我们的代码已经按预期工作了,只是 doOperate 会在后台继续执行直到完成。如果我们想要确保 doOperate 在超时后被取消(即停止执行),那么我需要重新设计 doOperate 方法以支持取消。

        重写doOperate 方法,将它转变为一个异步方法,并使用 CancellationToken 来检查是否应该提前退出。

        然而,由于原始的 doOperate 方法使用了 Thread.Sleep 来模拟长时间操作,这是不可取消的,我们需要找到一个替代方案。

一个常见的替代方案是使用 Task.Delay,它是一个可取消的异步延时操作。下面是重写后的 doOperate 方法和相应的调用逻辑:

    /// <summary>
    /// 封装操作结果的类
    /// </summary>
    private class OperationResult
    {
        /// <summary>
        /// 执行成功
        /// </summary>
        public bool Success { get; set; }

        /// <summary>
        /// 执行发生异常时的错误消息
        /// </summary>
        public string Error { get; set; }
    }

    /// <summary>
    /// 异步版本的doOperate方法
    /// </summary>
    /// <param name="x"></param>
    /// <param name="y"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    static async Task<OperationResult> doOperateAsync(int x, string y, CancellationToken cancellationToken)
    {
        try
        {
            // 使用Task.Delay模拟异步操作,该操作支持取消  
            await Task.Delay(5000, cancellationToken);

            // 假设这是耗时的异步操作  
            Console.WriteLine($"Hello {y}");

            return new OperationResult { Success = true, Error = null };
        }
        catch (OperationCanceledException)
        {
            // 如果操作被取消  
            return new OperationResult { Success = false, Error = "Operation was cancelled." };
        }
        catch (Exception ex)
        {
            // 捕获并处理其他可能的异常  
            return new OperationResult { Success = false, Error = ex.Message };
        }
    }

    static async Task Main(string[] args)
    {
        await MethodXAsync();

        Console.WriteLine("press any key to end..."); 
        Console.ReadKey();
    }

    static async Task MethodXAsync()
    {
        //设置超时时间是3秒
        OperationResult result = await CallDoOperateWithTimeoutAsync(3 * 1000);

        if (!result.Success)
        {
            Console.WriteLine($"Operation failed: {result.Error ?? "Timeout occurred."}");
        }
        else
        {
            Console.WriteLine("Operation completed successfully.");
        }
    }

    static async Task<OperationResult> CallDoOperateWithTimeoutAsync(int timeoutMilliseconds)
    {
        var cts = new CancellationTokenSource(timeoutMilliseconds);
        try
        {
            // 注意:这里我们不需要将cts.Token传递给Task.Run,  
            // 因为我们是在等待DoOperateAsync的完成,而不是Task.Run的完成。  
            // Task.Run主要用于在后台线程上执行代码,但在这里我们直接调用异步方法。  
            var result = await doOperateAsync(42, "someInput", cts.Token);
            return result;
        }
        catch (TaskCanceledException)
        {
            return new OperationResult { Success = false, Error = "Operation timed out." };
        }
        catch (Exception ex)
        {
            return new OperationResult { Success = false, Error = ex.Message };
        }
    }

请注意以下几点:

  1. 我将 Main 方法改为异步的,并使用了 await 关键字来等待 MethodXAsync 的完成。这是处理异步程序的常见做法。

  2. CallDoOperateWithTimeoutAsync 方法直接调用 doOperateAsync 并等待其完成,同时处理可能的取消异常和其他异常。doOperateAsync 方法现在是一个返回 OperationResult 实例的异步方法,该实例包含了操作的成功状态和可能的错误消息。

  3. DoOperateAsync 方法现在是一个异步方法,可以在不阻塞当前线程的情况下执行长时间的操作。
    如果 doOperateAsync 中的代码需要在另一个线程上执行(例如,因为它执行了阻塞的 I/O 操作),那么我们可以考虑使用 Task.Run 来封装这部分代码。但是,在这个例子中,Task.Delay 已经是一个异步操作,所以我们不需要额外的线程。

  4. 我创建了一个 OperationResult 类来封装 doOperate 方法的成功状态和可能的错误消息。这样,我们就可以在异步操作完成后返回一个包含这些信息的单一对象。

        现在,当我们运行这个程序时,如果 doOperateAsync 方法在超时之前完成,它将输出 "Hello someInput" 并报告成功。如果超时发生,它将报告超时错误,并且 doOperateAsync 方法中的 Console.WriteLine 将不会被执行(因为 Task.Delay 会被取消)。