HttpClient使用问题浅析

 1.背景

  最近团队开发的数据库组件需要通过HTTP请求方式从配置中心获取连接字符串,该组件采用.NET 6进行开发。考虑到并发的情况,因此对获取连接字符串的方法进行了加锁,并进行了双重检测(double-checking)。 由于组件框架使用.NET 6,我们采用了HttpClient组件进行HTTP请求。在实际测试中发现,当请求压力较大的场景下,程序容易出现“死锁”。为解决此问题,我们对程序进行了简单分析,并在本文中记录了整个分析过程。

以下是模拟代码:

using System.Diagnostics;

namespace HttpClientMultiInvokeTestConsole
{
    internal class Program
    {
        static string flag = "";
        static object lockObj = new object();

        static void Main(string[] args)
        {
            var tasks = new List<Task>();
            for (int i = 0; i < 100; i++)
            {
                var t = new Task(() => LockLock());
                tasks.Add(t);
            }

            var sw = Stopwatch.StartNew();
            foreach (var t in tasks)
            {
                t.Start();
            }

            Task.WaitAll(tasks.ToArray());

            sw.Stop();
            Console.WriteLine(flag);
            Console.WriteLine(sw.ElapsedMilliseconds);
        }

        private static void LockLock()
        {
            if (string.IsNullOrEmpty(flag))
            {
                lock (lockObj)
                {
                    if (string.IsNullOrEmpty(flag))
                    {

                       var content = GetConnectionString().Result;


                        flag = content;
                    }
                }
            }
        }

        private static async Task<string> GetConnectionString()
        {
            HttpClient client = new HttpClient();

            var content = await client.GetStringAsync("http://www.baidu.com");

            return content;
        }
    }
}

 

以上代码模拟并发的场景,初始化了100个任务。经测试,该代码在i7-7700K处理器机器上通常需要运行70秒以上,在i7-11800H处理器机器上差别不大。

关于HttpClient的介绍本文不再赘述,见参考资料[1][2]

2.问题分析

  当我们的程序遭遇性能问题时,通常可能需要考虑以下几个方面:

  • CPU利用率

   可以使用 Windows 任务管理器或者其他性能监控工具来检查 CPU 利用率。如果 CPU 利用率很高,说明程序可能存在 CPU 密集型任务,需要优化算法或者减少计算量。

  • 内存使用情况

      可以使用 Windows 任务管理器或者其他性能监控工具来检查内存使用情况。如果内存使用量很高,说明程序可能存在内存泄漏或者大量的对象创建和销毁操作,需要进行内存优化。

  • I/O 操作

     可以使用 Windows 任务管理器或者其他性能监控工具来检查磁盘和网络 I/O 操作的负载情况。如果 I/O 操作很频繁,说明程序可能存在 I/O 密集型任务,需要优化读写操作,例如使用缓存来减少磁盘或者网络访问。

  • 数据库操作

     如果程序需要频繁访问数据库,可以使用 SQL Server Profiler 或者其他数据库性能监控工具来检查 SQL 查询的性能情况。如果查询时间很长,说明可能需要进行优化,例如添加索引、优化查询语句或者减少查询次数等。

  • 线程和锁的使用

   如果程序使用了多线程和锁,需要检查线程和锁的使用情况,以及是否存在死锁和竞争问题。可以使用 Visual Studio 调试器或者其他工具来检查线程和锁的状态[3],以及分析线程和锁的竞争情况。

从场景模拟代码可以看出,其中并没有数据库操作,也不存在大量I/O操作。待运行程序后,我们使用任务管理器对程序的运行状况进行了初步了解,发现CPU利用率以及内存使用都非常低,几乎可以忽略,因此问题极有可能出在线程和锁上。 为了进一步分析,我们使用 Process Explorer进程管理工具[4]对模拟程序进行了Full DUMP,以便后续使用WinDbg进行分析。如下图所示:

