ASP.NET控制器:在异步操作仍在挂起时,一个异步模块或处理程序已经完成。

60

我有一个非常简单的ASP.NET MVC 4控制器:

public class HomeController : Controller
{
    private const string MY_URL = "http://smthing";
    private readonly Task<string> task;

    public HomeController() { task = DownloadAsync(); }

    public ActionResult Index() { return View(); }

    private async Task<string> DownloadAsync()
    {
        using (WebClient myWebClient = new WebClient())
            return await myWebClient.DownloadStringTaskAsync(MY_URL)
                                    .ConfigureAwait(false);
    }
}

当我开始这个项目时,我看到我的视图,它看起来很好,但是当我更新页面时,我会得到以下错误:

[InvalidOperationException: 在异步操作仍在进行的情况下完成了异步模块或处理程序。]

为什么会发生这种情况?我进行了一些测试:

  1. 如果我们从构造函数中删除task = DownloadAsync(); 并将其放入Index方法,则可以正常工作而没有错误。
  2. 如果我们使用另一个DownloadAsync()主体return await Task.Factory.StartNew(() => { Thread.Sleep(3000); return "给我一个错误"; });,它将正常工作。

为什么不能在控制器的构造函数中使用WebClient.DownloadStringTaskAsync方法?

8个回答

71
Async Void, ASP.Net和未完成操作计数中,Stephan Cleary解释了这个错误的根本原因:
“历史上,自从.NET 2.0以来,ASP.NET就支持干净的异步操作,通过基于事件的异步模式(EAP),其中异步组件通知SynchronizationContext它们的开始和完成。”
发生的情况是,在类构造函数中触发DownloadAsync,在其中await异步http调用。这将使用ASP.NET SynchronizationContext注册异步操作。当HomeController返回时,它看到有一个未完成的异步操作,因此引发异常。
“如果我们从构造函数中删除task = DownloadAsync();并将其放入Index方法中,则可以正常工作而没有错误。”
正如我上面所解释的那样,这是因为在从控制器返回时不再有未完成的异步操作。
如果我们使用另一个DownloadAsync()体返回await Task.Factory.StartNew(() => { Thread.Sleep(3000); return "Give me an error"; });,它将正常工作。这是因为在ASP.NET中,Task.Factory.StartNew执行了一些危险的操作。它没有将任务的执行注册到ASP.NET中。这可能导致一些边缘情况,在池回收执行时,完全忽略您的后台任务,导致异常终止。这就是为什么您必须使用一种注册任务的机制,例如HostingEnvironment.QueueBackgroundWorkItem

这就是为什么以你现在的方式做,想达到在后台线程中执行且“点火忘掉”的效果是不可能的。如果你真的想要实现这一点,可以使用 HostingEnvironment(如果你使用 .NET 4.5.2)或 BackgroundTaskManager。请注意,这样做会使用线程池线程来进行异步 IO 操作,这是多余的,并且正是使用 async-await 进行异步 IO 操作的目的所在。


2
谢谢你的回答。我已经阅读了你提到的文章,但我又重新读了一遍。我不知道为什么以前没有意识到,但我的问题的关键应该是第二个短语:“MVC框架知道如何等待您的Task,但它甚至不知道async void,因此它将完成返回给ASP.NET Core,后者发现它实际上并没有完成。”控制器构造函数编译成void方法,这就是我得到错误的原因。我是对的吗? - dyatchenko
我甚至不确定那是什么意思。你可以同步执行这段代码,或者使用我在答案中提到的适当措施在线程池线程上执行它。但是你不能像目前这样异步执行它。 - Yuval Itzchakov
但是,我是从DownloadStringTaskAsync方法中获取结果的。我只是设置了一个断点来检查它。这并不意味着我是“异步”获取结果吗? - dyatchenko
@dyatchenko 同步睡眠并没有帮助,因为在 ASP.NET 的“同步上下文”中注册的任务需要在您同步睡眠阻塞的线程上运行。如果您进行异步睡眠,例如 await Task.Delay(TimeSpan.FromSeconds(10)),则其他任务将在当前线程上运行并有机会完成。但是,等待时间过去并不是解决方案,因为您的下载可能需要更长时间。正确的解决方案是在所有操作方法中放置 await task - binki
1
这是因为当控制器返回时,您不再有挂起的异步操作。您能否简要说明一下原因?当在Index()中使用task = DownloadAsync();调用时,异步操作似乎仍在进行中,直到Index()完成。 - O. R. Mapper
显示剩余4条评论

