如何触发(不避免!) HttpClient 死锁

Posted

技术标签:

【中文标题】如何触发(不避免!) HttpClient 死锁【英文标题】:How to trigger (NOT avoid!) an HttpClient deadlock 【发布时间】:2016-12-23 19:20:49 【问题描述】:

关于如何避免从同步代码(例如 this)调用的异步代码(例如,HttpClient 方法)中的死锁,关于 SO 存在许多问题。我知道避免这些死锁的各种方法。

相比之下,我想了解在测试期间加剧触发错误代码中这些死锁的策略。

这是最近给我们带来问题的一些错误代码示例:

public static string DeadlockingGet(Uri uri)

    using (var http = new HttpClient())
    
        var response = http.GetAsync(uri).Result;
        response.EnsureSuccessStatusCode();
        return response.Content.ReadAsStringAsync().Result;
    

它是从 ASP.NET 应用程序调用的,因此具有非nullSynchronizationContext.Current,这为潜在的死锁火灾提供了燃料。

除了blatantly misusing HttpClient,这段代码在我们公司的一台服务器上死锁了……但只是偶尔发生。

我尝试重现死锁

我在 QA 部门工作,所以我尝试通过一个单元测试来重现死锁,该单元测试命中 Fiddler 的侦听器端口的本地实例:

public class DeadlockTest

    [Test]
    [TestCase("http://localhost:8888")]
    public void GetTests(string uri)
    
        SynchronizationContext.SetSynchronizationContext(new SynchronizationContext());
        var context = SynchronizationContext.Current;
        var thread = Thread.CurrentThread.ManagedThreadId;
        var result = DeadlockingGet(new Uri(uri));
        var thread2 = Thread.CurrentThread.ManagedThreadId;
    

需要注意的几点:

默认情况下,单元测试有一个空的SynchronizationContext.Current、and so .Result captures the context of TaskScheduler, which is the thread pool context。因此,我使用SetSynchronizationContext 将其设置为特定上下文,以更接近地模拟 ASP.NET 或 UI 上下文中发生的情况。

我已将 Fiddler 配置为在响应之前等待一段时间(约 1 分钟)。我从同事那里听说这可能有助于重现僵局(但我没有确凿的证据)。

我已经用调试器运行它以确保context 不是nullthread == thread2

不幸的是,我在这个单元测试中没有触发死锁。无论 Fiddler 中的延迟有多长,它总是会结束,除非延迟超过 HttpClient 的默认 100 秒 Timeout(在这种情况下,它只会出现异常)。

我是否缺少点燃僵局之火的成分?我想重现死锁,只是为了肯定我们的最终修复确实有效。

【问题讨论】:

【参考方案1】:

您似乎认为设置 any 同步上下文可能会导致异步代码死锁 - 这是不正确的。在 asp.net 和 UI 应用程序中阻塞异步代码是很危险的,因为它们有特殊的、单一的主线程。在 UI 应用程序中,也就是主 UI 线程,在 ASP.NET 应用程序中有很多这样的线程,但是对于给定的请求,只有一个请求线程。

ASP.NET 和 UI 应用程序的同步上下文的特殊之处在于它们基本上将回调发送到那个特殊的线程。所以当:

    你在这个线程上执行了一些代码 您可以从该代码中执行一些异步 Task 并阻止它的 ResultTask 有 await 语句。

会发生死锁。为什么会发生这种情况?因为 async 方法的延续是 Posted 到当前同步上下文。我们上面讨论的那些特殊上下文会将这些延续发送到特殊的主线程。你已经在同一个线程上执行了代码并且它已经被阻塞了——因此是死锁。

那你做错了什么?首先,SynchronizationContext 不是我们上面讨论的特殊上下文 - 它只是将延续发布到线程池线程。您需要另一个进行测试。您可以使用现有的(如WindowsFormsSynchronizationContext),也可以创建行为相同的简单上下文(示例代码,仅用于演示目的):

class QueueSynchronizationContext : SynchronizationContext 
    private readonly BlockingCollection<Tuple<SendOrPostCallback, object>> _queue = new BlockingCollection<Tuple<SendOrPostCallback, object>>(new ConcurrentQueue<Tuple<SendOrPostCallback, object>>());
    public QueueSynchronizationContext() 
        new Thread(() =>
        
            foreach (var item in _queue.GetConsumingEnumerable()) 
                item.Item1(item.Item2);
            
        ).Start();
            

    public override void Post(SendOrPostCallback d, object state) 
        _queue.Add(new Tuple<SendOrPostCallback, object>(d, state));
    

    public override void Send(SendOrPostCallback d, object state) 
        // Send should be synchronous, so we should block here, but we won't bother
        // because for this question it does not matter
        _queue.Add(new Tuple<SendOrPostCallback, object>(d, state));
    