(图1)

  得到了进程的完整DUMP文件后,我们便可以开始使用WinDbg调试工具进行调试了。 在WinDbg调试工具中,除了原生的调试指令之外,针对.NET程序的调试还有一些其他的常用扩展,例如:

  • SOS

     SOS 是 .NET 框架提供的一个调试扩展,可以用于分析 .NET 程序的内存状态和线程状态。SOS 可以帮助分析和调试 .NET 中的对象、堆栈、线程、GC 和异常等内容。

  • Psscor4

     Psscor4 是一个常用的 WinDbg 插件,可以用于分析和调试 .NET 程序的内存状态和线程状态。Psscor4 的功能类似于 SOS,但是它提供了更多的调试命令和功能,例如查看线程状态、分析 GC、查看对象和数组、分析堆栈和调用链等。

  • Son of Strike (SOS) Ex

     SOS Ex 是 SOS 的扩展版本,提供了更多的调试命令和功能,例如查看对象的引用关系、分析 Finalizer 队列、分析线程池、分析委托等。

  • Netext

     Netext 是一个常用的 WinDbg 插件,可以用于分析和调试 .NET 程序的内存状态和线程状态。Netext 提供了一些有用的命令和功能,例如查看对象、分析堆栈、查看线程状态、分析 GC 等。

  • ManagedXLL

     ManagedXLL 是一个用于分析和调试 .NET 程序的 WinDbg 插件,它提供了一些有用的命令和功能,例如查看对象、分析堆栈、查看线程状态、分析 GC 等。ManagedXLL 还提供了一些 Excel 函数,可以将调试信息输出到 Excel 表格中。

  • MEX.dll[5]

     MEX.dll 是一个用于辅助调试 .NET 应用程序的 WinDbg 扩展,它提供了一些有用的命令和功能,可以帮助分析和调试 .NET 应用程序的内存状态和线程状态。

这些扩展的使用方式都大同小异,其中最常用的莫过于SOS,SOSEx,MEX这三个。关于扩展的加载以及使用本文也不再赘述,见参考资料[6][7]。

  为了简便,在调试中我们采用了SOSEx扩展,它可以直接使用.dlk命令(DeadLock)来检测程序中的死锁。经过分析,程序中并未包含死锁,如下图所示:

(图2)

这也是为什么在本文开头提到的死锁会加上一个双引号的原因。实际上,我们在初期观察程序运行情况时就怀疑程序中并没有死锁,因为程序并不是从头到尾始终挂起,只是目标方法的运行时间过长,远超预期而已。 既然程序中没有死锁,那只能是其他线程相关的问题了。

  回过头重新看看程序的代码,为了模拟较高的并发量,其中使用Task类来创建了大量的任务。注意我的描述,这里说的是任务,并不是线程。因为创建一个Task实例并不一定会创建一个新的线程。在 .NET 中,Task 类可以利用线程池中的线程来执行任务,以提高系统的性能和吞吐量。Task 类的底层实现使用了 ThreadPool.QueueUserWorkItem 方法,将任务提交给线程池(TheadPool),由线程池中的线程来执行任务。当使用 Task.Factory.StartNew 或 Task.Run 方法创建一个新的任务时,Task 类会将任务封装成一个委托对象,然后调用 ThreadPool.QueueUserWorkItem 方法将委托对象提交给线程池。线程池会在有可用的线程时,从线程池中取出一个线程来执行任务,任务执行完毕后,线程会自动返回线程池,等待下一个任务的到来[8]  。

  综上所述,难道程序运行缓慢是因为是因为线程池被打满了?让我们监测下程序的线程使用情况看看。

  在Windows平台上,可以使用其自带的资源监视器工具进行资源监控(见图3,图4),也可以使用.NET自带的计数器工具(dotnet-counters monitor,首次使用需进行安装:dotnet tool install --global dotnet-counters),两款工具均可实时监控程序的资源使用情况。

(图3)

  

(图4)

