CLR 全面透彻解析

进程内并行

Jesse Kaplan 和 Luiz Fernando Santos

CLR 团队博客中发布您想询问的问题和提出的意见。

在我们构建 .NET Framework 4 时,我们面对的一个难度较大的问题是在添加新特性和新功能时保持与以前的版本兼容。我们采用严格的流程,任何可能带来兼容性问题的更改都需要经过审批,而大多数这样的更改都遭到了拒绝;并且还投入了一个包含数百种实际应用程序的兼容性实验室,力图找出任何意外的错误。

但是每次我们修复一个错误,都可能会经受某个应用程序依赖于该错误行为的风险。有时,应用程序会采用我们警告过的依赖关系,例如专用 API 的行为或异常的说明文本。

由于引入了 .NET Framework,我们拥有了一个优秀的应用程序兼容性解决方案:允许在一台计算机上同时安装多个版本的 .NET Framework。如果两个不同的应用程序分别针对两个不同的版本生成,并且安装在同一台计算机上,这种方案使这两个应用程序能够分别在适当的版本上运行。

加载项问题

当每个应用程序获得自己的进程时,这种方案运转良好,但是加载项的问题要复杂得多。假设您在运行一个程序,该程序包含一些托管的 COM 加载项(例如承载 COM 加载项的 Outlook),同时您的计算机上安装了两个版本的运行时,而这些加载项分别针对其中一个版本。您应该选择哪一个运行时?在较旧的运行时上加载较新的加载项显然不能正常运行。

另一方面,由于兼容性级别很高,较旧的加载项通常能够在较新的运行时上正常运行。为了使所有加载项能够获得最佳工作状态,我们始终为托管的 COM 激活选择最新的运行时。即使您只安装了较旧的加载项,在激活该加载项时我们也无从知道这一点,因此仍然会加载最新的运行时。

遗憾的是,这种激活策略有一个副作用:如果用户新装了一个带有新版运行时的应用程序,则针对旧版本生成的、使用托管 COM 加载项的、完全无关的应用程序会突然开始运行于较新的运行时之上,并且可能会失败。

对于 .NET Framework 3.0 和 3.5,我们通过极其严格的策略来解决此问题:每个版本都是扩充版本,仅仅向具有同一运行时的以前版本中添加新程序集。将它们安装到运行 .NET Framework 2.0 的计算机上时,这种方式可防止产生任何兼容性问题。这意味着,当您在 .NET Framework 3.5 上运行应用程序时,您实际上是在 2.0 运行时上运行的,只不过在其上多了一些额外的程序集而已。但是这也意味着,我们不能对 .NET 2.0 程序集进行创新,而它包含一些关键功能,例如垃圾收集器、实时 (JIT) 和基类库。

在 .NET Framework 4 中,我们实现了一种新的方法,这种方法可实现很高的兼容性,从来不会破坏现有的加载项,而且还允许我们对核心进行创新。我们现在可以在同一进程中同时运行 .NET 2.0 和 .NET 4 加载项。我们将这种方法称为“进程内并行”,也称为“In-Proc SxS”。

尽管进程内并行解决了最常见的兼容性问题,但是它并不能解决所有问题。在本专栏中,我们将详细介绍我们为什么决定构建进程内并行、它如何工作以及它不能解决哪些问题。对于编写普通应用程序或加载项的人而言,进程内并行只能说是有一定成效 — 水到自然渠成。对于那些编写宿主来利用进程内并行的人员,我们也将介绍更新的承载 API 并提供一些使用指南。

Ray Ozzie 办公室之旅

2005 年年底,几乎所有 Microsoft 高级官员突然无法在他们的任何主要计算机上查看电子邮件。没有任何明显的原因,只要他们打开 Outlook,它就会崩溃、重新启动,然后反复崩溃、重新启动。在此之前,未对 Outlook 执行任何更新或其他可能导致上述情况的操作。经过对问题的跟踪,很快找到了托管加载项引发的托管异常。我的一个朋友 [“我”是指专栏的合著者 Jesse Kaplan — 编辑按] 来自 Visual Studio Tools for Office (VSTO) 团队,他负责 Office 的托管加载项,受命在一台受此错误影响最严重的计算机上诊断此问题:Ray Ozzie(当时是首席技术官)的计算机。

