使用await/async时,HttpClient.GetAsync(...)永远不会返回。

377
编辑: 这个问题 看起来可能是同样的问题,但没有回复... 编辑: 在第5个测试用例中,任务似乎陷入了WaitingForActivation状态。
我在使用.NET 4.5中的System.Net.Http.HttpClient时遇到了一些奇怪的行为 - 在等待(例如)httpClient.GetAsync(...)的调用结果时,将永远不会返回。
仅在使用新的异步/等待语言功能和Tasks API时,在某些情况下才会发生此问题 - 当仅使用连续时,代码似乎总是可以正常工作。
以下是一些代码,可重现该问题 - 将其放入Visual Studio 11中的新“MVC 4 WebApi项目”中,以公开以下GET端点。
/api/test1
/api/test2
/api/test3
/api/test4
/api/test5 <--- never completes
/api/test6

这里的每个端点返回相同的数据(来自stackoverflow.com的响应头),除了/api/test5,它永远不会完成。

我是否在使用HttpClient类时遇到了错误,还是以某种方式误用了API?

复现代码:

public class BaseApiController : ApiController
{
    /// <summary>
    /// Retrieves data using continuations
    /// </summary>
    protected Task<string> Continuations_GetSomeDataAsync()
    {
        var httpClient = new HttpClient();

        var t = httpClient.GetAsync("http://stackoverflow.com", HttpCompletionOption.ResponseHeadersRead);

        return t.ContinueWith(t1 => t1.Result.Content.Headers.ToString());
    }

    /// <summary>
    /// Retrieves data using async/await
    /// </summary>
    protected async Task<string> AsyncAwait_GetSomeDataAsync()
    {
        var httpClient = new HttpClient();

        var result = await httpClient.GetAsync("http://stackoverflow.com", HttpCompletionOption.ResponseHeadersRead);

        return result.Content.Headers.ToString();
    }
}

public class Test1Controller : BaseApiController
{
    /// <summary>
    /// Handles task using Async/Await
    /// </summary>
    public async Task<string> Get()
    {
        var data = await Continuations_GetSomeDataAsync();

        return data;
    }
}

public class Test2Controller : BaseApiController
{
    /// <summary>
    /// Handles task by blocking the thread until the task completes
    /// </summary>
    public string Get()
    {
        var task = Continuations_GetSomeDataAsync();

        var data = task.GetAwaiter().GetResult();

        return data;
    }
}

public class Test3Controller : BaseApiController
{
    /// <summary>
    /// Passes the task back to the controller host
    /// </summary>
    public Task<string> Get()
    {
        return Continuations_GetSomeDataAsync();
    }
}

public class Test4Controller : BaseApiController
{
    /// <summary>
    /// Handles task using Async/Await
    /// </summary>
    public async Task<string> Get()
    {
        var data = await AsyncAwait_GetSomeDataAsync();

        return data;
    }
}

public class Test5Controller : BaseApiController
{
    /// <summary>
    /// Handles task by blocking the thread until the task completes
    /// </summary>
    public string Get()
    {
        var task = AsyncAwait_GetSomeDataAsync();

        var data = task.GetAwaiter().GetResult();

        return data;
    }
}

public class Test6Controller : BaseApiController
{
    /// <summary>
    /// Passes the task back to the controller host
    /// </summary>
    public Task<string> Get()
    {
        return AsyncAwait_GetSomeDataAsync();
    }
}

2
看起来不是同一个问题,但为了确保您知道它,beta版本中存在MVC4的异步方法问题,这些方法会同步完成-请参见https://dev59.com/hmkw5IYBdhLWcg3wzd3Z。 - James Manning
谢谢 - 我会注意的。在这种情况下,我认为该方法应该始终是异步的,因为调用了 HttpClient.GetAsync(...) - Benjamin Fox
7个回答

526

您正在错误使用API。

情况是这样的:在ASP.NET中,只有一个线程可以处理一个请求。如果必要的话,您可以进行一些并行处理(从线程池中借用额外的线程),但只有一个线程会有请求上下文(其他线程没有请求上下文)。

这是由ASP.NET的SynchronizationContext管理的。

默认情况下,当您await一个Task时,该方法将在捕获的SynchronizationContext(或者如果没有SynchronizationContext,则在捕获的TaskScheduler)上恢复。通常,这正是您想要的:异步控制器操作将await某些内容,并在恢复时使用请求上下文恢复。