这里我们使用.NET自带的计数器工具进行监测。启动模拟程序,打开命令行窗口或者Powershell窗口,键入 dotnet-counters monitor -n   【YourProcessName】,如图5所示:

(图5) 

(图6)

从图6中可以看到,程序刚运行时,线程池线程数量仅为1,线程池队列长度为0。接着,正式开始100个任务的执行。

(图7)

(图8)

从图8中的监控面板观察结果来看,随着程序的运行,线程池队列(ThreadPool Queue Length)开始慢慢减少,而线程池线程数量(ThreadPool Thead Count)则逐渐增多,呈现出一种此消彼长的现象。在此期间,程序则是保持挂起状态,直到线程池队列基本清空,程序开始返回了我们想要的结果,而这时候线程池线程数量已经增长到104。如图9所示:

(图9)

模拟程序打点测得整个执行时间为 71729毫秒,约72秒,如图10所示:

(图10)

为了验证是否线程不足导致程序运行缓慢的猜想,我们对模拟程序做了一些改动。当首次执行完100个任务后,在未重启程序的情况下,我们清空了定义的Task集合,并清空了返回的结果,然后立即开始再执行100个相同的任务。此时,线程池中线程很充足,再次执行100个任务耗时则非常短,只用了1112毫秒,约1秒钟。如图11所示:

(图11)

在执行时间上前后竟然存在72倍的差距!! 很明显线程池中充足的线程可以很好地解决方法执行时间过长的问题。 那难道需要执行1000个任务就需要1000个线程吗,这又明显不合理。  会不会是HttpClient导致的线程不足呢?我们再次更改了模拟程序代码。如图12所示。

(图12)

对程序编译后再次执行,同时进行资源监控。如图13所示:

(图13)

更改后的程序执行两次100个任务分别只需要2秒左右,用时基本持平,并且线程池中线程数量最大也才16(峰值截图)。 这样来看,问题必然出在HttpClient这边。

  为了一探究竟,我们将代码恢复为原始版本,利用VS的并行堆栈、任务列表等工具对程序进行了调试分析。在程序启动片刻之后,按下Ctrl + Alt + Break 进行中断。

  首先打开并行堆栈视图,如图14所示。

 (图14)

观察图14的上半部分,视图告诉我们分别有38个异步逻辑堆栈、1个异步逻辑堆栈、61个异步逻辑堆栈。让我们做一个简单的计算: 38 + 1 + 61 = ?, 还记得我们启动了多少个任务吗? 没错,正好是100个。一个异步逻辑堆栈对应着1个任务。

  将鼠标移动到第一个框中的“LockLock”处会弹出一个悬浮框,如图15所示:

(图15)

从图中可以看出这38个任务状态均是“已阻止”。 任意选择其中一个任务双击鼠标,这时会直接跳转到对应的栈帧,无一例外地都是 lock(lockObj)处。不难理解,这些任务都是在等待lockObj锁对象的释放。

  鼠标移动到图14的第三个框中的Program.Main.AnonymousMethod__3_0 处也会弹出悬浮框,如图16所示:

(图16)

从上图可以了解到这61个任务状态均是“已计划”,双击任意一个任务均会跳转到for循环 var t = new Task(() => LockLock())处,表明这些任务正在计划执行LockLock方法。那么“已计划”到底是怎样的一种状态? 从任务数组中选出这些状态为“已计划”的任务,在即时窗口中计算可得知这些任务的TaskStatus均为WaitingToRun。微软官方是这样解释WaitingToRun含义的:

The task has been scheduled for execution but has not yet begun executing.

翻译过来即是:该任务已被计划执行,但尚未开始执行。这表示任务已经被调度器接受,但尚未开始执行,因为没有可用的线程来执行它。

  接下来我们再看图14中最大的一个视图,从其中的堆栈信息可以得知第一个获取到Lock锁的线程上任务列表的一个等待链。栈顶部的HttpConnectionPool.GetHttp11ConnectionAsync引起了我们的注意,该方法正在异步获取HTTP/1.1连接(HTTP版本跟我们请求的目标站点有关),栈底的方法在等待该方法的完成。其源码如下:

