作为 .Net 攻城师,所必需掌握的 .Net Profiling 技术

1811 查看

众所周知,性能问题是所有实用应用在迭代过程中必然要面对的问题。对于此类问题,简单地投入更多硬件资源的做法可能会取得一定效果。但总的来看,此类做法的边际成本是不断上升的。换言之,随着性能需求的上涨,要换取同样的性能提升,仅凭硬件升级所需要的成本会越来越高。故而性能优化是每一位运维/软件开发人员必须掌握的技术。

.Net Profiling

在进行应用性能优化实践时,首先面对的就是热点定位,即确定那些带来巨大资源耗散的代码位置。而在不借助外部工具的前提下,定位资源热点是一件相当困难的事。它需要当事人对于应用实现本身有一个整体的把握,了解应用架构内每一个功能模块代码的路径与细节。与此同时,当事人还要对于应用实现所依赖的第三方功能库的表现有一定的把握。对于那些具备一定规模的应用系统,具备前述素质的工程师的数量屈指可数。而即便是这些百里挑一的优秀人才,其热点预估也不能保证一定是准确的。

Profiling 技术 的提出正是为了解决热点定位而提出的,它以程序的实际运行数据来帮助工程师们来理解应用行为,极大地简化了工程师们的工作。而对于像 .Net 这样比较成熟的技术栈,专家们(如 Bill Chiles赵颉)都推荐工程人员通过实际的 Profiling 数据来定位性能瓶颈。实际操作中,获取 .Net Profiling 数据的功能被实现为一个被称为 Profiler 的 COM 组件实体。下文中,我们将就 Profiler 本身的实现进行一些探讨。

Profiling API

.Net Profiler 在本质上是 CLR 的一个插件,它通过应用 Profiling API 来保持与 CLR 的信息沟通,并以此获取到 .Net 应用的运行时数据。通常来说,Profiler 的实体都表现为一个动态链接库(即 .dll 文件),CLR 在运行时会去加载该库(CLR 加载 Profiler 的详细配置可以参考 这篇文档),并在程序运行的特定阶段向库发送信息并接受库所返回的信息。

需要特别强调的一点是,虽然被称作是 Profiling API,但 CLR 的这套接口能做的可不仅仅是简单地度量应用的运行时间和内存耗散。实践上,profiling API 能够完成诸如代码覆盖、运行时插入等许多高级功能。不过,正如 MSDN Profiling 综述文档 所强调的,Profiling 对于应用本身应该透明。也就是说,在编写应用的时候,开发人员不应该在自己的逻辑中依赖 Profiler 或者被其影响。

对于 .Net 技术栈而言,由于环境本身引入了 application domain, GC, managed exception handling, JIT 等高级特性,Profiling 所展现的就不能仅止于应用运行所消耗的时间或者内存。为了能够真正地表现出运行时行为,Profiling API 中提供了包含这些特性的数据的接口。这就使得 Profiling API 在设计并不是如很多人想得那样直观。

常见的 .Net Profiler 实现多采取如下架构:



.Net Profiler 架构

其中,ICorProfilerCallbackICorProfilerInfo 就是 Profiling API 中最常被应用到的两个。在应用运行时,Profiler DLL 会被加载到应用所在的进程中。通过实现 ICorProfilerCallback 下特定功能的接口,Profiler DLL 会在应用运行时收到相关的动作执行通知。例如,如果在 Profiler DLL 中实现了 ICorProfilerCallback::AssemblyLoadFinished 接口,那么在应用运行中每加载完一个 程序集 时,Profiler DLL 中该接口的实现代码就会被调用。与此类似,Profiler DLL 可以靠实现 ICorProfilerInfo 下的接口来完成对被监测应用状态的获取。

需要补充说明一点,上文所说的 ICorProfilerCallback 接口实际上存在有 ICorProfilerCallback ~ ICorProfilerCallback7 这样7个版本的接口定义。高标号的接口版本向下兼容,但会提供新的功能扩展。不过,更高标号的接口往往也需要有更新版本的 CLR 来支持(如调用 ICorProfilerCallback7 需要在环境中部署 .Net Framework 4.6.1 以上版本),在实际使用时需要多加注意。

目前,Profiling API 可以被任何非托管的 COM 兼容的语言所调用。另外,API 本身的实现非常高效的,不会带来大到足以导致 profiling 失效的额外性能负担。也正因此,基于 Profiling API 完全可以实现一个抽样 profiler(对于 profiling 模式的探讨可参照 这篇文献 的 2.1 章节内容)。

目前 Profiling API 所支持的特性