进入 Ray 的办公室之后,我的朋友很快确定通过内部测试计划部署了一个测试版的 .NET Framework 2.0,并且他找出了导致该问题的 Office 加载项。作为 CLR 团队的兼容性项目经理之一,我也安装了该加载项并遇到了同样的问题。

我们很快确定是哪里出了错:该加载项出现了紊乱,导致它启动了多达九个不同的线程,并且在启动每个线程之后,它都初始化了该线程处理的数据(请参见图 1)。由于时间因素,程序员很幸运地没有遇到问题,但是当安装 .NET Framework 2.0 之后,由于我上面指出的原因,该加载项就会自动转到 .NET 2.0 上运行。但是 .NET 2.0 在启动线程时要稍快一点,因此隐藏的紊乱状况就开始不断浮出水面。

图 1 来自 Office 加载项的代码

Thread [] threads = new Thread[9];
for (int i=0; i<9; i++)
{
    Worker worker = new Worker();
    threads[i] = new ThreadStart(worker.Work);
    threads[i].Start(); //This line starts the thread executing
    worker.identity =i; //This line initializes a value that
                        //the thread needs to run properly
}

此应用程序故障是一次惨痛的教训,让我们了解到兼容性如此重要:无论我们多么努力地避免改变行为,都不可避免地会破坏应用程序,像性能改进这样简单的事情都可能会暴露出应用程序和加载项中的错误,只要它们不是在生成和测试所针对的运行时上运行,就会使它们失败。我们认识到,我们没有任何办法一面改进该平台,一面保证类似如上的应用程序能够在最新的版本上完美运行。

破坏性安装

在我们的兼容性测试期间,我们也出乎意料地遇到过这样的应用程序:如果在安装该应用程序之后安装 .NET 2.0,该应用程序能够正常运行;但是如果将应用程序安装到同时装有 1.1(该应用程序生成时所针对的版本)和 2.0 的计算机上,该应用程序就会失败。我们花了一些时间弄明白发生的事情,但是在我们跟踪该问题时,发现安装程序中运行的一些代码再次转到 2.0 上运行,这一次是查找框架目录的问题。

检测逻辑极其脆弱,实际上是错误的,如下面所示:

string sFrameworkVersion = System.Environment.Version.ToString();
string sWinFrameworkPath = session.Property["WindowsFolder"] +
"Microsoft.NET\\Framework\\v" +
sFrameworkVersion.Substring(0,8) + "\\";

但是即使在修复了该错误之后,该应用程序在安装后仍然无法正确执行。这是修复代码:

string sWinFrameworkPath = System.Runtime.InteropServices.RuntimeEnvironment.GetRuntimeDirectory();

这是因为该安装程序将查看该框架目录,以便获取 caspol.exe 的路径并取得在该框架中运行的应用程序权限。即使找到了该路径,此应用程序仍然会失败,因为它刚才授予自己在 2.0 CLR 上运行的权限,而它本身实际上在 1.1 CLR 上运行。这是有问题的代码:

System.Diagnostics.Process.Start(sWinFrameworkPath + "caspol.exe " + casPolArgs);

通过进程内并行实现的兼容性

按照我们的理解,导致所有这些问题的根源是不可能一面对我们的平台进行任何明显的更改或增加,一面仍然保证最新版本能够与较旧版本一样运行任何程序。

从一开始,.NET Framework 就试图通过支持在一台计算机上并行安装多个框架版本并让每个应用程序选择要在其上运行的版本,来解决这个问题。

很不幸的是,每个进程只允许一个运行时的限制意味着对于托管 COM 组件和可扩展方案,由于多个独立的应用程序运行在同一个进程中,因此没有一种选择能够对所有应用程序都适用。这项限制意味着有些组件无法获得它们需要的运行时,因此无论我们多么努力地保持兼容性,总会有一定比例的应用程序会出现问题。