因此,这就是为什么test5失败的原因:

  • Test5Controller.Get 执行 AsyncAwait_GetSomeDataAsync(在 ASP.NET 请求上下文中)。
  • AsyncAwait_GetSomeDataAsync 执行 HttpClient.GetAsync(在 ASP.NET 请求上下文中)。
  • HTTP 请求被发送出去,HttpClient.GetAsync 返回一个未完成的 Task
  • AsyncAwait_GetSomeDataAsync 等待该 Task;由于它尚未完成,AsyncAwait_GetSomeDataAsync 返回一个未完成的 Task
  • Test5Controller.Get 阻塞当前线程直到该 Task 完成。
  • HTTP 响应返回,并且由 HttpClient.GetAsync 返回的 Task 已经完成。
  • AsyncAwait_GetSomeDataAsync 尝试在 ASP.NET 请求上下文中恢复。但是,该上下文中已经有一个线程:被阻塞在 Test5Controller.Get 中的线程。
  • 死锁。

以下是其他方案可行的原因:

  • (test1, test2, 和 test3): Continuations_GetSomeDataAsync 将延续任务调度到线程池中,超出ASP.NET请求上下文。这使得由Continuations_GetSomeDataAsync返回的Task可以在不必重新进入请求上下文的情况下完成。
  • (test4test6): 由于Taskawaited,ASP.NET请求线程不会被阻塞。这使得当AsyncAwait_GetSomeDataAsync准备好继续时,它可以使用ASP.NET请求上下文。

以下是最佳实践:

  1. 在你的“库”async方法中,尽可能使用ConfigureAwait(false)。在你的情况下,这将改变AsyncAwait_GetSomeDataAsyncvar result = await httpClient.GetAsync("http://stackoverflow.com", HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
  2. 不要阻塞Tasks;一路使用async。换句话说,应该使用 await 而不是 GetResultTask.ResultTask.Wait 也应该替换为await)。

这样既可以获得连续性(AsyncAwait_GetSomeDataAsync 方法的其余部分)是在基本线程池线程上运行的,不必进入 ASP.NET 请求上下文;而且控制器本身是async 的(不会阻塞请求线程)。

更多信息:

更新于2012年07月13日:将此答案整合到一篇博客文章中。


9
据我所知,这个地方没有任何相关记录。 - Stephen Cleary
13
谢谢你的精彩回复。虽然表面上代码相同,但行为差异令人沮丧,但通过你的解释讲得很清楚。如果框架能够检测到这种死锁并在某处引发异常,那将非常有用。 - Benjamin Fox
3
在 ASP.NET 环境中,是否存在不建议使用 .ConfigureAwait(false) 的情况?在我看来,它似乎应该始终被使用,只有在 UI 上下文中不应该使用,因为你需要与 UI 同步。我是否理解有误? - AlexGad
3
ASP.NET 中的 SynchronizationContext 提供了一些重要的功能:它可以传递请求上下文。这包括从身份验证到 Cookies 到区域设置等各种信息。因此,在 ASP.NET 中,您需要将上下文同步回请求上下文,而不是同步回用户界面。可能很快就会有所改变:新的 ApiController 具有作为属性的 HttpRequestMessage 上下文-因此可能不需要通过 SynchronizationContext 传递上下文,但我还不确定。 - Stephen Cleary
2
谢谢!我错误地使用了Task.WaitAll而不是await Task.WhenAll。你的回答让我找到了正确的方向。 - Mikael Eliasson
显示剩余14条评论

79

编辑:通常情况下,除非万不得已要避免死锁,否则请尽量避免以下做法。请阅读 Stephen Cleary 的第一条评论。

这里快速修复。不要再写成:

Task tsk = AsyncOperation();
tsk.Wait();

尝试:

Task.Run(() => AsyncOperation()).Wait();

或者如果您需要结果:

var result = Task.Run(() => AsyncOperation()).Result;

从源代码(已编辑以匹配上面的示例):

AsyncOperation现在将在线程池上调用,其中不会有SynchronizationContext,并且在AsyncOperation内部使用的连续性将不会被强制返回到调用线程。

对我来说,这看起来是一个可用的选项,因为我没有使其完全异步的选项(我宁愿这样做)。

从源代码:

确保FooAsync方法中的等待未找到要返回的上下文。最简单的方法是从ThreadPool中调用异步工作,例如通过将调用包装在Task.Run中,例如:

int Sync() { return Task.Run(() => Library.FooAsync()).Result; }

FooAsync现在将在ThreadPool上调用,在那里不会有SynchronizationContext,并且在FooAsync内部使用的continuation将不会被强制返回到调用Sync()的线程。


