本文记录我将应用迁移到 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 应用跑起来,最后部署上应用,进行挂机
十分符合预期的,第二天应用挂掉了,而且系统提示 Xx 应用停止工作。通过 系统日志 可以看到存在应用错误异常,异常信息是 CLR Exception E0434352 也就是在 CLR 层面出现异常
我错误认为这是升级到 dotnet 6 时,由于 dotnet 6 和 Win7 的兼容性导致的问题,开始着手根据 CLR Exception E0434352 Microsoft Docs 官方文档的方法开始调查,然而却没有找到任何有用的信息
继续挂机到第三天,我这次采用任务管理器在 Xx 应用停止工作时,对应用抓一个 DUMP 传到我开发设备上,使用 VisualStudio 的混合调试进行调试,此时发现错误信息和第二天的不相同了,这次显示的是 OutOfMemory 相关异常。但是我在 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 <PID>
命令,这里的 <PID>
就是要抓取的进程的 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 开源仓库 https://github.com/dotnet/runtime/ 里翻 dotnet 6 的内存相关的帖子,好在没有找到任何有关联的有帮助的,那就侧面证明了,应该是没有其他人遇到了此问题,这是一个好消息。但也许不是,那就是我是第一个遇到的人。其次,由于我采用的是 dotnet 6.0.1 版本,分发给用户端的不敢那么头铁用刚发布的版本,官方最新的是 dotnet 6.0.4 版本,也许在某个安全更新修复了此问题,安全更新有一些是保密的,也就是说我没有能找到,如果强行去找,可以用 MVP 权限去寻找,但这个响应速度就没有那么快
接下来可以调查的方向如下
是否 dotnet 6 底层带来的问题
是否 dotnet 6.0.1 带来的问题,但在 dotnet 6.0.4 修复了
确认是否 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 框架才出现问题。那可能的原因实际上可以分为三个:
迁移 dotnet 6 过程中,与 .NET Framework 的变更导致的问题
由于 dotnet 6 的机制变更,与 .NET Framework 的不相同,导致的内存回收策略变更的内存泄露问题,例如之前遇到的委托问题
这就是 dotnet 6 底层与 Win7 适配的问题
由于 Xx 应用是一个足够复杂的大型应用,不好定位以上的三个原因。于是采用对比测试法,先创建一个空白的 dotnet 6 的 WPF 应用,在此 Win7 上运行。十分符合预期的,没有内存泄露问题。这能证明,不是那么简单的 dotnet 6 的底层的问题。假如使用空的 dotnet 6 的 WPF 应用也能存在内存泄露,那就能快速定位是 dotnet 6 底层的问题,接下来的步骤就是看是否 WPF 的问题还是 dotnet 更底层的问题,毕竟这个 WPF 是我定制的版本,改了不少的内容
再定位是否迁移 dotnet 6