折腾好久,最终在这里找到了答案: https://stackoverflow.com/questions/10343632/httpclient-getasync-never-returns-when-using-await-async/10351400#10351400
先来看看案例一,代码如下:
public class ValuesController : ApiController { [Route("getMessage")] public string GetMessage() { NetTask(); return "Ok"; } public async Task NetTask() { using (HttpClient client = new HttpClient()) { HttpRequestMessage request = new HttpRequestMessage { Method = HttpMethod.Get }; request.RequestUri = new Uri("http://www.baidu.com"); var response = await client.SendAsync(request); var result = await response.Content.ReadAsStringAsync(); Debug.WriteLine(result); } } }
上面这段代码,当我们在浏览器上访问/getMessage时,永远不会输出Debug.WriteLine(result);
猜想是不是NetTask()没有堵塞,导致主线程结束后,NetTask()方法中的异步代码没有执行。
运行案例二,把NetTask()堵塞掉,代码如下:
public string GetMessage() { NetTask().GetAwaiter().GetResult(); return "Ok"; }
访问/getMessage,发现请求得不到响应,断点一下,发现NetTask()方法一直被堵塞了,引发了死锁!
有的人可能会说GetMessage()应该打上async标记,使用await 等待NetTask,没错,这样实现的话代码是没有任何问题的,但是不满足我们现在的要求,假设NetTask()的网络请求耗时为10秒,如果我们这样去写我们的代码,那GetMessage()这个Action的耗时起码需要10秒。
也有人会推荐使用Task.Run,用其他线程去执行这个耗时请求。但是这样的操作还是额外产生了线程的调度(主线程,Task.Run中的线程,await后的线程,共3个线程),我们的理想操作是主线程完成请求前的所有动作后不堵塞完成NetTask()后面的代码。请求完成后的操作交给其他线程。这样我们只使用到2个线程。
Context 上下文
在我们使用async/await时,我们首先需要了解"Context",简单的说,当我们使用async/await时,当程序遇到需要等待的地方,程序会捕捉到当前的Context,当异步方法完成后,程序会恢复捕捉到的Context,并在上面执行后续的代码。异步的上下文可以分为UI上下文,ASP.NET Request上下文,线程池上下文,让我们用代码解释一下:
// WinForms example (WPF同原理). private async void DownloadFileButton_Click(object sender, EventArgs e) { // 异步方法,当代码需要await时,程序捕捉当前的上下文,此处为UI上下文。await期间主线程是不会被阻塞的 await DownloadFileAsync(fileNameTextBox.Text); // await完成,程序将为我们恢复捕捉的上下文,也就是UI上下文,因为我们拥有UI上下文所以可以更新UI控件,即使当前的线程可能是其他线程 resultTextBox.Text = "File downloaded!"; } // ASP.NET example protected async void MyButton_Click(object sender, EventArgs e) { // 异步方法,当代码需要await时,程序捕捉当前的上下文,此处为请求的上下文。await期间允许线程继续处理其他请求 await DownloadFileAsync(...); // await完成,程序恢复request上下文,即便当前操作的可能是其他线程,但我们拥有请求的上下文,所以我们能响应请求。 Response.Write("File downloaded!"); }
那么再回过头来解释一下案例一为什么不能运行到client.SendAsync()后面的代码,按照我们刚才所说,异步时会为我们捕捉请求的上下文。在看NetTask()并没有被阻塞,所以请求马上就结束了。当client.SendAsync()被执行完后无法操作一个已经结束的请求上下文,自然不会执行下面的代码。当然如果client.SendAsync()执行的够快,在请求还没有结束前完成,那么依旧能运行到后续的代码。
再来解释一下案例二为什么会死锁,在案例二中,使用GetAwaiter().GetResult()阻塞住了主线程,异步帮我们捕捉了请求的上下文,当异步完成恢复请求上下文的环境时,因为主线程阻塞等待异步的完成,而异步线程因为主线程阻塞没办法操作请求上下文,产生了死锁。
那么应该怎么解决这种问题呢?
ConfigureAwait
大部分情况,我们可能不需要返回到"主"上下文中,大部分的异步方法可以被组合使用,每个异步操作可能只代表本身,和之前的上下文并没有什么联系。此时,我们可以通过ConfigureAwait告诉程序不需要捕捉上下文:
private async Task DownloadFileAsync(string fileName) { // 使用HttpClient或者其他下载文件 var fileContents = await DownloadFileContentsAsync(fileName).ConfigureAwait(false); // 因为配置了ConfigureAwait(false),所以此时不再是之前的上下文,而是Thread pool Context// 将文件写到硬盘 await WriteToDiskAsync(fileName, fileContents).ConfigureAwait(false); // 第二个ConfigureAwait不是必须的,但是不错的做法 } // WinForms example (it works exactly the same for WPF). private async void DownloadFileButton_Click(object sender, EventArgs e) { // await,UI线程不会在此阻塞,并且调用异步方法没有使用ConfigureAwait(false),所以此时会捕捉UI上下文 await DownloadFileAsync(fileNameTextBox.Text); // 我们拥有UI上下文,所以当异步恢复时,可以直接操作UI控件 resultTextBox.Text = "File downloaded!"; }
当然,如果我们操作全程都包括标注了async,也不会出现阻塞的情况,但Action肯定是需要等异步结束后才能返回的。
参考文章:https://blogs.msdn.microsoft.com/pfxteam/2012/04/12/asyncawait-faq/
http://blog.stephencleary.com/2012/07/dont-block-on-async-code.html
http://blog.stephencleary.com/2012/02/async-and-await.html