托管和本机代码互操作性最佳实践 By Jesse Kaplan
在 2009 年伊始的《MSDN 杂志》中 看到这样一个专栏多少有些出人意料——自 2002 年的 1.0 版起,Microsoft .NET Framework 中已经支持托管代码或本机代码互操作性,格式都大同小异。此外,详细的 API 和工具级文档及支持文档也随处可得。但它们都不包含全面系统的指南,说明何时使用互操作、您应该考虑哪些体系问题以及使用哪种互操作技术。现在我就来弥补 这一漏洞。
托管和本机代码互操作适用哪种场合?
有关使用托管和本机代码互操作适宜时机的论述并不多,现有的论述也以自相矛盾者居多。有时,指南还缺乏实践体验做依据。因此,我先声明我编写的这个指南以我们互操作团队的实践经验为基础,它已向各类内部和外部客户提供过帮助。
在总结这一经验时,我们采纳了三种产品,由它们充当成功使用互操作和典型使用方式的上佳示例。提及互操作时,我首先想到的应用程序就是 Visual Studio Tools for Office,它是 Office 的托管扩展性工具集。它代表互操作的一种典型使用情况——一个想要启用托管扩展或加载项的大型应用程序。另一个就是 Windows Media Center,从一开始,它就是一个混合了托管和本机的应用程序。开发 Windows Media Center 主要使用的是托管代码和本机代码中内置的一些内容,即负责直接处理 TV 调谐器和其他硬件驱动程序的代码段。最后是 Expression Design,一个具备大型预置本机代码库的应用程序,它计划利用 Windows Presentation Foundation (WPF) 这一新的托管技术,提供全新的用户体验。
这三个应用程序解释了使用互操作的三个最普遍的原因:让原有的本机应用程序具备托管扩展性;让应用程序的大部分内容能利用托管代码的优点,同时又能在本机代码中编写最基础的代码段;为现有本机应用程序注入全新的用户体验。
过 去,指南中给出的对策是用托管代码彻底重新编写应用程序。采纳这一建议并目睹许多人将其拒之门外之后,您会清楚这一方案对于大部分现有应用程序来说都不适 用。互操作非常有助于开发人员维护其在本机代码中的投资,同时还能让他们利用新的托管环境。如果您由于其他原因计划重新编写应用程序,托管代码是个不错的 选择。但一般而言,您不想只为使用新的托管技术而重新编写程序,因此也就谈不上互操作。
互操作技术:三种选择
.NET Framework 中有三种主要的互操作技术,具体选用哪种由您用于互操作的 API 类型及控制边界的要求和需要决定。
Platform Invoke(或 P/Invoke)基本上是从托管到本机的互操作技术,您可以用它从托管代码调用 C 类本机 API。
您还可以使用 COM interop 技术从托管代码使用本机 COM 接口,或从托管 API 导出本机 COM 接口。
最后是 C++/CLI(先前称为托管 C++),它允许您创建包含托管和本机 C++ 混合编译代码的程序集,该程序集旨在为托管代码和本机代码搭建起沟通的桥梁。
互操作技术:P/Invoke
P/Invoke 是三种技术中最简单的一个,它的主要功能是让托管代码能访问 C 类 API 。使用 P/Invoke 时,您需要分别封装每个 API。如果要封装的 API 数量不多且其签名也不复杂,这是个不错的选择。但是,如果 API 有很多参数,且这些参数没有好的托管对等项,如变量长度结构、void *、重叠的共同体等,那么 P/Invoke 使用起来会相当难。
.NET Framework 基类库 (BCL) 包含 API 的多个示例,它们就是多个 P/Invoke 声明外部厚实的包装。在包装非托管 Windows API 的 .NET Framework 中,几乎所有功能都是使用 P/Invoke 构建的。实际上,即便是 Windows 窗体,也差不多完全是使用 P/Invoke 在本机 ComCtl32.dll 基础上构建的。
这里有几个非常有用的资源,可以极大地降低 P/Invoke 的使用难度。首先,pinvoke.net 网站上有一个 wiki,最初是由 CLR 互操作团队的 Adam Nathan 设置的,里面有大量由用户为各种通用 Windows API 贡献的签名。
还有非常便于使用的 Visual Studio 加载项,利用它可以轻松从 Visual Studio 访问 pinvoke.net。对于 pinvoke.net 上没有的 API(可能是您自己或他人库中的 API),互操作团队已发布了一个 P/Invoke 签名生成工具,称为 P/Invoke Interop Assistant,它能根据头文件自动为本机 API 创建签名。随附的截图显示了处于使用状态的工具。
互操作技术:COM Interop
COM interop 允许您从托管代码使用本机 COM 接口,或将托管 API 公开为 COM 接口。您可以使用 TlbImp 工具生成托管库,让它公开一个托管接口,以便与特定的 COM tlb 通话。TlbExp 执行相反的任务,生成一个 COM tlb,其中的接口与托管程序集中的 ComVisible 类型相对应。
如 果您已经在应用程序中使用 COM 或将其视为扩展模型,则非常适合使用 COM interop。它还是在托管代码和本机代码之间维护完全保真的 COM 语义的最简便途径。如果您与基于 Visual Basic 6.0 的组件互操作,尤其适合使用 COM interop,因为 CLR 基本与 Visual Basic 6.0 遵循相同的 COM 规则。
如果您尚未在内部使用 COM,或您不需要完全保真的 COM 语义且它的性能不满足您应用程序的要求,则 COM interop 的作用不大。
在 应用程序中,Microsoft Office 是使用 COM interop 在托管代码和本机代码间实现互操作的最典型示例。Office 是 COM interop 的上佳备选项,因为它一直将 COM 用做其扩展机制,也是 Visual Basic for Applications (VBA) 或 Visual Basic 6.0 最常使用的工具。
Office 原本完全依靠 TlbImp 和瘦互操作程序集做为其托管对象模型。但是,随着 Visual Studio 中内置了 Visual Studio Tools for Office (VSTO) 产品,这就提供了越来越丰富的开发模型,这些模型中融入了本专栏所述的诸多准则。现在使用 VSTO 产品时,有时很容易忘记 COM interop 是 VSTO 的基础,就象忘记 P/Invoke 是许多 BCL 的基础一样。
互操作技术:C++/CLI
C++/CLI 旨在为托管代码和本机代码搭建起沟通的桥梁,您可使用它将托管和本机 C++ 同时编译到同一程序集(甚至同一类)中,并在程序集的两部分之间执行标准的 C++ 调用。如果您使用 C++/CLI,您可选择想让程序集的哪一部分成为托管形式,哪一部分成为本机形式。生成的程序集是 MSIL(Microsoft 中间语言,可在所有托管程序集中找到)与本机程序集代码的混合。C++/CLI is 是非常强大的互操作技术,您几乎可以用它完全控制互操作边界。它的缺点是强制您取得对边界的绝大部分控制权。
如果需要静态类型检查、满足严格的性能要求且可预测性更强的定案,C++/CLI 可以出色担当桥梁作用。如果 P/Invoke或COM interop 能满足您的需要,通常它们更易于使用,尤其是开发人员对C++ 不甚熟悉时更是如此。
考虑C++/CLI 时,有几点需要注意。首先需要注意的是如果您计划使用 C++/CLI 充当速度更快的 COM interop,由于 COM interop 替您完成大量工作,所以它的速度要比 C++/CLI 慢。如果您只是想在应用程序中使用一下 COM,并不要求完全保真的 COM interop,这是一个不错的折衷方案。
但 是,如果您使用了许多 COM 规范,您会发现一旦要将 COM 语义内容加入 C++/CLI 解决方案,需要做大量的工作,并且它的性能比不上 COM interop。Microsoft 的几个团队试用过这种方法,发现它的这一缺点后转为继续使用 COM interop。
使用 C++/CLI 时,第二个需要注意的事项是它的作用仅限为托管代码和本机代码搭建桥梁,不适合用于编写应用程序的主体内容。虽然您确实可以用它编写程序,但与纯 C++ 或纯 C#/Visual Basic 环境相比,开发人员的生产率要低很多,并且应用程序的启动速度也慢得多。因此,如果您使用 C++/CLI,建议仅用 /clr 开关编译哪些必需的文件,而使用纯托管或纯本机程序集的组合构建应用程序的核心功能。
互操作体系结构注意事项
一旦您已决定在应用程序中使用互操作且确定了要用的技术,在建立解决方案的体系结构时,有几个高层级的注意事项,包括您的 API 设计和开发人员在针对互操作边界编写代码时的体验。还需考虑本机托管转换的放置位置和可能对应用程序产生的性能影响。最后要考虑您是否需要填补托管环境中 的垃圾收集与本机环境内手动/确定性生存期管理间的差异。
API 设计和开发人员体验
在考虑 API 设计时,您必须先问自己几个问题:谁将为我的互操作层编写代码,我是应该通过优化改进他的体验,还是应该将构建边界的成本降至最小?针对这一边界编写代码 的开发人员是不是就是编写本机代码的人员?还是他们不负责编写本机代码?他们是负责扩展您的应用程序或将其用作服务的第三方开发人员吗?他们的技术水准如何?他们愿意使用本机模式吗?还是只习惯编写托管代码?
如能回答这些问题,则有助于在本机代码的超薄包装与内部使用本机代码的丰富托管对象间确定合适的统一体。在超薄包装中,所有本机模式清晰可见,开发人员可以 对边界了如指掌,并清楚认识到他们是在针对本机 API 编写代码。对于厚实的包装,您几乎可以完全隐藏有本机代码参与这一事实——BCL 中的文件系统 API 就是提供了一流托管对象模型的超厚互操作层的极好示例。
互操作边界的性能和位置
在花费大量时间优化应用程序前,有必要先确定您是否有互操作性能问题。许多应用程序在对性能有严格要求的内容中使用互操作,它们对此应尤为注意。但对于其他 那些在对用户的鼠标单击响应中使用互操作的应用程序而言,不想看到会为用户带来延迟的成百上千的互操作转换。这就是说,如果您确实关注互操作解决方案的性 能,应把握两个原则:减少互操作转换的数量和每个转换所传递的数据量。
托管和本机代码间具备给定数据量的给定互操作的成本基本上也是固定的。具体的固定成本视您选择的互操作技术而定,但如果您选择的前提是需要用到某项技术的功能,那么通常不会再有更改。这意味着您的侧重点就是先减小边界的干扰,然后减少跨边界传输的数据量。
如何达成这一目标很大程度上取决于您的应用程序。但常用策略是在定义繁忙和大数据量接口的边界的一侧编写几行代码,来移动隔离边界,这一策略已有多个成功运 用的实例。基本方法是编写一个抽象层,将调用分批放入非常繁忙的接口,更好的办法是在边界间移动需要与此 API 交互的应用程序逻辑块,并且仅跨边界传送输入和结果。
生存期管理
对于互操作客户而言,托管与本机环境之间生存期管理的差异是最大的难题。.NET Framework 中基于垃圾收集的系统与本机环境中的手动和确定性系统间存在着本质差异,这种差异的表现形式常常十分怪异,难以诊断。
互操作解决方案中第一个需要注意的问题是在托管环境使用完本机资源后,一些托管对象仍长时间占有这些资源。如果本机资源十分稀少,需要调用方使用之后迅速释放(数据库连接就是这方面的确切示例),这种占用通常会造成问题。
如果这类资源很充足,您只需让垃圾收集器调用对象的终结器,然后让该终结器显式或隐式释放本机资源即可。如果资源稀少,托管 Dispose 模式就非常有用。您不必将本机对象直接公开给托管代码,而是至少为它们加上一层薄包装,由该包装实现 IDisposable 并沿用标准 Dispose 模式。这样,如果您发现资源耗尽问题,可以显式在托管代码中处理这些对象,并在用完后迅速释放资源。
经常影响应用程序的第二个生存期管理问题是开发人员总是感觉垃圾收集的作用不明显:他们的内存使用持续上升,但某些原因使垃圾收集器运行得极不稳定,对象长时间占用资源。他们不得不反复调用 GC.Collect 来解决这一问题。
造成这一问题的主要原因是大量非常小的托管对象持续占用很大的本机数据结构。垃圾收集器本身进行自我调节,尝试避免浪费时间进行不必要或无用处的收集。在决定是否进行另外一项收集时,它不仅查看进程的当前内存压力,还会查看每项垃圾收集释放的内存量。
如在此环境中运行,它看到的是每个收集只释放了少量内存(记住,它只了解释放的内存量),并未意识到释放这些小对象可以极大地减轻总体压力。这就导致内存使用持续提高,但收集反而越来越少。
解决方案是通知垃圾收集器每个此类小托管包装的实际内存消耗高过了本机资源。为此,我们专门在.NET Framework 2.0 中新增了一对 API。您可使用向稀有资源添加 Dispose 模式所用的包装,但要将它们设定为向垃圾收集器提供提示,而不是必须由自己显式释放资源。
在 此对象的构造函数中,您只需调用方法 GC.AddMemoryPressure 并传入本机对象的本机内存的大约成本即可。然后在对象的终结器方法中调用 GC.RemoveMemoryPressure。这两项调用将会帮助垃圾收集器理解这些对象的真实成本及释放它们后能空出的实际内存。注意:必须要确保 能出色平衡对 Add/RemoveMemoryPressure 的调用。
上述两种环境中的第三个常见生存期管理问题与单个资源的管理没有太多联系,它涉及的是整个程序集或库。本机库在应用程序用过之后可以轻松卸载,但托管库无法 依靠自己卸载。CLR 有称为 AppDomains 的隔离单元,可以单独卸载并能在卸载时整理所有程序集、对象,甚至该域中运行的线程。如果您构建的是本机应用程序并习惯在处理完成后卸载加载项,您将发现 分别对每个托管加载项使用不同的AppDomains后,您得到的灵活性不亚于卸载单个的本机库。