private async ValueTask<HttpConnection> GetHttp11ConnectionAsync(HttpRequestMessage request, bool async, CancellationToken cancellationToken)
        {
            // Look for a usable idle connection.
            TaskCompletionSourceWithCancellation<HttpConnection> waiter;
            while (true)
            {
                HttpConnection? connection = null;
                lock (SyncObj)
                {
                    _usedSinceLastCleanup = true;

                    int availableConnectionCount = _availableHttp11Connections.Count;
                    if (availableConnectionCount > 0)
                    {
                        // We have a connection that we can attempt to use.
                        // Validate it below outside the lock, to avoid doing expensive operations while holding the lock.
                        connection = _availableHttp11Connections[availableConnectionCount - 1];
                        _availableHttp11Connections.RemoveAt(availableConnectionCount - 1);
                    }
                    else
                    {
                        // No available connections. Add to the request queue.
                        waiter = _http11RequestQueue.EnqueueRequest(request);

                        CheckForHttp11ConnectionInjection();

                        // Break out of the loop and continue processing below.
                        break;
                    }
                }

                if (CheckExpirationOnGet(connection))
                {
                    if (NetEventSource.Log.IsEnabled()) connection.Trace("Found expired HTTP/1.1 connection in pool.");
                    connection.Dispose();
                    continue;
                }

                if (!connection.PrepareForReuse(async))
                {
                    if (NetEventSource.Log.IsEnabled()) connection.Trace("Found invalid HTTP/1.1 connection in pool.");
                    connection.Dispose();
                    continue;
                }

                if (NetEventSource.Log.IsEnabled()) connection.Trace("Found usable HTTP/1.1 connection in pool.");
                return connection;
            }

            // There were no available idle connections. This request has been added to the request queue.
            if (NetEventSource.Log.IsEnabled()) Trace($"No available HTTP/1.1 connections; request queued.");

            ValueStopwatch stopwatch = ValueStopwatch.StartNew();
            try
            {
                return await waiter.WaitWithCancellationAsync(async, cancellationToken).ConfigureAwait(false);
            }
            finally
            {
                if (HttpTelemetry.Log.IsEnabled())
                {
                    HttpTelemetry.Log.Http11RequestLeftQueue(stopwatch.GetElapsedTime().TotalMilliseconds);
                }
            }
        }

该方法检查HTTP连接集合中是否有可用连接,如果无可用连接则将请求添加到请求队列,然后调用CheckForHttp11ConnectionInjection方法来创建新连接并加入到连接集合中。过程如图17所示:

(图17)

CheckForHttp11ConnectionInjection 方法中会使用Task.Run 新启一个任务来完成HttpConnection的创建。Task.Run(() => AddHttp11ConnectionAsync(request));

综上,我们可以得到如下的框图:

(图18)

为了更好地理解程序以上行为,这里需要引出TaskScheduler[9](任务调度器)的概念。在.NET中,所有的任务执行都是依靠任务调度器进行调度的。默认情况下该调度器的实例是ThreadPoolTaskScheduler[10],可以通过 TaskScheduler.Default进行获取。 ThreadPoolTaskScheduler 是基于线程池 (ThreadPool[11]) 实现的。线程池是一种用于减少线程创建和销毁开销的技术,通过在一组线程中重用线程来提高性能。