8
你可能需要重新阅读你的来源链接;作者建议不要这样做。它有效吗?是的,但仅在避免死锁方面有效。这种解决方案抵消了在ASP.NET上使用async代码的所有好处,并且实际上在大规模应用时可能会引发问题。顺便说一下,ConfigureAwait在任何情况下都不会“破坏正确的异步行为”;它确切地说是你应该在库代码中使用的东西。 - Stephen Cleary
2
这是整个第一部分,标题为“避免暴露异步实现的同步包装器”。如果您绝对需要,整篇文章的其余部分将解释几种不同的方法来实现它。 - Stephen Cleary
1
我添加了我在源代码中找到的部分 - 我将让未来的读者决定。请注意,通常应该尽量避免这样做,并且只有在没有控制异步代码时才应该采取此措施(即作为最后一招)。 - Ykok
3
我喜欢这里的所有答案,正如以往一样...它们都基于上下文(双关语lol)。我正在用同步版本包装HttpClient的异步调用,因此无法更改该库以添加ConfigureAwait到该代码中。因此,为了防止生产中出现死锁,我将异步调用包装在一个Task.Run中。据我所知,这会使用每个请求额外的1个线程,并避免死锁。我认为为了完全符合规定,我需要使用WebClient的同步方法。那是很多工作来证明,所以我需要一个令人信服的理由不坚持我的当前方法。 - samneric
1
我最终创建了一个扩展方法来将异步转换为同步。我在这里读到过,这与.Net框架的方式相同: public static TResult RunSync<TResult>(this Func<Task<TResult>> func) { return _taskFactory .StartNew(func) .Unwrap() .GetAwaiter() .GetResult(); } - samneric
显示剩余3条评论

24

由于您正在使用.Result.Waitawait,这将导致您的代码出现死锁(deadlock)

您可以在async方法中使用ConfigureAwait(false)预防死锁(deadlock)

像这样:

var result = await httpClient.GetAsync("http://stackoverflow.com", HttpCompletionOption.ResponseHeadersRead)
                             .ConfigureAwait(false);

尽可能使用ConfigureAwait(false)来防止异步代码阻塞。


4
这两个学校并非真正排斥对方。
以下是必须使用的情况。
   Task.Run(() => AsyncOperation()).Wait(); 

或类似的东西

   AsyncContext.Run(AsyncOperation);

我有一个MVC操作,在数据库事务属性下。大概的想法是,如果出现问题,将回滚操作中的所有内容。这不允许上下文切换,否则事务回滚或提交本身将失败。

我需要的库是异步的,因为期望它是异步运行的。

唯一的选择。将其作为普通同步调用运行。

我只是说每个人都有自己的方式。


那你的意思是你在你的回答中建议选择第一种选项? - Don Cheadle
我需要的库是异步的,因为它预计会以异步方式运行。这是真正的问题。 - Redwolf

3
我将这句话放在这里更多是为了完整性而非直接与问题相关。我花了近一天的时间调试HttpClient请求,想知道为什么我从未收到响应。 最终发现我忘记了在调用栈中进一步await async调用。 感觉就像漏掉了分号一样难受。

2

我使用了太多的await,导致没有得到响应,将其转换为同步调用后,它开始工作了。

            using (var client = new HttpClient())
            using (var request = new HttpRequestMessage())
            {
                client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
                request.Method = HttpMethod.Get;
                request.RequestUri = new Uri(URL);
                var response = client.GetAsync(URL).Result;
                response.EnsureSuccessStatusCode();
                string responseBody = response.Content.ReadAsStringAsync().Result;
               

1
“request” 变量似乎没有被使用? - Doug Royer

2

在我的情况下,'await'由于执行请求时发生异常(例如,服务器未响应等)而无法完成。将其放置在try..catch中以确定发生了什么,这也会使你的'await'优雅地完成。

public async Task<Stuff> GetStuff(string id)
{
    string path = $"/api/v2/stuff/{id}";
    try
    {
        HttpResponseMessage response = await client.GetAsync(path);
        if (response.StatusCode == HttpStatusCode.OK)
        {
            string json = await response.Content.ReadAsStringAsync();
            return JsonUtility.FromJson<Stuff>(json);
        }
        else
        {
            Debug.LogError($"Could not retrieve stuff {id}");
        }
    }
    catch (Exception exception)
    {
        Debug.LogError($"Exception when retrieving stuff {exception}");
    }
    return null;
}

1
这是一个被忽视的答案。有时候错误并不明显。例如,如果你在OnIntialised中初始化一个变量,渲染引擎可能会抛出一个错误,如果它试图从该变量构建,因为线程仍在等待,那么整个过程就会崩溃,而没有任何错误提示。这个答案解决了我几周以来一直在研究的问题。 - Tod

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