C# 核心 - Task.Wait(int Timeout) 未按预期等待

C# Core - Task.Wait(int Timeout) not awaiting as expected

我正在尝试为不支持 CancellationTokens 或预定义超时的 WCF 调用实现超时机制。为此,我创建了一个小项目并提出了这个结构:

       var st = Stopwatch.StartNew();

       try
       {
           var responseTask = Task.Run(() =>
           {
               var task = new WaitingService.ServiceClient().GetDataAsync(60000);
               if (!task.Wait(1000))
               {
                   return null;
               }
               return task.Result;
           });
           await responseTask;
       }
       catch { }
       return Ok("Awaited for " + st.ElapsedMilliseconds + "ms. Supposed to be " + sleep);

当我 运行 在我的本地机器上按顺序执行此代码(一次调用 1 个)时,输出非常非常 接近 1000,缺失大约 10 到 50 毫秒,这是 100% 可以接受的。
但是,如果我 运行 以并发方式进行此操作,假设一次有 5 个请求,它开始下滑,达到 100 毫秒...如果我 运行 以 25 个并发请求进行此操作,我开始看到以秒为单位的滑动,当我 运行 高于 35 时,滑动优于 10 秒(此时我将睡眠时间增加到 60 秒,因为服务在框架可以 "notice" 超时之前返回)

谁能告诉我这是怎么回事?为什么 "slip" 会发展到如此程度?对于我想要实现的目标,是否有更可靠的实现?

详情:
服务很简单:

public string GetData(int value)
{
    Console.WriteLine("Will sleep for " + value);
    Thread.Sleep(value);
    return string.Format("Slept for: {0}ms", value);
}

编辑 1
我也测试了这个场景:

       var st = Stopwatch.StartNew();
       CancellationTokenSource src = new CancellationTokenSource(1000);

       try
       {
           var responseTask = Task.Run(() =>
           {
               var task = new WaitingService.ServiceClient().GetDataAsync(sleep);
               if (!task.Wait(1000,src.Token))
               {
                   return null;
               }
               task.Wait(src.Token);
               return task.Result;
           });
           await responseTask;
       }
       catch { }
       return Ok("Awaited for " + st.ElapsedMilliseconds + "ms. Supposed to be " + sleep);

但我实际上得到了更糟糕的结果...滑得更厉害...

编辑 2:
下面的实现得到了更好的结果,50 个并发请求很少超过 2 秒!

var st = Stopwatch.StartNew();

try
{
    var responseTask = Task.Run(async () =>
    {
        var task = new WaitingService.ServiceClient().GetDataAsync(sleep);
        do
        {
            await Task.Delay(50);
        }while (task.Status == TaskStatus.Running || st.ElapsedMilliseconds < 1000);
        if (task.Status == TaskStatus.RanToCompletion)
        {
            return task.Result;
        }
        else { return null; }
    });
    await responseTask;
}
catch { }
return Ok("Awaited for " + st.ElapsedMilliseconds + "ms. Supposed to be " + sleep);

假设您的进程中有 10 个线程,线程以循环方式调度,每个线程的执行时间仅为 2 毫秒。假设如果所有 10 个线程都在第 0 毫秒启动并且 StopWatch 的 20 毫秒已经过去,每个线程只有 4 毫秒的执行时间,剩下的 16 毫秒,每个线程将等待轮到它执行.因此,如果您使用特定超时值将特定线程阻塞一定数量的 ms,这并不意味着线程执行将在指定时间内完成。线程完成执行的实际时间,包括线程等待获取下一个执行周期的时间。

因此,当您调用 task.Wait(1000) 时,线程将被阻塞其执行时间的 1000 毫秒,但秒表运行时间不会被阻塞

正如其他人在评论中所解释的那样,原始 async 代码片段被 Task.WaitTask.Result 的用法阻塞。这 can cause issues 不应该这样做。

使用 Task.Run 导致执行时间增加,正如您看到的 threadpool was becoming exhausted 随着顺序调用的数量增加。

如果你想运行一个Task有超时,不阻塞,你可以使用下面的方法:

public static async Task<(bool hasValue, TResult value)> WithTimeout<TResult>(Task<TResult> task, int msTimeout)
{
    using var timeoutCts = new CancellationTokenSource();

    var timeoutTask = Task.Delay(msTimeout, timeoutCts.Token);
    var completedTask = await Task.WhenAny(task, timeoutTask);

    if (completedTask == task)
    {
        timeoutCts.Cancel(); //Cancel timeoutTask
        return (true, await task); //Get result or propagate exception
    }

    //completedTask was our timeoutTask
    return (false, default);
}

此方法将 Task.WhenAnyTask.Delay 结合到 运行 超时任务以及传入的任务参数。如果传入的任务在超时之前完成,结果将为 return编辑。但是,如果超时先完成,(hasValue: false, value: default) 将被 returned。

你的例子的用法:

var task = new WaitingService.ServiceClient().GetDataAsync(sleep);
var result = await WithTimeout(task, 1000);

if (result.hasValue)
{
   //Do something with result.value;
}

请务必注意,由于您的任务不支持取消,因此它将继续 运行。除非在别处处理,否则它可能会引发未捕获的异常。

因为我们正在通过非阻塞等待任务 Task.WhenAny,所以您不应该像在原始示例中看到的那样耗尽线程池。

在您的最后一个示例中,在继续处理结果之前可能会有不必要的额外 0-50 毫秒延迟。当任务在超时期限内完成时,此方法将立即return。