我们在一个进程中加载多个版本的运行时的新能力解决了这些问题。

指导性原则

为了帮助您更好地理解我们作出的一些决策以及本专栏的后面部分详细介绍的行为,了解我们在设计此功能时所持有的指导性原则会很有帮助。

  1. 安装新版本的 .NET Framework 应该对现有的应用程序没有任何影响。
  2. 应用程序和加载项应该在生成和测试它们时所针对的框架版本上运行。
  3. 有些情况下,例如在使用库时,我们无法在生成库时所针对的框架版本上运行代码,因此我们仍然必须为 100% 向后兼容而奋斗。

所有现有的应用程序和加载项应该继续在它们生成和配置运行的框架版本上运行,而且除非它们明确要求,否则它们不应该看到新版本。这一直都是托管应用程序的规则,但是现在此规则还适用于托管 COM 加载项和运行时承载 API 的使用者。

除了确保应用程序在其生成时所针对的运行时版本上运行以外,我们仍然需要确保它很容易过渡到较新的运行时,因此我们仍然使 .NET Framework 4 的兼容性不低于、甚至高于 .NET 2.0。

行为概述

.NET Framework 4 运行时和未来的所有运行时都将能够在进程中同时运行。尽管我们没有将此功能向后迁移到较旧的运行时(1.0 到 3.5),我们确实保证了 4 和之后的版本能够与任何一个较旧的运行时在进程中同时运行。也就是说,您能够在同一个进程中加载 4、5 和 2.0,但不能在同一个进程中加载 1.1 和 2.0。.NET Frameworks 2.0 到 3.5 都运行于 2.0 运行时之上,因此相互不会产生冲突,如图 2 所示。

.NET Framework 版本        
  1.1 2.0 到 3.5 4 5
1.1
2.0 到 3.5
4
5

图 2 可以在同一进程中加载这些运行时吗?

当 .NET Framework 4 运行时安装时,所有现有的应用程序和组件都不会注意到任何差别:它们应该继续获得它们生成时所针对的运行时。针对 .NET 4 生成的应用程序和托管 COM 组件将继续在 4 运行时上执行。希望与 4 运行时交互的宿主需要明确提出请求。

进程内并行对您意味着什么?

**最终用户和系统管理员:**您现在可以确信:如果您安装新版本的运行时,无论是单独安装,还是与某个应用程序一起安装,它都不会对您的计算机造成任何影响,所有现有的应用程序都将与以前一样运行。

**应用程序开发人员:**进程内并行对应用程序开发人员几乎没有影响。在默认情况下,应用程序仍然在生成它们的框架版本上运行,这没有任何变化。我们做出的会对应用程序开发人员产生影响的唯一行为更改是:当原始版本不存在时,我们不再自动在较新的版本上运行针对较旧的运行时生成的应用程序。而是提示用户下载原始版本,并提供链接来简化此过程。

我们仍然提供配置选项,允许您指示应用程序在哪个框架版本上运行,因此可以在较新的运行时上运行较旧的应用程序,但我们不会自动这么做。

**库开发人员和使用者:**进程内并行不能解决库开发人员所面对的兼容性问题。无论是通过直接引用,还是通过 Assembly.Load*,任何由应用程序直接加载的库仍然会直接加载到运行时以及加载该运行时的应用程序的 AppDomain 中。这意味着,如果重新编译一个应用程序,使其在 .NET Framework 4 运行时上运行,但是该应用程序仍然依赖于针对 .NET 2.0 生成的程序集,则这些依赖项也会加载到 .NET 4 运行时上。因此,我们仍然建议您针对您希望支持的所有框架版本测试您的库。这是我们继续保持高度后向兼容的原因之一。

托管 COM 组件开发人员:以前,这些组件将自动在计算机上安装的最新版本的运行时上运行。现在,.NET Framework 4 之前的组件仍将针对最新的运行时(3.5 或更早版本)激活,而所有 .NET Framework 4 及之后的组件都将加载到生成它们的版本中,如图 3 所示。

