.net 异步任务死锁,使用等待从同步方法和超时运行方法

kwvwclae  于 2023-01-06  发布在  .NET
关注(0)|答案(2)|浏览(217)

我有一个方法定义为:

public Task<ReturnsMessage> Function()
{
    var task = Task.Run(() =>
    {
         var result = SyncMethod();
         return new ReturnMessage(result);
    });
    
    if (task.Wait(delay))
    {
        return task;
    }

    var tcs = new TaskCompletionSource<ReturnMessage>();
    tcs.SetCanceled();
    return tcs.Task;
}

现在,基于maxAttempts值在循环中调用它:
(方法名称可重试调用)

for (var i = 0; i < maxAttempts; i++)
{
    try
    {
        return Function().Result;
    }
    catch (Exception e)
    {
    }
}

它工作得非常好,但是当有大量负载时,我发现线程急剧增加,dump向我显示以下警告:


谁能建议我处理这种情况的最佳方法,这样我就不会看到任何类型的死锁?

dsekswqp

dsekswqp1#

您正在死锁应用程序,因为您没有使用async/awaitConfigureAwait(false,而是选择使用Task.WaitTask.Result
首先,您应该知道Task.Run捕获执行它的线程的SynchronizationContext(调用者线程或父线程)。然后Task.Run在新的ThreadPool线程上执行委托。当完成时,它将返回到父线程以继续执行剩余代码。当返回时,它将返回到所捕获的SyncronizationContextSyncronizationContext仅被捕获用于延续)。
使用Task.WaitTask.Result违反了概念。两个Task成员都将 * 同步 * 调用Task,这意味着:
a)父线程将阻塞自己,直到Task完成。它处于 * busy waiting * 状态,这意味着它除了等待之外不能做任何事情。如果调用是使用async/await * 异步 * 的,那么父线程将继续执行其他操作,而不是等待(例如,在UI线程的情况下的UI相关操作),直到Task发信号通知其完成)。
b)Task完成了,但是不能返回到捕获的SynchronizationContext来执行剩余的代码(Task.Wait之后的代码),因为父线程仍然忙于等待任务运行完成。现在Task必须等待父线程准备就绪(完成等待)以执行继续。
这是最终锁定父线程的互斥情况:父线程忙于等待Task,而Task忙于等待父线程。两者都无限期地等待对方。这就是死锁。如果父线程是主线程,则整个应用程序死锁并挂起。
由于您在一个地方使用了Task.Wait,而在另一个地方使用了Task.Result,因此造成了两种潜在的死锁情况:

public void RetryableInvoke()
{ 
  for (var i = 0; i < maxAttempts; i++)
  {
    try
    {
        // Potential deadlock #1
        return Function().Result;
    }
    catch (Exception e)
    {
    }
  }
}

private Task<ReturnsMessage> Function() 
{
  var task = Task.Run(() =>
  {
    var result = SyncMethod();
    return new ReturnMessage(result);
  });

  // Potential deadlock #2.
  // Task.Wait returns true if the Task completed execution within the allotted time, otherwise, false.
  if (task.Wait(delay)) 
  {
    // task has completed before the delay has expired
    return task;
  }

  // The 'task' has not completed before the delay.
  // Therefore return a cancelled Task object to signal cancellation.
  var tcs = new TaskCompletionSource<ReturnMessage>();
  tcs.SetCanceled();
  return tcs.Task;
}

让我们逐步了解Function()代码:
1.创建任务并启动它:
变量任务=任务.运行(()=〉{变量结果=同步方法();返回新的返回消息(结果); });

  • 重要的事情发生在这里 *

Task.Run捕获当前SynchronizationContext并在后台开始执行lambda表达式,同时父线程继续执行下一条指令(if-语句)。如果此处使用了await Task.Run(),则跟随在await之后的剩余代码将被排队到后续队列中以供稍后执行)。这样做的目的是让当前线程可以暂时返回(离开当前上下文),这样它就不需要等待和阻塞(使用Task.Wait)。一旦子线程运行完成,剩余的代码将由Task执行,因为它之前在继续队列中排队。

  1. task.Wait()等待,直到后台线程完成。等待意味着阻止线程继续执行。调用堆栈已停止。这相当于后台线程的同步,因为父线程不再继续执行,而是阻止:
    //执行task对象超时的危险实现if(task. Wait(delay)){return task;}
  • 重要的事情在这里发生 *

