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

时间:2016-12-23 19:20:49

标签: c# async-await deadlock dotnet-httpclient qa

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

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

enter image description here

以下是最近给我们带来问题的不良代码示例:

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.Currentand so .Result captures the context of TaskScheduler, which is the thread pool context。因此,我使用SetSynchronizationContext将其设置为特定上下文,以更接近地模拟ASP.NET或UI上下文中发生的事情。

  • 我已经将Fiddler配置为等待一段时间(约1分钟),然后再回复。我从同事那里听说这可能有助于重新制造僵局(但我没有确凿证据证明这种情况)。

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

不幸的是,我没有运气通过此单元测试触发死锁。它总是完成,无论Fiddler的延迟有多长,除非延迟超过Timeout的100秒默认HttpClient(在这种情况下,它会因异常而爆炸)。

我错过了点燃死锁火的成分吗?我想重新制定僵局,只是为了肯定我们的最终解决方案确实有效。

2 个答案:

答案 0 :(得分:8)

您似乎认为设置任何同步上下文可能会导致异步代码死锁 - 这不是真的。阻止asp.net和UI应用程序中的异步代码是危险的,因为它们具有特殊的单一主线程。在UI应用程序中,主UI空间线程,在ASP.NET应用程序中有许多这样的线程,但对于给定的请求,有一个 - 请求线程。

ASP.NET和UI应用程序的同步上下文非常特殊,因为它们基本上将回调发送到该特殊线程。所以当:

  1. 您在此主题上执行了一些代码
  2. 从该代码中执行一些异步Task并阻止其Result
  3. Task等待声明。
  4. 将发生死锁。为什么会这样?因为异步方法的延续是Post到当前同步上下文。我们上面讨论的那些特殊上下文将把这些延续发送到特殊的主线程。你已经在同一个线程上执行了代码并且它已被阻止 - 因此死锁。

    那你做错了什么?首先,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;
            }
        }
    }
    

答案 1 :(得分:4)

您无法重现此问题,因为SynchronizationContext本身并不模仿ASP.NET安装的上下文。基础SynchronizationContext没有锁定或同步,但是ASP.NET上下文会这样做:因为HttpContext.Current不是线程安全的,也不存储在线程之间传递的LogicalCallContext中, AspNetSynchronizationContext做了一些工作。恢复任务时恢复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)