托管 COM 组件:我的组件将在哪个版本上运行?        
组件版本 1.1 2.0 到 3.5 4 5
计算机/进程状态

安装了 1.1、3.5、4、5

未加载任何版本

3.5 3.5 4 5

安装了 1.1、3.5、4、5

加载了 1.1、4

1.1 无法加载* 4 5

安装了 1.1、3.5、4、5

加载了 3.5、5

3.5 3.5 4 5

安装了 1.1、3.5、5

未加载任何版本

3.5 3.5 默认情况下,无法加载** 5

*这些组件将像以前一样无法加载。

**现在,当您注册组件时,您可以指定它们支持的版本范围,因此您如果进行了测试,就可以将此组件配置为在 5 或未来的运行时上运行。

       

图 3 托管 COM 组件和运行时互操作性

**外壳扩展开发人员:**外壳扩展是一个适用于 Windows 外壳中的各种扩展点的通用名称。两个常见的示例是:添加到右键单击文件和文件夹时弹出的上下文菜单中的扩展,以及为文件和文件夹提供自定义图标和图标覆盖的扩展。

这些扩展通过标准的 COM 模型公开,它们的关键特征是它们会与任何应用程序一起加载到进程中。正是由于这个原因,以及每个进程只允许一个 CLR 的事实,导致了托管外壳扩展的问题。为了详细说明:

  • 外壳扩展是针对运行时版本 N 编写的。
  • 它必须能够加载到计算机上的任何应用程序中,包括那些针对早于或晚于 N 的版本生成的应用程序。
  • 如果应用程序针对的版本晚于扩展针对的版本,则除非出现兼容性问题,否则通常一切正常。
  • 如果应用程序针对的版本早于扩展针对的版本,则它一定会失败,因为较旧的运行时显然无法运行在较新的运行时上生成的外壳扩展。
  • 如果由于任何原因,外壳扩展在应用程序的托管代码组件之前加载,它选择的框架版本可能会与应用程序发生冲突并会导致出现问题。

这些问题使我们再次正式建议,但不提供支持,使用托管代码来开发进程中外壳扩展。这对我们以及我们的客户是一个痛苦的选择,您可以在以下 MSDN 论坛中查看对该问题的解释:https://social.msdn.microsoft.com/forums/en-US/netfxbcl/thread/1428326d-7950-42b4-ad94-8e962124043e。外壳扩展极为常见,也是开发特定类型的应用程序的开发人员不得不编写本机代码的场合之一。很不幸,由于每个进程只允许一个运行时的限制,我们无法为他们提供支持。

由于进程中可以同时有多个运行时,我们现在可以为编写托管外壳扩展提供通用的支持,即使对那些与计算机上的任意应用程序一起在进程中运行的外壳扩展,也可以提供支持。我们仍然不支持使用 .NET Framework 4 之前的任何版本编写外壳扩展,因为那些版本的运行时不能同时加载到进程中,并且在许多情况下都会导致出现问题。

托管和本机外壳扩展的开发人员仍然必须特别小心,确保它们能够在各种环境中运行并且能够与其他应用程序一起正常使用。随着我们越来越接近批量生产版 (RTM),我们将提供指南和示例,帮助您开发高质量的托管外壳扩展,使其能够在 Windows 生态系统中表现出色。

托管代码的宿主:如果您使用本机 COM 激活来承载托管代码,则不需要执行任何特殊操作,就能与多个运行时一起使用。您只需像往常一样激活组件,运行时就会根据图 3 中列出的规则加载这些组件。

如果您曾经用过 .NET Framework 4 之前的任何承载 API,您可能已经注意到它们都假设进程中只加载一个运行时。因此,如果您使用本机 API 来承载运行时,则您需要修改宿主,使其支持进程内并行。在一个进程中允许有多个运行时的新方法中,我们放弃了旧的仅支持单一运行时的承载 API,并添加了一组新的承载 API,专门用来帮助您管理多运行时环境。MSDN 应该已经完成了新 API 的文档,但是如果您使用过旧的 API,则新 API 会相对容易使用。