它所做的只是将所有回调放入单个队列并在单独的单个线程上一一执行。

用这个上下文模拟死锁很容易:

class Program 
    static void Main(string[] args)
    
        var ctx = new QueueSynchronizationContext();
        ctx.Send((state) =>
        
            // first, execute code on this context
            // so imagine you are in ASP.NET request thread,
            // or in WPF UI thread now                
            SynchronizationContext.SetSynchronizationContext(ctx);
            Deadlock(new Uri("http://google.com"));   
            Console.WriteLine("No deadlock if got here");
        , null);
        Console.ReadKey();
    

    public static void NoDeadlock(Uri uri) 
        DeadlockingGet(uri).ContinueWith(t =>
        
            Console.WriteLine(t.Result);
        );
    

    public static string Deadlock(Uri uri)
    
        // we are on "main" thread, doing blocking operation
        return DeadlockingGet(uri).Result;
    

    public static async Task<string> DeadlockingGet(Uri uri) 
        using (var http = new HttpClient()) 
            // await in async method
            var response = await http.GetAsync(uri);
            // this is continuation of async method
            // it will be posted to our context (you can see in debugger), and will deadlock
            response.EnsureSuccessStatusCode();
            return response.Content.ReadAsStringAsync().Result;
        
    

【讨论】:

感谢您指出导致死锁的不是SynchronizationContext 的存在,而是上下文实现其Post/Send 方法的方式,用于在发生回调时恢复执行。这有帮助!在你的代码中我仍然不明白的一件事是为什么用我原来的DeadlockingGet 同步定义替换Deadlock 似乎不会触发死锁。任何想法为什么不会死锁,但你的例子呢? @DumpsterDoofus DeadlockingGet 不在任何地方使用 async\await,也不被 HttpClient 方法内部使用(GetAsync 和 ReadAsStringAsync 在内部使用常规任务完成源和 Task.ContinueWith) - 所以它不应该死锁。你怎么知道这个代码块在你的情况下死锁了? 我们在其中一项操作冻结时对其进行了概要分析,请参阅imgur.com/a/LnMln 了解概要结果(我已将公司名称空间审查为红色)。第一张图显示了包含死锁方法的堆栈,即HttpClientWrapper.Post(实现与我的DeadlockingGet相同,除了它使用PostAsync),你可以看到50%的花费在Wait()上。第二张图片显示另外 50% 用于System.Web.LegacyAspNetSynchronizationContext.CallCallback。 AFAIK 这些是异形死锁的标志。 HttpClientWrapper.Post 的确切实现是public string Post(Uri uri, string dataToPost, string mediaType) using (var http = new HttpClient()) var response = http.PostAsync(uri, new StringContent(dataToPost, Encoding.Default, mediaType)).Result; response.EnsureSuccessStatusCode(); return response.Content.ReadAsStringAsync().Result; ,它仍然不应该死锁。我开始怀疑死锁是否是由调用堆栈中的其他东西引起的。 据我了解,这种死锁并不总是发生,只是偶尔发生?您还可以发布 asp.net 方法的执行方式(***控制器方法)。是异步的吗?【参考方案2】:

您无法重现该问题,因为SynchronizationContext 本身并不模仿 ASP.NET 安装的上下文。基本的SynchronizationContext 没有锁定或同步,但ASP.NET 上下文有:因为HttpContext.Current 不是线程安全的,也不是存储在LogicalCallContext 中以便在线程之间传递,所以AspNetSynchronizationContext 有点作用工作到 a。恢复任务时恢复 HttpContext.Current 和 b。锁定以确保在给定的上下文中只运行一个任务。

MVC 也存在类似问题:http://btburnett.com/2016/04/testing-an-sdk-for-asyncawait-synchronizationcontext-deadlocks.html

这里给出的方法是使用上下文测试您的代码,以确保永远不会在上下文中调用 SendPost。如果是,这表示死锁行为。要解决这个问题,要么将方法树设置为async,要么在某处使用ConfigureAwait(false),这实际上将任务完成与同步上下文分离。更多信息,本文详述when you should use ConfigureAwait(false)

【讨论】:

以上是关于如何触发(不避免!) HttpClient 死锁的主要内容,如果未能解决你的问题,请参考以下文章

[ Linux ] 死锁以及如何避免死锁

一个多线程死锁案例,如何避免及解决死锁问题?

如何避免数据库死锁?

如何避免死锁?

Java开发之——线程面试篇:死锁和如何避免死锁?

「操作系统」深入理解死锁(什么是死锁?死锁形成条件?如何避免死锁?如何排查死锁?)