以下是一些相关的核心概念:

  1. 工作项队列:线程池维护一个工作项队列,其中包含等待执行的任务。当任务被提交给线程池时,它将被添加到工作项队列中。线程池中的线程会在队列中检索并执行任务。

  2. 全局队列和本地队列:为了减少线程间竞争,线程池实现了全局队列和本地队列。全局队列包含所有待执行任务,而每个线程都有自己的本地队列。当线程需要执行任务时,首先尝试从本地队列获取任务,如果本地队列为空,再尝试从全局队列获取任务。这种设计可以减少锁竞争,提高性能。
  3. 线程创建:线程池会根据需要创建新的线程。初始线程池可能为空,但当有任务需要执行时,线程池会创建一个新线程来处理任务。为了避免线程过度创建,线程池会限制最大线程数。

  4. 线程重用:当线程完成任务并返回到线程池时,它不会被销毁,而是被重用以执行其他任务。这样可以降低线程创建和销毁的开销,提高性能。

  5. 线程回收:如果线程池中的线程在一定时间内闲置,它们将被回收以释放资源。线程回收策略可以根据配置进行调整。

  6. 工作窃取:当一个线程的本地队列为空,并且全局队列也没有任务时,该线程可以尝试从其他线程的本地队列窃取任务。这种工作窃取算法可以平衡线程负载,提高资源利用率。

  7. 任务调度优先级:较高优先级的任务会被优先执行,而较低优先级的任务会被推迟执行。ThreadPoolTaskScheduler会根据任务的优先级和可用线程的数量来分配任务给线程池中的线程。它会尽量平衡任务的执行,避免某些线程过度占用任务而导致其他线程空闲。

了解了这些概念之后,再结合.NET Runtime源码,我们可以得出一些结论了。 线程池初始只有几个线程,数量在0 ~ Environment.ProcessorCount(处理器核心数)个之间。我们使用Task新建了100个任务,并且没有指定优先级,这些任务会被ThreadPoolTaskScheduler调度进入线程池的全局队列(WorkItems Queue)中,接着线程池中仅有的线程都会从全局队列中获取任务并执行。 由于这些Task执行的是同一个方法,并且方法中使用了锁,因此第一个执行Task的线程将持有锁,直到任务完成后将锁释放(图18中Awaiting的任务),在此期间,其他执行Task的线程都将等待锁的释放(图18中Blocked的任务)。然而,全局队列中任务数量远远超过了当前线程池中线程数量,因此没有足够的线程执行剩余的Task,这些Task都是已计划未执行的状态(图18中Scheduled的任务)。针对线程不足的情况,.NET运行时会单独启动一个System.Threading.PortableThreadPool.GateThread线程(如图19所示)来动态调整线程池中线程的数量。在线程数量调整的初期(即线程数量小于Environment.ProcessorCount时),往线程池中注入线程的速度是很快的,几乎是即时的。当超过这个数量后,则是平均500ms新增一个线程。 新增的线程再次从全局队列中取得一个Task然后执行,但仍然被Block,需继续等待第一个Task所在线程释放持有的锁。这时Scheduled的任务数量减1,Blocked的任务数量加1。由于每次新增的线程都是因为等待锁释放被占用,因此GateThread不得不持续新增线程来完成工作。通常来说,只要我们执行的Task是非阻塞的或者耗时很短的,可能只需要少量的线程即可完成大量的Task,因为线程池可以复用线程。但是不巧的是,从图17的执行流程来看,第一个Task内部会使用Task.Run(() => AddHttp11ConnectionAsync(request));创建一个子任务来获取HttpConnection,这个子任务同样也没有设置任何优先级,它会被调度进本线程的本地队列,其TaskCreationOptions属性值为DenyChildAttach,微软官方是这样解释这个枚举值的:

Specifies that any child task that attempts to execute as an attached child task (that is, it is created with the AttachedToParent option) will not be able to attach to the parent task and will execute instead as a detached child task.

翻译过来就是:指定任何尝试作为附加的子任务执行(即,使用 AttachedToParent 选项创建)的子任务都无法附加到父任务,会改成作为分离的子任务执行。 这意味着子任务将在它自己的线程上执行,不会附加到父任务的线程上,也就是说子任务需要等待一个新的线程来执行它。但是因为前面还有很多Scheduled的任务已计划未执行,GateThread新注入线程池的线程也是按照FIFO的顺序去执行这些排队的任务。 因此,在Scheduled的任务数量变成0之前,上述子任务都等不到线程去执行它,而该子任务的父任务又在等待子任务的完成,其他任务又在等待“父任务”锁的释放。  到此,本程序的性能问题算是找到了原因。