我们在开发进程内并行时面对的最有趣的挑战之一是如何更新现有的仅支持单一运行时的承载 API。我们有很多选择,但是考虑到本专栏前面列出的原则,我们就只剩下了下面的准则:API 应该与计算机上安装了 .NET Framework 4 时一样运行,它们应该返回与以前完全一样的行为。这意味着它们只能在每个进程中注意到一个运行时,即使在使用它们时计算机上以前已经激活了最新的运行时,它们仍然只为您提供版本早于 4 的最新运行时。

仍然可以通过向它们显式传递 4 版本号,或者通过以特定的方式配置应用程序,将这些 API“绑定”到 .NET Framework 4 运行时,但是仍然只有在您明确请求 4 运行时,而不是请求“最新的”运行时的时候,才会出现这种绑定。

**总结:**安装 .NET Framework 4 之后,使用现有承载 API 的代码仍能继续使用,但是它们会假设进程中只加载了一个运行时。此外,为了保持这种兼容性,它们通常只能与第 4 版之前的版本交互。MSDN 上将提供有关为每一个较旧的 API 选择哪个版本的详细信息,但是上述几条规则应该能够帮助您理解我们如何确定这些行为。如果您希望与多个运行时交互,您将需要转移到新的 API。

**C++/CLI 开发人员:**C++/CLI 或托管 C++ 是一种有趣的技术,它允许开发者在同一个程序集中混合使用托管代码和本机代码,并且基本无需开发人员的交互就能管理这两者之间的过渡。

由于该体系结构,在这个新世界中使用这些程序集时会有一些限制。一个根本问题是:如果我们允许每个进程加载多个这样的程序集,我们仍然需要在每个托管数据部分和本机数据部分之间保持隔离。这意味着两个部分都需要加载两次,而本机 Windows 加载程序不允许这么做。我们为何有以下限制的完整原因不在本专栏的讨论范围内,而会在我们接近 RTM 时提供。

基本的限制是基于 .NET Framework 2.0 之前版本的 C++/CLI 程序集只能加载到 .NET 2.0 运行时上。如果您提供一个 2.0 C++/CLI 库,并希望从 4 和之后的版本来使用它,则您需要为希望加载它的每一个版本重新编译它。如果您使用这些库中的某一个,您将需要从库开发人员处获取更新的版本,或者作为最后的手段,将应用程序配置为在进程中禁止 4 之前的运行时。

没有更多麻烦

Microsoft .NET Framework 4 是迄今为止向后兼容性最佳的 .NET 版本。通过为表格提供进程内并行功能,Microsoft 保证像安装 .NET 4 这样的简单操作不会破坏任何现有的应用程序,并且保证计算机上已经安装的任何应用程序都能像以前一样运行。

最终用户不再需要担心安装该框架(无论是直接安装还是与需要该框架的应用程序一起安装)会破坏计算机上已经存在的任何应用程序。

企业和 IT 专业人员可以根据需要快速或逐步采用新版本的框架,而不必担心由不同应用程序使用的不同版本之间会相互冲突。

开发人员可以使用最新版本的框架来生成其应用程序,并且能够使其客户确信他们可以安全部署这些应用程序。

最后,宿主和加载项开发人员很高兴他们将获得他们需要的框架版本,而不会影响其他任何应用程序,因此他们相信他们的代码在安装新版本的框架之后仍能正常运行。                                                             

Jesse Kaplan* 是 Microsoft CLR 团队中负责托管/本机互操作性的项目经理。他以前负责的工作包括兼容性和可扩展性。*

Luiz Santos* 以前是 CLR 团队的成员,而现在是 SQL 连接团队中的项目经理,负责 ADO.NET 托管提供程序,包括 SqlClient、ODBCClient 和 OLEDBClient。*

衷心感谢以下技术专家,感谢他们审阅了本文:Joshua Goodman、Simon Hall 和 Sean Selitrennikoff