task.Wait()阻塞等待子线程完成的当前线程(SynchronizationContext)。子任务完成后,Task尝试执行捕获的SynchronizationContext中的继续队列中先前排队的剩余代码。但此上下文被等待子任务完成的线程阻塞。潜在死锁情况一。
以下剩余代码将无法访问:

var tcs = new TaskCompletionSource<ReturnMessage>();
tcs.SetCanceled();
return tcs.Task;

引入asyncawait是为了消除阻塞等待。await允许父线程返回并继续。await之后的剩余代码将作为捕获的SynchronizationContext中的延续执行。
这也是使用Task.WhenAny(不首选)的正确任务超时解决方案对第一个死锁的修复:

public async Task<ReturnsMessage> FunctionAsync()
{
  using (var cancellationTokenSource = new CancellationTokenSource())
  {
    try
    {
      var task = Task.Run(
        () =>
        {
          // Check if the task needs to be cancelled
          // because the timeout task ran to completion first
          cancellationToken.ThrowIfCancellationRequested();

          var result = SyncMethod();
          return result;
        }, cancellationTokenSource.Token);

      int delay = 500;
      Task timoutTask = Task.Delay(delay, cancellationTokenSource.Token);
      Task firstCompletedTask = await Task.WhenAny(task, timoutTask);

      if (firstCompletedTask == task)
      {
        // The 'task' has won the race, therefore
        // cancel the 'timeoutTask'
        cancellationTokenSource.Cancel();
        return await task;
      }
    }
    catch (OperationCanceledException)
    {}

    // The 'timeoutTask' has won the race, therefore
    // cancel the 'task' instance
    cancellationTokenSource.Cancel();

    var tcs = new TaskCompletionSource<string>();
    tcs.SetCanceled();
    return await tcs.Task;
  }
}

或者使用CancellationTokenSouce timeout构造函数重载(首选),使用更好的替代超时方法修复第一个死锁:

public async Task<ReturnsMessage> FunctionAsync()
{
  var timeout = 50;
  using (var timeoutCancellationTokenSource = new CancellationTokenSource(timeout))
  {
    try
    {
      return await Task.Run(
        () =>
        {
          // Check if the timeout elapsed
          timeoutCancellationTokenSource.Token.ThrowIfCancellationRequested();

          var result = SyncMethod();
          return result;
        }, timeoutCancellationTokenSource.Token);
    }
    catch (OperationCanceledException)
    {
      var tcs = new TaskCompletionSource<string>();
      tcs.SetCanceled();
      return await tcs.Task;
    }
  }
}

第二个潜在的死锁代码是Function()的消耗:

for (var i = 0; i < maxAttempts; i++)
{
  return Function().Result;
}

来自Microsoft文档:
访问属性的[Task.Result] get访问器将阻塞调用线程,直到异步操作完成; * * 它等效于调用Wait方法**。
死锁的原因与前面解释的相同:阻止所调度的继续的执行的被阻止的SynchronizationContext
要修复第二个死锁,我们可以使用 * async/await *(首选)或ConfigreAwait(false)

for (var i = 0; i < maxAttempts; i++)
{
  return await FunctionAsync();
}

ConfigreAwait(false)。此方法可用于强制同步执行异步方法:

for (var i = 0; i < maxAttempts; i++)
{
  return FunctionAsync().ConfigureAwait(false).GetAwaiter().GetResult();
}

ConfigreAwait(false)指示Task忽略捕获的SynchronizationContext,并在永远不会成为父线程的另一个ThreadPool线程上继续执行继续队列。

00jrzges

00jrzges2#

您使用Task.Run启动任务,如果任务超时,则返回取消,但您从未停止任务。它们只是继续在后台运行。
你的代码应该是async/await,使用CancellationSource,并在SyncMethod()中处理取消标记,但是如果你不能,并且你想异步运行一个方法,并在一段时间后强制终止它,你可能应该使用Threads和Abort threads。
警告:中止线程是不安全的,除非你知道你在做什么,它甚至可能从.NET的未来版本中删除。
其实我之前研究过这个问题:https://siderite.dev/blog/how-to-timeout-task-and-make-sure-it.html

相关问题