(图19)

3. 解决方案

3.1 提前预热HttpClient

 从上面的分析来看,既然首个任务的子任务是要获取HttpConnection对象,假如HttpConnectionPool中已经有足够连接的话是不是就斩断了等待链?于是我们修改了源代码,在程序开头先实例化HttpClient,然后单独请求一次目标站点进行预热。经过测试,效果出奇地好,执行100个Task只需要200ms左右,线程池线程数量峰值12左右。 但是要注意的是,HttpClient预热后需尽快执行我们的并发模拟代码,否则HttpConnectionPool中的对象可能会被回收掉。

3.2 为任务指定TaskCreationOptions

 我们知道,当指定任务的TaskCreationOptions为LongRunning时,该任务将使用单独的线程执行,而不是线程池中的线程,这样就能避免线程争用的问题。ThreadPoolTaskScheduler中相关调度方法源码如下:

       /// <summary>
        /// Schedules a task to the ThreadPool.
        /// </summary>
        /// <param name="task">The task to schedule.</param>
        protected internal override void QueueTask(Task task)
        {
            TaskCreationOptions options = task.Options;
            if (Thread.IsThreadStartSupported && (options & TaskCreationOptions.LongRunning) != 0)
            {
                // Run LongRunning tasks on their own dedicated thread.
                new Thread(s_longRunningThreadWork)
                {
                    IsBackground = true,
                    Name = ".NET Long Running Task"
                }.UnsafeStart(task);
            }
            else
            {
                // Normal handling for non-LongRunning tasks.
                ThreadPool.UnsafeQueueUserWorkItemInternal(task, (options & TaskCreationOptions.PreferFairness) == 0);
            }
        }

因此,可以修改我们程序中创建任务的代码为: var t = new Task(() => LockLock(), TaskCreationOptions.LongRunning);经测试,执行100个Task需要960ms,线程池线程数量峰值为5左右,Window自带的资源监视器监测线程数量峰值为120。

3.3 设置线程池最小线程数

  从上文的实验来看,充足的线程的确能够较快地完成任务。所以我们可以调用线程池方法来设置最小线程数,如:ThreadPool.SetMinThreads(100, 100); 因为我们的任务数是100,我这里方法传递参数也是100。经测试,100个Task执行需要1.6秒。但是这种方法也是有缺点的,那就是我们并不知道我们面临的并发数是多少。一旦并发数超过我们设置的最小线程数,那么又会面临线程数不足的问题。超出得越多,响应时间越慢。

 

参考资料:

[1] 五维思考,.Net及.Net Core下HttpClient详解,简书

[2] Official Account, HttpClient类, Microsoft

[3] Official Account , 在 Visual Studio 中调试多线程应用程序,  Microsoft

[4] Sysinternals, 进程资源管理器 v17.04,Microsoft

[5] Eric Zhou,Windbg程序调试系列1-Mex扩展使用总结,CNBLOGS

[6] Eric Zhou,  .NET高级调试系列-Windbg调试入门篇, CNBLOGS 

[7] Eric Zhou,  Windbg程序调试系列1-常用命令说明&示例, CNBLOGS

[8] 凌晨三点半,.NET中ThreadPool与Task的认识总结,CNBLOGS

[9] Official Account, TaskScheduler类,Microsoft

[10] Official Account,ThreadPoolTaskScheduler源码,GitHub

[11] Official Account, ThreadPool类, Microsoft

[12] Official Account, HttpConnectionPool源码, GitHub

[13] Official Account,托管线程处理的最佳做法, Microsoft

posted @ 2023-07-01 04:28  X-Cracker  阅读(120)  评论(0编辑  收藏  举报