正如前文所述,Profiler 对于程序行为的描述源自 profiling API 所提供的信息。在目前版本中,凭借 profiling API 能够获取到下列事件的消息通知:

  1. CLR 的启动与关停
  2. application domain 的创建与关闭
  3. 程序集的加载与卸载
  4. 模块(Module)的加载与卸载
  5. COM vtable 的创建与销毁
  6. JIT 编译与 code-pitching 的出发
  7. 类的加载卸载
  8. 线程的创建与销毁
  9. 函数的进入与返回
  10. 托管代码与非托管代码的执行切换
  11. 运行时挂起
  12. 运行时堆内存信息与 GC 活动

随着 .Net 技术的演进,未来的 Profiling API 或许能够提供更多的信息。不过,以下功能点是 Profiling API 不会实现的,请在应用时回避:

  1. 非托管代码的执行信息
  2. 运行时修改自身代码的应用的 Profiling(如 AOP)
  3. 边界检验
  4. 远程 profiling
  5. 高可靠性环境下的 profiling

线程相关

对于加载了 Profiler DLL 的进程而言,其在创建新线程时,新线程本身也会产生 ICorProfilerCallback 接口下定义的各种事件通知。这一过程中,Profiler 不必去显式地指定一个 ThreadID 以使得 Profiling API 生效。同样的,Profiler 完全可以简单地在代码中使用 thread-local 的存储方式,用不着费心地去进行存储位置的全局重定向。

当然,还是有一些要点需要我们在并发背景下留意。例如,Profiling API 本身并不能保障数据结构的线程安全,因此我们需要在可能产生并行访问的地方给 Profiler 代码冲突区加锁以保证 Profiler 的行为符合预期。同样,对于多线程的应用场景来说,Profiler 不应该假设 ICorProfilerCallback 的各个接口存在一定的先后顺序。举例来说,一个有两个同样线程的程序在运行时可能会先产生一个 FunctionEnter 然后才产生 ICorProfilerCallback::JITCompilationFinished。

还有一个线程相关的问题是来自于 COM 接口的。上文中我们说过 Profiler 事实上是实现为一个 COM 组件的,但其实 CLR 在运行时并不会去初始化 COM。这是为了避免在应用代码指定线程模型前,CLR 调用 [CoInitialize][Ref12] 来指定应用线程模型。同样地,在 Profiler 内部,不要去调用 CoInitialize 以避免与应用代码产生冲突。

调用栈

获取调用栈信息是应用 Profiling 时的一项关键需求。针对这一需求,Profiling API 提供给 Profiler 编写者两种实现方式:栈快照和倒影栈。

栈快照是在特定时刻对特定线程调用栈的一次追踪。需要注意,Profiling API 仅支持对栈上托管函数的追踪。如果 Profiler 需要追踪栈上的非托管函数,则需要自己提供一个栈遍历器出来。读者如对 Profiler 栈快照机制的实现详情感兴趣,可以参照 Divid Broman 的 这篇博客 内容。

频繁使用栈快照会为 CLR 带来过多的额外性能损耗。因此,如果需要频繁进行栈追踪,那么 Profiler 应该通过 FunctionEnter2, FunctionLeave2, FunctionTailCall2 及 ICorProfilerCallback::Exception* 等一系列接口构建出当前应用调用栈的一个倒影。如此,Profiler 即可低消耗地进行栈追踪操作。

其他需要留意之处

前文强调过,Profiler 是一个非托管的 DLL 库,会在应用运行时被加载到 CLR 中并与应用处于同一进程空间下。如此,Profiler DLL 实质上是不受托管代码的访问控制的。其运行唯一的限制就是运行 Profiler 的 OS 用户必须拥有足够权限。因此,对于要部署 Profiler 的技术人员来说,必须要明白这其中可能的风险,提早进行准备。例如可以把 Profiler DLL 加到访问控制列表(ACL)中以免恶意用户对其加以利用。

还有,Profiler DLL 作为 CLR 的一个插件,其运行错误可能会引起 CLR 本身的崩溃,在实施时一定要足够小心。而对于那些运行在栈内存空间紧张的环境下的 Profiler,要警惕因为 ICorProfilerCallback 导致栈溢出而引起的应用崩溃。在这种资源受限的环境中,要尽可能地减少 Profiler 自身的资源耗散。尽力避免原本能够运行的应用因为 Profiler 而导致无法运行的情况。

结语

本文简述了 .Net Profiling 技术的总体情况,并就其中的一些重要技术点进行了阐述。希望能帮助读者初步理解 .Net Profiling 技术。后续,我们将具体到代码实施层面,就 .Net Profiling 的实现进行详细讨论

OneAPM 助您轻松锁定 .NET 应用性能瓶颈,通过强大的 Trace 记录逐层分析,直至锁定行级问题代码。以用户角度展示系统响应速度,以地域和浏览器维度统计用户使用情况。想阅读更多技术文章,请访问 OneAPM 官方博客
本文转自 OneAPM 官方博客