10
ASP.NET认为在绑定其SynchronizationContext的“异步操作”启动并在所有已启动的操作完成之前返回ActionResult是不合法的。所有异步方法都将自己注册为“异步操作”,因此您必须确保所有这样绑定到ASP.NET SynchronizationContext的调用在返回ActionResult之前完成。
在您的代码中,您在不确保DownloadAsync()运行完成的情况下返回。但是,您将结果保存到task成员中,因此确保其完成非常容易。在返回之前,在所有操作方法中(在异步化它们之后)放置await task即可使其完成。
public async Task<ActionResult> IndexAsync()
{
    try
    {
        return View();
    }
    finally
    {
        await task;
    }
}

编辑:

在某些情况下,您可能需要调用一个async方法,该方法在返回到ASP.NET之前不应完成。例如,您可能希望延迟初始化后台服务任务,并且该任务应在当前请求完成后继续运行。这不适用于OP的代码,因为OP希望任务在返回之前完成。但是,如果您确实需要启动任务而不等待任务完成,则有一种方法可以做到这一点。您只需使用一种技术来“逃离”当前的SynchronizationContext.Current

  • 不建议使用Task.Run()的一个特性是可以逃离当前同步上下文。但是,人们建议不要在ASP.NET中使用此功能,因为ASP.NET的线程池是特殊的。即使在ASP.NET之外,这种方法也会导致额外的上下文切换。

  • 推荐使用)一种安全的方法,可以在不强制进行额外的上下文切换或立即打扰ASP.NET线程池的情况下逃离当前同步上下文,即SynchronizationContext.Current设置为null,调用您的async方法,然后恢复原始值


7
今天在构建API控制器时,我遇到了这个错误。事实证明,在我的情况下解决方案很简单。
我原本写成:
public async void Post()

我需要把它改成:

public async Task Post()

注意,编译器没有警告关于 async void


3
我遇到了相关问题。一个客户使用返回Task并使用async实现的接口。
在Visual Studio 2015中,客户端方法是异步的,并且在调用方法时不使用await关键字,代码没有警告或错误,可以编译成功。这会导致竞争条件进入生产环境。

谢谢你的回答。我也遇到了完全相同的问题,但这是一种不同的方式来引起OP所描述的同样的问题。 - Vivian River

2

方法返回async Task,使用ConfigureAwait(false)可以是其中的一种解决方案。它将像异步void一样工作,并且不会继续使用同步上下文(只要您真正不关心方法的最终结果)


1

方法myWebClient.DownloadStringTaskAsync在单独的线程上运行,是非阻塞的。一种可能的解决方案是使用myWebClient的DownloadDataCompleted事件处理程序和SemaphoreSlim类字段来完成此操作。

private SemaphoreSlim signalDownloadComplete = new SemaphoreSlim(0, 1);
private bool isDownloading = false;

....

//Add to DownloadAsync() method
myWebClient.DownloadDataCompleted += (s, e) => {
 isDownloading = false;
 signalDownloadComplete.Release();
}
isDownloading = true;

...

//Add to block main calling method from returning until download is completed 
if (isDownloading)
{
   await signalDownloadComplete.WaitAsync();
}

0

我曾经遇到过类似的问题,但是通过将CancellationToken作为参数传递给异步方法解决了这个问题。


-3

带附件的电子邮件通知示例..

public async Task SendNotification(string SendTo,string[] cc,string subject,string body,string path)
    {             
        SmtpClient client = new SmtpClient();
        MailMessage message = new MailMessage();
        message.To.Add(new MailAddress(SendTo));
        foreach (string ccmail in cc)
            {
                message.CC.Add(new MailAddress(ccmail));
            }
        message.Subject = subject;
        message.Body =body;
        message.Attachments.Add(new Attachment(path));
        //message.Attachments.Add(a);
        try {
             message.Priority = MailPriority.High;
            message.IsBodyHtml = true;
            await Task.Yield();
            client.Send(message);
        }
        catch(Exception ex)
        {
            ex.ToString();
        }
 }

2
这与问题有什么关系?你应该在那里使用client.SendMailAsync(message)。为什么要在任务yield上等待? - Gargoyle

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接