0

    Docs官方文档的方法开始调查,然而却没有找到任何有用的信息

    2023.08.08 | admin | 133次围观

    本文记录我将应用迁移到 dotnet 6 之后,在 Win7 系统上,因为使用 HttpWebRequest 访问一个本地服务,此本地服务开启 https 且证书链在此 Win7 系统上错误,导致应用内存泄露问题。本文记录此问题的原因以及调查过程

    核心原因

    核心原因是在 CRYPT32.dll 上的 CertGetCertificateChain 方法存在内存泄露,更底层的原因未知

    在 .NET 6 里,更新了 https 访问方法逻辑,详细请看Announcing .NET 6 - The Fastest .NET Yet - .NET Blog和What’s new in .NET 6 Microsoft Docs

    核心问题是调用进入ChainPal.BuildChain时,将会调用Crypt32.CertGetCertificateChain方法的调用逻辑有所变更,此进入逻辑和 .NET Framework 4.5 有所不同。准确来说,此差异不是 .NET 6 与 .NET Framework 4.5 的差异,而是 .NET Framework 4.6 以及更高版本与 .NET Framework 4.5 的差异

    在 .NET Framework 4.6 时引入Switch.System.Net.DontEnableSchUseStrongCrypto变更是导致此问题的关键,在 .NET Framework 4.5 下,默认是 true 的值,但是在 .NET Framework 4.6 和更高版本下都是 false 的值。这就导致了整体逻辑的行为差异。此逻辑差异只和 SDK 相关,而和用户端所安装的运行时无关

    但是此差异是否一定导致内存泄露,这是未知的。但内存泄露必定走了此调用逻辑

    解决方法

    如 SDK 提示,使用 WebRequest.Create 等方法创建 HttpWebRequest 用来进行网络请求逻辑是一个过时的方法,应该换用 HttpClient 等代替。经过实际的测试,换用 HttpClient 即可完美解决内存泄露问题,顺带提升了不少的性能

    也就是说此内存泄露从业务上说是使用了一个过时的 API 导致的问题

    调查过程

    在开始记录调查过程之前,还请看一下背景

    如上一篇博客记将一个大型客户端应用项目迁移到 dotnet 6 的经验和决策 - lindexi - 博客园我在完成了迁移了此大型应用到 dotnet 6 发布到内测用户端,有内测小白鼠反馈说第二天过来就看到应用挂掉了

    一开始没有认为这是一个问题。等到第二个用户反馈时才开始认为这是一个坑,开始进行调查

    以下调试过程非新手友好,请新手一定不要阅读下文,如果阅读了也一定不要在调试内存泄露使用下面的方法

    通过分析应用本身的日志,了解到应用是被闪退的。询问内测的用户了解到,应用闪退的时候,都是在晚上挂机的时候,这时候没有任何的用户动作。为了尽可能干掉环境问题带来的干扰,我搭建了虚拟机,使用cn_windows_7_ultimate_with_sp1_x64_dvd_u_677408.iso安装了纯净的系统,再加上 KB2533623 补丁让 dotnet 6 应用跑起来,最后部署上应用,进行挂机

    十分符合预期的win7管理工具里没有本地安全策略,第二天应用挂掉了,而且系统提示 Xx 应用停止工作。通过 系统日志 可以看到存在应用错误异常,异常信息是 CLR Exception E0434352 也就是在 CLR 层面出现异常

    我错误认为这是升级到 dotnet 6 时,由于 dotnet 6 和 Win7 的兼容性导致的问题,开始着手根据CLR Exception E0434352 Microsoft Docs官方文档的方法开始调查,然而却没有找到任何有用的信息

    继续挂机到第三天,我这次采用任务管理器在 Xx 应用停止工作时,对应用抓一个 DUMP 传到我开发设备上,使用 VisualStudio 的混合调试进行调试,此时发现错误信息和第二天的不相同了,这次显示的是 OutOfMemory 相关异常。但是我在 Win7 虚拟机上win7管理工具里没有本地安全策略,使用任务管理器看到的 Xx 应用占用的内存实际上才 250 MB 而已,这一定是在讽刺我

    好在我反应过来,任务管理器上面看到的应用占用 250MB 内存,完全不等于应用使用的内存是 250MB 的空间。为什么呢?这是一个复杂的问题,我不想在本文这里聊 Windows 下的应用内存知识,也许后续会另外开一篇很长的博客来说明。需要了解的是,如果一个应用 OOM 了,那除了系统本身给不到应用足够的内存之外,还有另一个问题就是应用本身用到了平台限制的最大内存数量。别忘了 x86 和 x64 的差异

    刚好,此 Xx 应用是一个 x86 应用。在通过系统日志了解到此 Win7 虚拟机上没有存在一刻是内存不足的情况,而且此纯净的虚拟机也就跑了 Xx 一个应用,要是内存不足,也是 Xx 应用的锅。回忆一下,使用 x86 应用,默认的进程空间是 4G 大小,其中有 1 到 2G 需要给系统交税,也就是应用在开启大内存感知时,最大能用到 3G 的内存。如果应用在到达 3G 内存占用附近时,依然向系统申请内存,那此时就 OOM 了

    任务管理器说应用占用了多少内存,实际上如果是以上的申请内存超过 x86 平台限制的导致的问题,那完全必须无视任务管理器说的话。特别是在用户端,别忘了还有EmptyWorkingSet这样安慰人的方法

    我通过拿到 DUMP 文件的大小,看到 DUMP 文件是接近 4G 的大小,猜测是 Xx 应用申请内存超过 x86 平台限制。调查此问题需要用到微软极品工具箱的VMMap工具

    通过 vmmap 可以看到此时的应用的 Private Data 占用达到接近 3G 的大小,因此可以定位到 Xx 应用闪退的原因是因为申请内存超过 x86 平台限制

    也就是说有两个分支导致 Private Data 占用过多,第一个原因就是业务需要申请大量的内存空间,第一个原因不算是内存泄露问题,只能算是性能优化问题,某个业务逻辑空间复杂度过高。第二个原因就是应用内存泄露,应用不断运行过程中,不断泄露内存,运行的时间长了,自然多少内存都不够用

    换句话说,不是所有的 OOM 问题,都是内存泄露问题,可能还是业务需要申请大量的内存空间问题。但显然,本次遇到的问题,应该就是内存泄露问题了。毕竟只是挂机就让应用挂掉了,那大概确定是内存泄露了。但是这只能说大概,万一有一个定时任务是从后台拉取某个数据,刚好这个数据导致了某个处理业务需要申请大量的内存,从而让应用挂掉。为了确定是哪个方式导致的 OOM 了,可以先使用排除的方式,如果是某个业务申请大量的内存导致内存泄露,这是非常好也非常方便调试出来的,只需要使用 dotMemory 工具分析一下即可

    在开始使用 dotMemory 之前,还遇到一个小问题,那就是 dotMemory 不能在我的 Win7 虚拟机上运行,而我又不想去污染此虚拟机环境。好在 dotMemory 可以分析 DUMP 文件,于是我就拿来刚才使用 任务管理器 抓的 DUMP 文件进行分析。可惜,由于 Win7 虚拟机采用的是 X64 系统,而应用是 X86 应用,导致任务管理器抓的 DUMP 文件无法被 dotMemory 识别,只能再次换用专业ProcDump工具去抓进程的 DUMP 文件

    换用ProcDump工具去抓应用的 DUMP 文件用起来比任务管理器更加方便,我也推荐使用 ProcDump 去抓 DUMP 文件,这个工具是十分强大的,本文用到的只是很少的功能。由于这个工具太强大了,要介绍的话,也是另一篇博客了,本文也不会包含此工具的更多使用方法

    在虚拟机上面使用procdump -ma

    命令,这里的

    就是要抓取的进程的 Id 号,将 Xx 应用抓取 DUMP 文件,然后再用 7z 压缩一下,传回到我的开发设备上,用 dotMemory 打开分析。使用 7z 是因为可以很大的压缩 DUMP 文件。通过 dotMemory 分析没有看到有哪个业务使用了大量的内存,总的 .NET 内存占用实际上才不到 100MB 大小。因此大概可以确定不是因为某个业务申请大量的内存导致内存泄露,至少不是申请托管内存

    继续回到确定 OOM 导致的原因上,我重新运行 Xx 应用,通过 VMMap 工具不断按 F5 刷新,经过三个小时间断追踪,可以看到 Private Data 缓慢上涨。通过此,可以判断是内存泄露问题

    内存泄露通用处理方法就是先抓取泄露点,通过泄露点了解泄露模块。抓取泄露点的通用方法就是对比几段时间点,有哪些对象被创建且不被回收。依然是使用ProcDump工具抓取 DUMP 文件,然后通过 dotMemory 的导入 DUMP 功能,以及对比内存功能,进行分析

    如果要是 dotMemory 可以符合预期的让我看到业务模块上有哪些对象没有被释放,那自然就不会有本文的记录,毕竟如此简单就能解决的问题,要是还水一篇博客就太水了。通过 dotMemory 抓取可以看到不同的时间点上,没有任何业务代码的对象泄露。唯一新建的几个对象都是 System.Net 命名空间下的,而且占用的托管内存也特别小,这几个对象的根引用都是 Ssl 相关的底层模块,看起来似乎没有问题

    也如一开始的调查,泄露的部分似乎不在 .NET 托管上,而是非托管的泄露。对一个纯 .NET 应用来说,可以认定所有的非托管泄露都是由托管导致的。但是可惜 Xx 应用是一个复杂的应用里面包含了其他团队写的一点库逻辑。于是先尝试定位一下是否迁移过程,修改了部分的C++\CLI逻辑导致的内存泄露。定位的方法是采用二分法,也就是干掉这些引入的库的逻辑。我重新写了代码,用 Fake 的方式重新实现了假逻辑,将所有的其他团队写的非 .NET 的库的文件都删掉

    可惜删除了其他团队写的非 .NET 的库之后,依然存在内存泄露。也就是说可以确定是在托管层存在内存泄露的,此时我特别怕是迁移到 dotnet 6 导致的,和 Win7 的适配问题。而用 dotMemory 也无法给我带来更多的帮助,用 dotMemory 最预期的能拿到的信息就是业务端有某些对象被泄露,可惜没有找到任何业务端的对象泄露。那此时用 VisualStudio 是否有更多信息?不会有的,放心吧,在调试内存泄露方面,使用 VisualStudio 和 dotMemory 的能力是完全相同的,只是 VisualStudio 的交互做的太过垃圾,完全不如 dotMemory 的交互形式。因此用 dotMemory 没有带来更多帮助,同理使用 VisualStudio 也不会有更多帮助

    为了确定是否 dotnet 6 底层带来的问题,我先在 dotnet 开源仓库里翻 dotnet 6 的内存相关的帖子,好在没有找到任何有关联的有帮助的,那就侧面证明了,应该是没有其他人遇到了此问题,这是一个好消息。但也许不是,那就是我是第一个遇到的人。其次,由于我采用的是 dotnet 6.0.1 版本,分发给用户端的不敢那么头铁用刚发布的版本,官方最新的是 dotnet 6.0.4 版本,也许在某个安全更新修复了此问题,安全更新有一些是保密的,也就是说我没有能找到,如果强行去找,可以用 MVP 权限去寻找,但这个响应速度就没有那么快

    接下来可以调查的方向如下

    确认是否 dotnet 6 底层带来的问题刚好在我这个项目上,没有那么麻烦。我对比测试了在 Win10 的设备上,发现没有内存泄露。刚好 Xx 应用是从 .NET Framework 迁移过来的,现在改改代码还能跑 .NET Framework 的版本,于是也就同步在出现问题的 Win7 上跑 .NET Framework 的版本,结果发现在 Win7 上使用 .NET Framework 版本没有任何问题。于是大概可以确定,这和 dotnet 6 底层是有所关联,但不能说这是 dotnet 6 底层的锅

    接下来确定是否 dotnet 6.0.1 带来的问题,但在 dotnet 6.0.4 修复了的问题。我在此出现问题的 Win7 上,使用 dotnet 6.0.4 版本代替原先的 6.0.1 版本,好在 dotnet 6 是不需要安装的,替换文件即可。结果依然存在内存泄露,这是一个坏消息。也就是说也许我是第一个遇到此问题的人,或者说这是一个官方也不知道的问题。我就尝试去面向群编程,询问了几位大佬是否遇到过此问题,然而所有的回答都和本次遇到的不是相同的问题,且没有一位大佬遇到 dotnet 6 底层的内存泄露问题,这也算是好消息

    回到测试 dotnet 6 底层带来的问题上,既然对比了 .NET Framework 和 dotnet 6 两个框架,发现只有在 dotnet 6 框架才出现问题。那可能的原因实际上可以分为三个:

    由于 Xx 应用是一个足够复杂的大型应用,不好定位以上的三个原因。于是采用对比测试法,先创建一个空白的 dotnet 6 的 WPF 应用,在此 Win7 上运行。十分符合预期的,没有内存泄露问题。这能证明,不是那么简单的 dotnet 6 的底层的问题。假如使用空的 dotnet 6 的 WPF 应用也能存在内存泄露,那就能快速定位是 dotnet 6 底层的问题,接下来的步骤就是看是否 WPF 的问题还是 dotnet 更底层的问题,毕竟这个 WPF 是我定制的版本,改了不少的内容

    再定位是否迁移 dotnet 6 过程中,与 .NET Framework 的变更导致的问题,我寻找了所有的变更逻辑,逐个还原,或者使用 Fake 逻辑,干掉对应的功能。这个过程相当于一个二分,也就是说如果在干掉了某些功能之后,没有出现内存泄露,那就能定位内存泄露和被干掉的功能相关。完成之后,同时构建出 dotnet 6 和 .NET Framework 两个版本,在此 Win7 上运行。结果依然是 dotnet 6 版本存在内存泄露,而 .NET Framework 版本没有内存泄露

    这就证明了原因可能就是 由于 dotnet 6 的机制变更,与 .NET Framework 的不相同,导致的内存泄露。但经过以上的测试,不能说明一定是 内存回收策略变更的内存泄露问题

    到这里,其实基本没有了通用套路可以定位的方法了。除了使用二分法,使用二分法逐个模块干掉,看干掉到哪个模块就不存在内存泄露问题。但在此 Xx 应用上使用二分法是一个大工程,再加上内存泄露的判断是需要等待一段时间的。而不是快速就能定位出来,需要通过 VMMap 经过一段时间,按照小时为单位,看 Private Data 的占用,才能了解到是否内存泄露。以上的测试都是可以并行多个同时开始的,尽管每个测试都需要占用半天的时间,好在多个测试并行,以上的测试都在一天内完成。但如果采用二分,那就意味着需要进行串行测试,在上次没有测试完成之前,是无法进行下一个二分的。我就将二分作为最后的方法,继续找找其他的方法

    回顾一下,使用 .NET Framework 没有问题,只有 dotnet 6 版本存在内存泄露。通过 dotMemory 和 DUMP 没有找到业务对象的内存泄露,只有某几个 System.Net 命名空间下的对象存在,这些对象不确定是否泄露。更新了 dotnet 6.0.4 也没有解决,也没有搜到帖子,问了大佬们也没有遇到相同的问题,也就是说不是 dotnet 的官方已知问题

    既然看到了存在 System.Net 命名空间下的对象存在,那可以猜测是和网络相关的问题,刚才的 dotnet 6 的空 WPF 测试应用只能证明和基础的 dotnet 6 无关,但没有证明和网络模块无关。继续写一个访问网络的 demo 项目,运行发现没有内存泄露问题,看起来此内存泄露问题也不是那么简单能复现,一半是好消息,一半是坏消息。刚好waterlv大佬有空回复我了,他告诉我,内存不会无缘无故上涨的,一定是有某些业务逻辑在跑。于是另一个方向是放弃内存的方向,而是调查空闲的时候运行了哪些逻辑

    调查某个应用在某段时间运行了哪些逻辑,这是一个 CPU 性能调试问题,相当于调查一段时间内,有哪些逻辑占用了 CPU 资源。调查这个问题最好用的工具就是 dotTrace 工具了。我准备在此 Win7 使用 dotTrace 工具抓 Xx 应用的信息,可惜 dotTrace 工具无法在此 Win7 运行,原因有两个,一个是需要 .NET Framework 4.7 的环境,另一个就是 ETW 准备失败。其中 ETW 准备失败也就无法抓取信息,于是我放弃了 dotTrace 工具

    刚好 dotnet 系里面有 dotnet trace 工具,此工具可以完美在 Win7 运行。于是我换用 dotnet trace 工具去抓取,虽然是抓取到了信息,但是 dotnet trace 工具比 dotTrace 工具还是差太远了,差距大概是一个是记事本,一个是 SublimeText 的差距,我没有成功分析出来什么,反而又过去了一天

    那换一个方式,通过 DUMP 抓取瞬时的线程调用堆栈,可以看到有很多线程存在,但是基本上都是不在运行的线程。唯一一个看起来稍微相关的堆栈如下

    > ntdll.dll!_ZwWaitForMultipleObjects@20() Unknown
    KERNELBASE.dll!_WaitForMultipleObjectsEx@20() Unknown
    kernel32.dll!_WaitForMultipleObjectsExImplementation@20() Unknown
    kernel32.dll!_WaitForMultipleObjects@16() Unknown
    winhttp.dll!HANDLE_OBJECT::IsInvalidated(void) Unknown
    winhttp.dll!OutProcGetProxyForUrl(class INTERNET_SESSION_HANDLE_OBJECT *,unsigned short const *,struct WINHTTP_AUTOPROXY_OPTIONS const *,struct WINHTTP_PROXY_INFO *) Unknown
    winhttp.dll!_WinHttpGetProxyForUrl@16() Unknown
    cryptnet.dll!InetGetProxy(void *,void *,unsigned short const *,unsigned long,struct WINHTTP_PROXY_INFO * *) Unknown
    cryptnet.dll!InetSendAuthenticatedRequestAndReceiveResponse(void *,void *,unsigned short const *,unsigned short const *,unsigned char const *,unsigned long,unsigned long,struct WINHTTP_PROXY_INFO *,struct _CRYPT_CREDENTIALS *,struct _CRYPT_RETRIEVE_AUX_INFO *) Unknown
    cryptnet.dll!_InetSendReceiveUrlRequest@32() Unknown
    cryptnet.dll!CInetSynchronousRetriever::RetrieveObjectByUrl(unsigned short const *,char const *,unsigned long,unsigned long,struct _CRYPT_BLOB_ARRAY *,void (**)(char const *,struct _CRYPT_BLOB_ARRAY *,void *),void * *,void *,struct _CRYPT_CREDENTIALS *,struct _CRYPT_RETRIEVE_AUX_INFO *) Unknown
    cryptnet.dll!_InetRetrieveEncodedObject@40() Unknown
    cryptnet.dll!CObjectRetrievalManager::RetrieveObjectByUrl(unsigned short const *,char const *,unsigned long,unsigned long,void * *,void *,struct _CRYPT_CREDENTIALS *,void *,struct _CRYPT_RETRIEVE_AUX_INFO *) Unknown
    cryptnet.dll!CryptRetrieveObjectByUrlWithTimeoutThreadProc(void *) Unknown
    kernel32.dll!@BaseThreadInitThunk@12() Unknown

    看起来和系统的 cryptnet.dll 有几毛钱关系,也许这是 Win7 一个已知的问题,也许更新了某个补丁能解决。到这里想要继续就只能通过 WinDbg 了,玩 WinDbg 工具需要花太多的时间,于是我先挂着 WinDbg 在 Win7 系统上,拉符号文件,将我本机的符号文件夹共享给他。拉取符号和共享符号文件夹需要半天的时间,我也不能摸鱼。似乎走 CPU 分析这个路是不可行的。继续回到分析内存的方法

    继续猜测是网络相关问题,好在使用的是虚拟机,我听了waterlv大佬的方法,禁用了网卡,跑了一个晚上,没有内存泄露。那基本可以定位和网络问题是强相关了。于是开启 Fiddler 准备抓数据,默认的 Fiddler 是没有抓 Https 的请求的,我分为两个阶段,先抓 http 的请求,结果发现 Xx 应用没有任何 http 请求。开启 Fiddler 的抓取 https 请求,结果发现有某些请求发出,但是此时诡异的是 Xx 应用不再有内存泄露了

    我根据 Fiddler 抓 Https 请求的原理猜测是因为 Fiddler 为了抓取 Https 安装的证书导致 Xx 应用的行为和之前不同,从而没有内存泄露问题。于是做对比测试,关掉 Fiddler 的抓 https 功能,重启 Xx 应用,跑了半天,内存泄露

    大概可以定位到和证书相关,继续定位是和请求哪个链接相关,从代码里面进行二分逻辑,从 Fiddler 里面抓到的各个请求的代码,逐个干掉,终于被我定位到核心的问题所在。我的另一个本机的服务应用,这是一个在本机开启的进程服务,通过 Https 进行 IPC 本机跨进程通讯。业务模块和这个本地服务应用有心跳通讯,每次通讯都是内存泄露。那为什么这个本地服务应用的通讯会让 Xx 应用内存泄露,根据 Fidder 的证书问题我猜测和证书相关。重新阅读这个服务应用的代码,以及请教了lsj证书相关知识点之后,了解到这个服务应用,采用的证书有点问题,这个服务应用的证书链是不完整的,刚好在此 Win7 系统上,证书也都没有更新

    解决的方法有几个:

    版权声明

    本文仅代表作者观点。
    本文系作者授权发表,未经许可,不得转载。

    发表评论