在 MVC 中不等待从同步调用异步时的 TPL 任务死锁

TPL Task deadlock when calling async from sync without await in MVC

我知道在同步 MVC 方法中调用异步方法时存在 TPL 死锁陷阱,同时使用 .Wait() 或 .Result 等待任务完成。

但我们刚刚在我们的 MVC 应用程序中发现了一个奇怪的行为:同步操作调用了一个异步方法,但由于它是一个触发器,我们从未等待它完成。不过,异步方法似乎卡住了。

代码如下,这个奇怪的问题不是100%发生的。它只是偶尔发生。

发生时间:

  1. HomeController.Index() 动作完成
  2. Log.Info("Begin") 已执行。
  3. SaveToDb() 完成了工作,但不知道它是否在完成后挂起。
  4. PublishTomessageQueue() 没有完成这项工作,不知道它是从未启动过还是只是卡在里面。
  5. Log.Info("Finish")/Log.Error("Error") 都没有被调用。

大部分时间,代码都按预期工作。

ISomeInterface.Trigger() 也被从其他地方调用,windows 服务而不是 mvc,但这种奇怪的行为从未发生过。

所以我的问题是,即使 没有 .Wait() 和 .Result,异步任务是否有可能陷入死锁?

非常感谢。

public interface ISomeInterface
{
    Task Trigger();
}

public class SomeClass
{
    public async Task Trigger()
    {
        Log.Info("Begin");

        try
        {
            await SaveToDb();

            await PublishToMessageQueue();

            Log.Info("Finish");
        }
        catch (Exception ex)
        {
            Log.Error("Error");
        }
    }
}

public class HomeController : Controller
{
    public ISomeInterface Some { get; set; }

    public ActionResult Index()
    {

        Some.Trigger(); //<----- The thread is not blocked here.

        return View();
    }


}

would it be possible that async tasks get into deadlock even WITHOUT .Wait() nor .Result?

是的,这是可能的。默认情况下,执行将在 await 之后编组回原始线程。但是,如果线程不可用或由于某种原因被阻塞,则可能会发生死锁。

不确定你的情况是否也是这个问题,但你可以尝试以下操作:

await SaveToDb().ConfigureAwait(false);

await PublishToMessageQueue().ConfigureAwait(false);

ConfigureAwait(false) 告诉运行器状态机可以在任何线程上继续执行。在大多数情况下,这没问题。只有在特殊情况下才需要编组回原始线程(例如 WinForms 或 WPF UI 线程)。

the async method seems stucked... It just happens sometime... Most of time, code works as expected.

Yes. There's a couple of major problems with this code.

First, it can attempt to resume on a request context that no longer exists. For example, the request for Index comes in, and ASP.NET creates a new request context for that thread. It then invokes Index within that request context, and Index calls Some.Trigger, and when Trigger hits its first await, it captures that context by default and returns an incomplete task to Index. Index then returns, notifying ASP.NET that the request is complete; ASP.NET sends the response and then tears down that request context. Later on, Trigger is ready to resume after its await, and attempts to resume on that request context... but it no longer exists (the request has already completed). Pandemonium ensues.

The second major problem is that this is "fire and forget", which is a really bad idea on ASP.NET. It's a bad idea because ASP.NET is designed entirely around a request/response system; it has very limited facilities for working with code that does not exist as part of a request. When there are no active requests, ASP.NET can (and will) periodically recycle your app domain and worker process (this is required to keep things clean). It has absolutely no idea that your Trigger code is 运行 because the request that called it has already completed - thus, your 运行 code can just disappear periodically.

The easiest solution is to move this "trigger" code into an actual request. E.g., Index can await the task returned by Trigger. Or have your page code issue an AJAX call to an API that calls Trigger (and awaits it).

If this isn't doable, then I'd recommend a proper distributed system: have Index place a "trigger request" into a reliable queue and have it processed by an independent backend (e.g., Win32 service). Or you could use an off-the-shelf solution like Hangfire.