65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 和 .NET 平台 - 第 6 章

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.69/5 (9投票s)

2001 年 11 月 24 日

65分钟阅读

viewsIcon

159780

程序集、线程和应用程序域

Sample Image - 1893115593_6.gif
作者Andrew Troelsen
标题C# 和 .NET 平台
出版社Apress
ISBN1893115593
价格定价 59.95 美元
页数1004

程序集、线程和应用程序域

在前面五章中开发的所有应用程序都遵循传统的“独立”应用程序模式,因为所有编程逻辑都包含在单个 (EXE) 二进制文件中。 .NET 生态系统的一个方面是二进制重用的概念。与 COM 类似,.NET 提供了以语言无关的方式访问二进制文件之间类型的能力。然而,.NET 平台提供了比经典 COM 更强大的语言集成。例如,.NET 平台支持跨语言继承(想象一个 Visual Basic.NET 类派生自 C# 类)。要理解这是如何实现的,需要对程序集有更深入的了解。

一旦您了解了程序集的逻辑和物理布局(以及相关的清单),您就可以区分“私有”和“共享”程序集。您还将详细了解 .NET 运行时如何解析程序集的加载位置,并理解全局程序集缓存 (GAC) 的作用。与位置解析密切相关的是应用程序配置文件。正如您将看到的,.NET 运行时可以读取此文件中包含的基于 XML 的数据来绑定到特定版本的共享程序集(以及其他内容)。

本章最后将介绍如何使用 System.Threading 命名空间中定义的类型来构建多线程程序集。如果您有 Win32 背景,您会很高兴看到 .NET 框架中的线程操作得到了多么大的简化。

经典 COM 二进制文件的问题

二进制重用(即,可移植代码库)并非新概念。迄今为止,程序员共享二进制文件之间类型(并在某些方面,跨语言)的最流行方式是构建现在可被视为“经典 COM 服务器”的东西。尽管 COM 二进制文件的构建和使用是公认的行业标准,但这些小小的二进制文件也给我们每个人带来了不少头痛。除了 COM 需要大量复杂的基础设施(IDL、类工厂、脚本支持等)之外,我相信您还可能考虑过以下相关问题:

  • 为什么我的 COM 二进制文件版本控制如此困难?
  • 分发我的 COM 二进制文件为什么如此复杂?

.NET 框架极大地改进了当前状况,并使用一种名为程序集的新二进制格式正面解决了版本控制和部署问题。然而,在您理解程序集如何为这些问题提供干净的解决方案之前,让我们花点时间更详细地回顾一下这些问题。

问题:COM 版本控制

在 COM 中,您构建称为 coclass 的实体,它们不过是实现任何数量的 COM 接口(包括强制的 IUnknown)的用户定义类型 (UDT)。然后,这些 coclass 被打包到二进制文件中,该文件在物理上表示为 DLL 或 EXE 文件。一旦(已知的)错误都已从代码中清除,COM 二进制文件最终就会出现在用户的计算机上,供其他程序访问。

COM 中的版本控制问题在于,COM 运行时本身不提供任何内置支持来强制为调用客户端加载正确版本的二进制服务器。确实,COM 程序员可以修改类型库的版本,更新注册表以反映这些更改,甚至可以重新设计客户端的代码库以引用特定的库。但是,事实仍然是这些任务委托给程序员,并且通常需要重新构建代码库。正如你们许多人已经用血泪学到的那样,这远非理想。

假设您已经费尽周折来确保 COM 客户端激活正确版本的 COM 二进制文件。您的担忧还远远没有结束,因为目标计算机上可能安装了其他应用程序,这些应用程序可能会覆盖您精心配置的注册表项(甚至可能在此过程中用早期版本替换一个或两个 COM 服务器)。神秘的是,您的客户端应用程序现在可能无法运行。

例如,如果您有 10 个应用程序都需要使用 MyCOMServer.dll 版本 1.4,而另一个应用程序安装了 MyCOMServer.dll 版本 2.0,那么这 10 个应用程序都面临中断的风险。这是因为我们无法保证完全的向后兼容性。在理想情况下,给定 COM 二进制文件的所有版本都与先前版本完全兼容。但在实践中,保持 COM 服务器(以及软件,总的来说)完全向后兼容是极其困难的。

所有这些版本控制问题汇集在一起,被亲切地称为“DLL 地狱”(顺便说一句,这不仅仅限于 COM DLL;传统的 C DLL 也遭受着同样的痛苦)。正如您在本章的整个过程中将看到的,.NET 框架通过使用多种技术(包括并行执行和非常强大(但非常简单)的版本控制方案)解决了这个噩梦。

简而言之,.NET 允许在同一目标计算机上安装同一二进制文件的多个版本。因此,在 .NET 下,如果客户端 A 需要 MyDotNETServer.dll 版本 1.4,而客户端 B 需要 MyDotNETServer.dll 版本 2.0,则会自动为各自的客户端加载正确版本。您还可以使用应用程序配置文件绑定到特定版本。

问题:COM 部署

COM 运行时是一项相当娇气的服务。当 COM 客户端希望使用 coclass 时,第一步是通过调用 CoInitialize() 将 COM 库加载到给定线程中使用。此时,客户端会向 COM 运行时发出其他调用(例如,CoCreateInstance()、CoGetClassObject() 等)以将给定二进制文件加载到内存中。最终结果是 COM 客户端收到一个接口引用,然后使用该引用来操作包含的 coclass。

为了让 COM 运行时定位并加载二进制文件,COM 服务器必须在目标计算机上正确配置。从高层次来看,注册 COM 服务器听起来很简单:构建一个安装程序(或使用系统提供的注册工具)以触发 COM 二进制文件中的正确逻辑(DLL 为 DllRegisterServer(),EXE 为 WinMain()),然后就完成了。然而,您可能知道,COM 服务器需要大量的注册条目。通常,每个 COM 类 (CLSID)、接口 (IID)、库 (LIBID) 和应用程序 (AppID) 都必须插入系统注册表。

需要牢记的关键点是,二进制映像和正确的注册表条目之间的关系非常松散,因此极其脆弱。在 COM 中,二进制映像(例如,MyServer.dll)的位置与完全描述组件的大量注册表条目是完全分开的。因此,如果最终用户移动(或重命名)了 COM 服务器,整个系统就会中断,因为注册表条目已不同步。

经典 COM 服务器需要大量外部注册详细信息这一事实也引入了另一个部署难题:必须在每台引用服务器的计算机上进行相同的条目。因此,如果您在远程计算机上安装了 COM 二进制文件,并且有 100 台客户端计算机访问此 COM 服务器,这意味着需要正确配置 101 台计算机。至少,这非常麻烦。

.NET 平台使应用程序部署过程变得极为简单,因为 .NET 二进制文件(即程序集)根本不注册在系统注册表中。简单直接。相反,程序集是完全自描述的实体。部署 .NET 应用程序可以(而且最常是)像将构成应用程序的文件复制到计算机上的某个位置一样简单,然后运行您的程序。简而言之,准备好与 HKEY_CLASSES_ROOT 告别吧。

.NET 程序集概述

既然您已经理解了问题,让我们来看看解决方案。.NET 应用程序是通过组合任意数量的程序集来构建的。简而言之,程序集不过是一个版本化、自描述的二进制文件(DLL 或 EXE),其中包含一些类型的集合(类、接口、结构等)和可选的资源(图像、字符串表等)。有一点需要您非常注意,那就是 .NET 程序集的内部组织与经典 COM 服务器的内部组织完全不同(尽管文件扩展名相同)。

例如,一个进程内 COM 服务器导出四个函数(DllCanUnloadNow()、DllGetClassObject()、DllRegisterServer() 和 DllUnregisterServer()),以便 COM 运行时可以访问其内容。.NET DLL 则只需要一个函数导出,即 DllMain()。

本地 COM 服务器将 WinMain() 定义为 EXE 的唯一入口点,该入口点旨在测试各种命令行参数以执行与 COM DLL 相同的职责。.NET 协议则不同。尽管 .NET EXE 二进制文件提供了 WinMain() 入口点(或控制台应用程序的 main()),但幕后逻辑完全不同。

.NET 二进制文件的物理格式实际上更类似于传统的可移植可执行文件 (PE) 和 COFF (Common Object File Format) 文件格式。真正的区别在于,传统的 PE / COFF 文件包含针对特定平台和特定 CPU 的指令。相比之下,.NET 二进制文件包含使用 Microsoft 中间语言 (MSIL 或简称 IL) 构建的代码,该代码是平台和 CPU 无关的。在运行时,内部 IL 会被即时编译器(just-in-time compiler)即时编译为平台和 CPU 特定的指令。这是对经典 COM 的强大扩展,因为 .NET 程序集旨在成为不一定与 Windows 操作系统绑定的平台中性实体。

除了原始 IL 之外,请记住程序集还包含元数据,这些元数据完整地描述了程序集中存在的每种类型,以及每种类型支持的成员的完整集合。例如,如果您使用某种 .NET 感知的语言创建了一个名为 JoyStick 的类,则相应的编译器会发出元数据来描述此自定义类型定义的所有字段、方法、属性和事件。.NET 运行时使用这些元数据来解析二进制文件中类型(及其成员)的位置、创建对象实例,以及促进远程方法调用。

与传统文件格式或经典 COM 服务器不同,程序集必须包含关联的清单(也称为“程序集元数据”)。清单记录了程序集中的每个模块,建立了程序集的版本,并且还记录了当前程序集引用的任何外部程序集(与不记录所需外部依赖项的经典 COM 类型库不同)。鉴于此,.NET 程序集是完全自描述的。

单文件和多文件程序集

在底层,一个给定的程序集可以由多个模块组成。模块实际上就是有效文件的通用名称。从这个角度来看,程序集可以被视为部署单元(通常称为“逻辑 DLL”)。在许多情况下,程序集实际上由单个模块组成。在这种情况下,(逻辑)程序集与底层(物理)二进制文件之间存在一对一的对应关系,如图 6-1 所示。

当您创建一个由多个文件组成的程序集时,您可以实现高效的代码下载。例如,假设一个远程客户端引用了一个由三个模块组成的多文件程序集。如果远程应用程序仅引用了这些模块中的一个,则 .NET 运行时仅下载当前引用的文件。如果每个模块的大小为 1 MB,我相信您可以看到其中的好处。

请注意,多文件程序集并非字面上链接成一个新(更大)的文件。相反,多文件程序集由相应清单中的信息在逻辑上关联起来。在相关方面,多文件程序集包含一个单一的清单,该清单可以放置在一个独立的文件中,但通常会打包到相关的模块之一中。大局如图 6-2 所示。

本书不关注多文件程序集的构建。但是,请注意,在线帮助文档中也记录了该过程(该过程基本上就是将 /addmodule 标志传递给 C# 编译器)。

程序集的两种视图:物理和逻辑

当您开始使用 .NET 二进制文件时,将程序集(无论是单文件还是多文件)视为具有两个概念视图会有所帮助。当您构建程序集时,您关心的是物理视图。在这种情况下,程序集可以实现为包含您的自定义类型和资源的若干文件(图 6-3)。

作为程序集使用者,您关心的是程序的逻辑视图(图 6-4)。在这种情况下,您可以将程序集理解为可用于当前应用程序的版本化公共类型集合(请记住,“内部”类型只能由定义它们的程序集引用)。

例如, Redmond(微软所在地)的好心人开发了 System.Drawing.dll,并为您创建了一个物理程序集供您在应用程序中使用。然而,尽管 System.Drawing.dll 在物理上可以看作是一个二进制 DLL,但您在逻辑上将此程序集视为相关类型的一个集合。当然,ILDasm.exe 是当您有兴趣发现给定程序集的逻辑布局时的首选工具(图 6-5)。

您很有可能会扮演程序集构建者和程序集消费者的双重角色,正如本书中的情况一样。但是,在深入研究代码之前,让我们简要回顾一下这种新文件格式的一些核心优势。

程序集促进代码重用

程序集包含由 .NET 运行时执行的代码。正如您可能想象的那样,程序集中包含的类型和资源可以被多个应用程序共享和重用,就像传统的 COM 二进制文件一样。与 COM 不同,还可以配置“私有”程序集(事实上,这是默认行为)。私有程序集仅供给定计算机上的单个应用程序使用。正如您将看到的,私有程序集极大地简化了应用程序的部署和版本控制。

与 COM 一样,.NET 平台下的二进制重用遵循语言独立的理想。C# 是能够构建托管代码的众多语言之一,并且将来还会有更多语言。当一个 .NET 感知的语言遵循公共语言规范 (CLS) 的规则时,您的语言选择几乎只是一个个人偏好。

因此,不仅可以跨语言重用类型,还可以跨语言扩展类型。在经典 COM 中,开发人员无法从 COM 对象 B 派生 COM 对象 A(即使两种类型都以相同的语言开发)。简而言之,经典 COM 不支持经典继承(“is-a”关系)。在本章后面,您将看到一个跨语言继承的示例。

程序集建立类型边界

程序集用于定义其包含的类型(和资源)的边界。.NET 中,给定类型的标识(部分)由其所在的程序集定义。因此,如果两个程序集各自定义了一个同名的类型(类、结构等),它们在 .NET universe 中就被视为独立的实体。

程序集是可版本化和自描述的实体

如前所述,在 COM 世界中,开发人员负责正确地对二进制文件进行版本控制。例如,为了确保 MyComServer.dll 版本 1.0 和 MyComServer.dll 版本 2.4 之间的二进制兼容性,程序员必须使用基本的常识来确保接口定义未被更改,否则就有可能破坏客户端代码。虽然在 .NET universe 中,大量的版本控制常识也很有用,但 COM 版本控制方案的问题在于,这些程序员定义的技巧并未被运行时强制执行。

当前版本控制实践中的另一个主要麻烦是,COM 没有提供一种方法让二进制服务器明确列出它正常运行所需的其他二进制文件的集合。如果最终用户错误地移动、重命名或删除了依赖项,解决方案就会失败。.NET 下,程序集的清单负责明确列出所有内部和外部的依赖项。

每个程序集都有一个版本标识符,该标识符适用于程序集中每个模块包含的所有类型和所有资源。使用版本标识符,运行时能够确保为调用客户端加载正确的程序集,使用定义良好的版本策略(稍后详述)。程序集的版本标识符由两个基本部分组成:一个友好的文本字符串(称为信息版本)和一个数字标识符(称为兼容版本)。

例如,假设您创建了一个新的程序集,其信息版本字符串为“MyInterestingTypes”。此程序集还将定义一个兼容版本号,例如 1.0.70.3。兼容版本号始终采用相同的通用格式(四个数字用句点分隔)。第一个和第二个数字标识程序集的主版本和次版本(此处为 1.0)。第三个值(70)标记内部版本号,后跟当前修订号(3)。

正如您将在本章稍后发现的那样,.NET 运行时会利用程序集的版本来确保为客户端加载正确的二进制文件(前提是该程序集是共享的)。由于清单明确列出了所有外部依赖项,因此运行时能够确定“最后已知良好”的配置(即,已知可以正常运行的版本化程序集的集合)。

程序集定义安全上下文

程序集还可以包含安全信息。.NET 运行时架构下,安全措施的范围是程序集级别的。例如,如果 AssemblyA 希望使用 AssemblyB 中包含的类,则由 AssemblyB 来决定是否提供访问权限。程序集定义的安全约束在其清单中明确列出。虽然本节的重点不在于详细介绍 .NET 安全措施,但请注意,对程序集内容的访问是通过程序集元数据进行验证的。

程序集支持并行执行

.NET 程序集最大的优势可能是允许运行时加载(并理解)同一程序集的多个版本。因此,可以在单个计算机上安装和加载同一程序集的多个版本。这样,客户端就可以与其他不兼容的同一程序集版本隔离。

此外,可以使用应用程序配置文件来控制加载(共享)程序集的哪个版本。这些文件不过是简单的文本文件,通过 XML 语法描述要为调用应用程序加载的程序集的版本和特定位置。您将在本章的后续内容中学习如何编写应用程序配置文件。

构建单文件测试程序集

现在您对 .NET 程序集有了更好的理解,让我们用 C# 构建一个最小且完整的代码库。在物理上,这将是一个名为 CarLibrary 的单文件程序集。要使用 Visual Studio.NET IDE 构建代码库,您需要选择一个新的类库项目工作区(图 6-6)。

我们的汽车库设计始于一个名为 Car 的抽象基类,该类定义了通过自定义属性公开的若干受保护数据成员。该类有一个名为 TurboBoost() 的抽象方法,并使用一个枚举(EngineState)。这是 CarLibrary 命名空间的初始定义。

// Our first code library (CarLibrary.dll)
namespace CarLibrary
{
using System;

public enum EngineState      // Holds the state of the engine.
{
     engineAlive,
     engineDead
}

public abstract class Car    // The abstract base class in the hierarchy.
{
     // Protected state data.
     protected string petName;
     protected short currSpeed;
     protected short maxSpeed;
     protected EngineState egnState;

     public Car(){egnState = EngineState.engineAlive;}
     public Car(string name, short max, short curr)
     {
          egnState = EngineState.engineAlive;
          petName = name; maxSpeed = max; currSpeed = curr;
     }

     public string PetName
     {
          get { return petName; }
          set { petName = value; }
     }
     public short CurrSpeed
     {
          get { return currSpeed; }
          set { currSpeed = value; }
     }

     public short MaxSpeed
     { get { return maxSpeed; } }

     public EngineState EngineState
     { get { return egnState; } }

     public abstract void TurboBoost();
}
}

现在假设您有两个直接继承自 Car 类型的类,分别名为 MiniVan 和 SportsCar。每个类都以适当的方式实现了抽象的 TurboBoost() 方法。

namespace CarLibrary
{
using System;
using System.Windows.Forms;     // Needed for MessageBox definition.

// The SportsCar
public class SportsCar : Car
{
     // Ctors.
     public SportsCar(){}
     public SportsCar(string name, short max, short curr)
          : base (name, max, curr){}

     // TurboBoost impl.
     public override void TurboBoost()
     {
          MessageBox.Show("Ramming speed!", "Faster is better. . .");
     }
}

// The MiniVan
public class MiniVan : Car
{
     // Ctors.
     public MiniVan(){}
     public MiniVan(string name, short max, short curr)
          : base (name, max, curr){}

     // TurboBoost impl.
     public override void TurboBoost()
     {
          // Minivans have poor turbo capabilities!
          egnState = EngineState.engineDead;
          MessageBox.Show("Time to call AAA", "Your car is dead");
     }
}
}

请注意,每个子类都使用 MessageBox 类实现了 TurboBoost(),该类定义在 System.Windows.Forms.dll 程序集中。为了使您的程序集能够使用此程序集中定义的类型,CarLibrary 项目必须通过“Project | Add Reference”菜单选项包含对该二进制文件的引用(再次参见图 6-7)。在第 8 章中,将详细介绍 System.Windows.Forms 命名空间。正如您从命名空间名称可以看出的那样,此程序集包含许多有助于构建 GUI 应用程序的类型。目前,您只需要关注 MessageBox 类。如果您正在跟着操作,请继续编译您的新代码库。

C# 客户端应用程序

由于我们的每辆汽车都被声明为“public”,其他二进制文件都可以使用我们的自定义类。稍后,您将学习如何从 Visual Basic 等其他 .NET 感知语言中使用这些类型。在此之前,让我们创建一个 C# 客户端。首先,创建一个新的 C# 控制台应用程序项目 (CSharpCarClient)。接下来,将引用添加到您的 CarLibrary.dll,使用“Browse”按钮导航到您的自定义程序集的位置(再次使用“Add Reference”对话框)。添加对 CarLibrary 程序集的引用后,Visual Studio.NET IDE 会将该程序集的完整副本复制到您的 Debug 文件夹(当然,前提是您已配置了调试构建)(图 6-8)。

这显然与经典 COM 相比是一个巨大的变化,在 COM 中,二进制文件的解析是通过系统注册表实现的。

现在我们的客户端应用程序已配置为引用 CarLibrary 程序集,您可以自由地创建一个使用这些类型的类。这是试驾(双关语,intended)。

// Our first taste of binary reuse.
namespace CSharpCarClient
{
using System;

// Make use of the CarLib types!
using CarLibrary;

public class CarClient
{
     public static int Main(string[] args)
     {
          // Make a sports car.
          SportsCar viper = new SportsCar("Viper", 240, 40);
          viper.TurboBoost();

          // Make a minivan.
          MiniVan mv = new MiniVan();
          mv.TurboBoost();

          return 0;
     }
}
}

这段代码看起来与迄今为止开发的应用程序一样。唯一有趣的地方是 C# 客户端应用程序现在正在使用定义在唯一程序集中的类型。请继续运行您的程序。正如您所期望的那样,执行此程序将显示两个消息框。

Visual Basic.NET 客户端应用程序

当您安装 Visual Studio.NET 时,您将获得四种能够构建托管代码的语言:JScript.NET、带有托管扩展的 C++ (MC++)、C# 和 Visual Basic.NET。Visual Studio.NET 的一个优点是所有语言都共享相同的 IDE。因此,Visual Basic.NET、ATL、C# 和 MFC 程序员都使用通用的开发环境。鉴于此,构建使用 CarLibrary 的 Visual Basic.NET 应用程序的过程很简单。假设已经创建了一个名为 VBCarClient 的新 VB.NET Windows 应用程序项目工作区(图 6-9)。与 Visual Basic 6.0 类似,此项目工作区提供了一个设计时模板,用于构建主窗口的 GUI。然而,VB.NET 是一个完全不同的动物。您正在查看的模板实际上是 Form 类型的子类,这与 VB 6.0 Form 对象有很大不同(更多细节请参见第 8 章)。

现在,再次使用“Add Reference”对话框,为 C# CarLibrary 设置引用。与 C# 类似,VB.NET 要求您列出项目使用的每个命名空间。但是,VB.NET 使用“imports”关键字而不是 C# 的“using”指令。因此,打开 Form 的代码窗口并添加以下内容:

' Like C#, VB.NET needs to 'see' the namespaces used by a given class.
Imports System
Imports System.Collections
. . .
Imports CarLibrary

使用设计时模板,构建一个最小且完整的用户界面来测试您的汽车类型(图 6-10)。两个按钮就足够了(只需从工具箱中选择 Button 小部件并将其绘制在 Form 对象上)。

下一步是添加事件处理程序来捕获每个 Button 对象的 Click 事件。为此,只需双击 Form 上的每个按钮。IDE 会响应并编写将在按钮单击时调用的存根代码。以下是一些示例代码:

' A little bit of VB.NET!
Protected Sub btnMiniVan_Click(ByVal sender As Object, 
                       ByVal e As System.EventArgs) Handles btnMiniVan.Click

        Dim sc As New MiniVan()
        sc.TurboBoost() 
End Sub

Protected Sub btnCar_Click(ByVal sender As Object, 
                     ByVal e As System.EventArgs) Handles btnCar.Click

        Dim sc As New SportsCar()
        sc.TurboBoost()      
End Sub

虽然本书的目标不是让您成为一个强大的 VB.NET 开发人员,但这里有一点值得注意。注意每个 Car 子类都是使用 New 关键字创建的。然而,与 VB 6.0 不同的是,类现在有了真正的构造函数!因此,附加在类名后面的空括号确实会调用类的给定构造函数。正如您所预期的那样,当您运行程序时,每辆汽车都会做出相应的响应。

跨语言继承

.NET 开发的一个非常有吸引力的方面是跨语言继承的概念。为了说明这一点,让我们创建一个新的 VB.NET 类,该类派生自 CarLibrary.SportsCar。您可能会说不可能?嗯,如果您使用的是 Visual Basic 6.0,那将是这种情况。然而,随着 VB.NET 的出现,程序员可以使用与 C#、Java 和 C++ 中相同的面向对象特性,包括经典继承(即,“is-a”关系)。

为了说明这一点,向您当前的 VB.NET 客户端应用程序添加一个名为 PerformanceCar 的新类(使用“Project | Add Class”菜单选项)。在下面的代码中,请注意您正在使用 VB.NET 的“Inherits”关键字从 C# Car 类型派生。正如您回忆的那样,Car 类定义了一个抽象的 TurboBoost() 方法,我们使用 VB.NET 的“Overrides”关键字来实现它。

' Yes, VB.NET supports each pillar of OOP!
Imports CarLibrary
Imports System.Windows.Forms

' This VB type is deriving from the C# SportsCar!
Public Class PerformanceCar
       Inherits CarLibrary.SportsCar
    
           ' Implementation of abstract Car method.
           Overrides Sub TurboBoost()
                MessageBox.Show("Blistering speed", "VB PerformanceCar says")
           End Sub
End Class

如果我们更新现有的 Form 以包含另一个按钮来测试高性能汽车,我们可以编写以下测试代码:

Protected Sub btnPreCar_Click(ByVal sender As Object, 
                     ByVal e As System.EventArgs) Handles btnPerfCar.Click
            
            Dim pc As New PerformanceCar()
            pc.PetName = "Hank"     ' Inherited property.

            ' Display base class.
            MessageBox.Show(pc.GetType().BaseType.ToString(), "Base class of Perf car")

            ' Custom Implementation of Car.TurboBoost()
            pc.TurboBoost()
End Sub

请注意,我们可以通过编程方式识别我们的基类(图 6-11)。

太棒了!此时,您已经开始将应用程序分解为离散的二进制构建块。鉴于 .NET 的语言无关性,任何面向运行时的语言都可以创建(和扩展)给定程序集中描述的类型。

探索 CarLibrary 的清单

此时,您已成功创建了一个单文件程序集和两个客户端应用程序。您的下一个任务是深入了解 .NET 程序集在底层是如何构建的。首先,请记住每个程序集都包含一个关联的清单,可以将其视为 .NET 的“罗塞塔石碑”。清单包含指定程序集的名称和版本,以及组成整个程序集的内部和外部模块列表的元数据。此外,清单可能包含区域性信息(用于国际化)、相应的“强名称”(共享程序集必需)以及可选的安全和资源信息(我们将在第 10 章中介绍 .NET 资源格式)。

.NET 感知的编译器(如 csc.exe)会在编译时自动创建清单。正如您在第 7 章中看到的,可以通过基于属性的编程技术来增强编译器生成的清单。现在,请加载 CarLibrary 程序集到 ILDasm.exe 中。如您所见,此工具已读取元数据以显示每个类型的相关信息(图 6-12)。

现在,通过双击 MANIFEST 图标来打开清单(图 6-13)。

清单中的第一个代码块用于指定当前程序集正常运行所需的所有外部程序集。正如您回忆的那样,CarLibrary.dll 使用了 mscorlib.dll 和 System.Windows.Forms.dll,它们都在清单中使用 [.assembly extern] 标记进行标记。

.assembly extern mscorlib
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )                         // .z\V.4
  .ver 1:0:2411:0
}

.assembly extern System.Windows.Forms
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )                        // .z\V.4..
  .ver 1:0:2411:0
}

在此,每个 [.assembly extern] 块都由 [.publickeytoken] 和 [.ver] 指令着色。[.publickeytoken] 指令仅在程序集被配置为共享程序集时存在,用于引用共享程序集的“强名称”(稍后有更多详细信息)。[.ver] 当然是数字版本标识符。

在枚举完所有外部引用之后,清单接着枚举程序集中包含的每个模块。鉴于 CarLibrary 是一个单文件程序集,您将只找到一个 [.module] 标记。此清单还列出了许多属性(用 [.custom] 标记标记),例如公司名称、商标等,所有这些目前都是空的(更多关于这些属性的信息将在第 7 章中介绍)。

.assembly CarLibrary
{
  .custom instance void [mscorlib]
System.Reflection.AssemblyKeyNameAttribute::.ctor(string) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyKeyFileAttribute::.ctor(string) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyDelaySignAttribute::.ctor(bool) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyTrademarkAttribute::.ctor(string) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyCopyrightAttribute::.ctor(string) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyProductAttribute::.ctor(string) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyCompanyAttribute::.ctor(string) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyConfigurationAttribute::.ctor(string)=( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyDescriptionAttribute::.ctor(string) = ( 01 00 00 00 00 )
  .custom instance void [mscorlib]
System.Reflection.AssemblyTitleAttribute::.ctor(string) = ( 01 00 00 00 00 )
 
  .hash algorithm 0x00008004
  .ver 1:0:454:30104
}
.module CarLibrary.dll

在这里,您可以看到 [.assembly] 标记用于标记您的自定义程序集(CarLibrary)的友好名称。与外部声明一样,[.ver] 标记定义此程序集的兼容版本号,其中 [.hash] 标记文件生成的哈希码。请注意,CarLibrary 程序集未定义 [.publickeytoken] 标记,因为 CarLibrary 未被配置为共享程序集。

为了总结程序集清单中存在的标签,请思考表 6-1。

表 6-1. 清单 IL 标签

清单声明标签

生命意义

.assembly

标记程序集声明,表明该文件是一个程序集。

.file

标记同一程序集中的其他文件。

.class extern

程序集导出的类,但在另一个模块中声明。

.exeloc

指示程序集的执行文件位置。

.manifestres

指示清单资源(如果有)。您将在第 9 章(GDI+)中看到此标签的应用。

.module

模块声明,表明该文件是一个模块(即,一个没有清单的 .NET 二进制文件),而不是一个程序集。

.module extern

此程序集的模块包含在此模块中引用的项。

Assembly extern

程序集引用指示另一个包含此模块引用的项的程序集。

.publickey

包含公钥的实际字节。

.publickeytoken

包含公钥的令牌。

探索 CarLibrary 的类型

请记住,程序集不包含平台特定的指令,而是平台无关的中间语言 (IL)。当 .NET 运行时将程序集加载到内存中时,底层 IL 会被(使用 JIT 编译器)编译成目标平台能够理解的指令。另外,请记住,除了原始 IL 和程序集清单之外,程序集还包含描述给定模块中每种类型的成员的元数据。

例如,如果您双击 SportsCar 类的 TurboBoost() 方法,ILDasm.exe 会打开一个新的窗口,显示原始 IL 指令。请注意,在以下屏幕截图中,[.method] 标记用于标识(当然)SportsCar 类型定义的某个方法(图 6-14)。如您所料,类型定义的公共数据用 [.field] 标记(图 6-15)。请记住,Car 类定义了一组受保护数据,例如 currSpeed(请注意,“family”标记表示受保护数据)。

属性也用 [.property] 标记(图 6-16)。该图显示了描述访问底层 currSpeed 数据点的公共属性的 IL(注意 CurrSpeed 属性的读/写性质由 .get 和 .set 标签标记)。

如果您现在选择“Ctrl + M”组合键,ILDasm.exe 将显示每种类型的元数据(图 6-17)。

使用这些元数据,.NET 运行时能够定位和构造对象实例,并调用方法。各种工具(如 Visual Studio.NET)在设计时使用元数据来验证编译期间参数的数量和类型。为了总结到目前为止的故事,请确保您清楚以下几点:

  • 程序集是一组版本化、自描述的模块。每个模块包含一定数量的类型和可选资源。
  • 每个程序集都包含元数据,这些元数据描述了给定模块中的所有类型。.NET 运行时(以及许多设计时工具)读取元数据来定位和创建对象、验证方法调用、激活 IntelliSense 等等。
  • 每个程序集都包含一个清单,该清单枚举了二进制文件所需的所有内部和外部文件的集合、版本信息以及其他程序集相关的详细信息。

接下来,您需要区分私有程序集和共享程序集。如果您是从经典 COM 的角度进入 .NET 范例,请准备好迎接一些重大变化。

理解私有程序集

严格来说,程序集要么是“私有的”,要么是“共享的”。好消息是每种变体都具有相同的底层结构(即,一定数量的模块和一个关联的清单)。此外,每种类型的程序集都提供相同类型的服务(例如,访问一定数量的公共类型)。私有程序集和共享程序集之间的真正区别在于命名约定、版本策略和部署问题。让我们从检查私有程序集的特征开始,这是两种选项中迄今为止最常见的。

私有程序集是一组类型,仅供与其一起部署的应用程序使用。例如,CarLibrary.dll 是 CSharpCarClient 和 VBCarClient 应用程序使用的私有程序集。当您创建私有程序集时,假设该类型集合仅由“拥有”应用程序使用,而不与系统上的其他应用程序共享。

私有程序集必须位于拥有应用程序的主目录(称为应用程序目录)或其子目录中。例如,回想一下,当我们为 CarLibrary 程序集设置引用时(正如我们在 CSharpCarClient 和 VBCarClient 应用程序中所做的那样),Visual Studio.NET IDE 会将该程序集的完整副本复制到您的项目应用程序目录中。这是默认行为,因为私有程序集被认为是首选的部署选项。

请注意与经典 COM 的鲜明对比。无需在 HKEY_CLASSES_ROOT 下注册任何项,也无需使用 InprocServer32 或 LocalServer32 条目输入二进制文件的硬编码路径。私有 CarLibrary 的解析和加载是因为程序集放置在应用程序目录中。事实上,如果您将 CSharpCarClient.exe 和 CarLibrary.dll 移动到新目录,应用程序仍然会运行。为了说明这一点,将这两个文件复制到您的桌面并运行客户端(图 6-18)。

卸载(或复制)仅使用私有程序集的应用程序是一个简单的问题。删除(或复制)应用程序文件夹。与经典 COM 不同,您不必担心几十个孤立的注册表设置。更重要的是,您不必担心删除私有程序集会破坏计算机上的任何其他应用程序!

探测基础知识

在本章稍后,您将接触到关于程序集位置解析的大量细节。在此之前,以下概述应该有助于您做好准备。严格来说,.NET 运行时使用一种称为探测的技术来解析私有程序集的位置,这种技术听起来并没有那么复杂。探测是从外部程序集引用(即 [.assembly extern])映射到正确的相应二进制文件的过程。例如,当运行时从 VBCarClient 的清单中读取以下行时:

.assembly extern CarLibrary
{
. . .
}

在应用程序目录中搜索名为 CarLibrary.DLL 的文件。如果找不到 DLL 二进制文件,则会尝试查找 EXE 版本(CarLibrary.EXE)。如果两者都找不到,则会进一步检查共享程序集(稍后介绍)。

私有程序集的标识

私有程序集的标识由一个友好的字符串名称和一个数字版本组成,这两者都记录在程序集清单中。友好名称是根据包含程序集清单的二进制模块的名称创建的。例如,如果您检查 CarLibrary.dll 程序集的清单,您会发现以下内容(确切版本可能会有所不同):

.assembly CarLibrary as "CarLibrary"
{
. . .
.ver 1:0:454:30104
}

然而,鉴于私有程序集的性质,.NET 运行时在加载程序集时不应用任何版本策略,这应该是可以理解的。假设私有程序集不需要任何复杂的版本检查,因为客户端应用程序是唯一“知道”其存在的实体。作为有趣的推论,您应该理解,一台计算机(非常)可能在各种应用程序目录中有多个相同私有程序集的副本。

私有程序集和 XML 配置文件

当 .NET 运行时被指示绑定到程序集时,第一步是确定应用程序配置文件是否存在。这些可选文件包含 XML 标签,用于控制启动应用程序的绑定行为。根据规定,配置文件必须与启动应用程序同名,并带有 *.config 文件扩展名。

如前所述,配置文件可用于指定在绑定到私有程序集的过程中要搜索的任何可选子目录。正如您在本章前面所见,一个组件化的 .NET 应用程序可以通过将所有程序集放在启动应用程序的同一目录下进行部署。然而,通常情况下,您可能希望以应用程序目录包含许多相关子目录的方式部署应用程序,以赋予应用程序整体有意义的结构。

您在商业软件中经常看到这种情况。例如,假设我们的主目录称为 MyRadApplication,其中包含多个子目录(\Images、\Bin、\SavedGames、\OtherCoolStuff)。使用应用程序配置文件,您可以指示运行时在尝试定位启动应用程序使用的私有程序集集合时应探测的位置。

为了说明这一点,让我们为前面的 CSharpCarClient 应用程序创建一个简单的配置文件。我们的目标是将引用的程序集(CarLibrary)从 Debug 文件夹移动到一个名为 Foo \ Bar 的新子目录中。请立即移动此文件(图 6-19)。

现在,创建一个名为 CSharpCarClient.exe.config 的新配置文件(记事本即可),并将其保存在与 CSharpCarClient.exe 应用程序相同的文件夹中。应用程序配置文件的开头用 标签标记。在 < /Configuration.> 标签结束之前,指定一个 assemblyBinding 行,该行用于使用 privatePath 属性指定搜索给定程序集的备用位置(FYI,可以使用分号分隔的列表指定多个子目录)。

<configuration>
    <runtime>
        <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
            <probing privatePath="foo\bar"/>
        </assemblyBinding>
    </runtime>
</configuration>

完成后,保存文件并启动客户端。您会发现 CSharpCarClient 应用程序运行顺利。作为最后的测试,更改配置文件的名称并再次尝试运行程序(图 6-20)。

客户端应用程序会默默失败。请记住,配置文件必须与启动应用程序同名。由于您重命名了此文件,.NET 运行时会假定您没有配置文件,因此会尝试在应用程序目录中探测引用的程序集(但找不到)。

绑定到私有程序集的具体细节

为了总结当前的讨论,让我们正式化在运行时绑定到私有程序集所涉及的具体步骤。首先,加载程序集的请求可能是显式的或隐式的。当清单直接引用某个外部程序集时,就会发生隐式加载请求。正如您所回忆的,外部引用用 [.assembly extern] 指令标记。

// An implicit load request. . .
.assembly extern CarLibrary
{
. . .
}

使用 System.Reflection.Assembly.Load() 以编程方式执行显式加载请求。Assembly 类在第 7 章中进行了介绍,但请注意,Load() 方法允许您在语法上指定名称、版本、强名称和区域性信息(请注意,您不需要指定每个特征)。

// An explicit load request. . .
Assembly asm = Assembly.Load("CarLibrary");

总而言之,名称、版本、强名称和区域性信息统称为程序集引用(或简称为 AsmRef)。负责根据 AsmRef 定位正确程序集的实体称为程序集解析器,它是 CLR 的一项功能。

如前所述,应用程序目录不过是硬盘上的一个文件夹(例如,C:\MyApp),其中包含给定应用程序的所有文件。如有必要,应用程序目录可以指定其他子目录(例如,C:\MyApp\Bin、C:\MyApp\Tools 等),以建立更严格的文件层次结构。

当发出绑定请求时,运行时会将 AsmRef 传递给程序集解析器。如果解析器确定 AsmRef 指的是私有程序集(意味着清单中没有记录强名称),则遵循以下步骤:

  1. 首先,程序集解析器尝试在应用程序目录中查找配置文件。正如您将看到的,此文件可以指定要包含在搜索中的其他子目录,以及建立要用于当前绑定的版本策略。
  2. 如果不存在配置文件,运行时会尝试通过检查当前应用程序目录来发现正确的程序集。如果存在配置文件,则会搜索指定的子目录。
  3. 如果在应用程序目录(或指定的子目录)中找不到程序集,则搜索在此停止,并引发 TypeLoadException 异常,因为私有程序集始终位于应用程序目录(或指定的子目录)内。

为了巩固这一系列事件,图 6-21 说明了上述过程。

同样,正如您所看到的,解析私有程序集的位置非常简单。如果应用程序目录不包含配置文件,程序集解析器只需查找与正确字符串名称匹配的二进制文件。如果应用程序目录包含配置文件,也会搜索指定的子目录。

理解共享程序集

与私有程序集一样,“共享”程序集也是一组包含在某些模块中的类型和(可选)资源。共享程序集与私有程序集之间最明显的区别在于,共享程序集可以被一台计算机上的多个客户端使用。显然,如果您想创建一个机器范围的类库,共享程序集是理想的选择。

共享程序集通常不部署在其使用的应用程序的同一目录中。相反,共享程序集被安装到一个机器范围的全局程序集缓存中,这又为编程宇宙带来了另一个生动的缩写:GAC。GAC 本身位于

这是 COM 和 .NET 体系结构之间的另一个主要区别。在 COM 中,只要配置正确,共享应用程序就可以驻留在机器上的任何位置。在 .NET 下,共享程序集通常放置在一个集中的、众所周知的 [ ]位置(GAC)。

与私有程序集不同,共享程序集需要比友好文本字符串更多的版本信息。正如您可能猜到的,.NET 运行时在为调用应用程序加载共享程序集之前会强制执行版本检查。此外,必须为共享程序集分配一个“共享名称”(也称为“强名称”)。

您的 GAC 出了问题?

顺便提一下,就本文所基于的 Beta2 而言,我注意到我的某些开发机器无法正确显示 GAC。问题在于 GAC 是一个 shell 扩展,需要注册一个名为 shfusion.dll 的 COM 服务器。在安装过程中,此服务器可能无法正确注册。如果您在打开 GAC 时遇到问题,只需使用 regsvr32.exe 注册此 COM 服务器即可。

理解共享(强)名称

当您希望创建可供给定计算机上的多个应用程序使用的程序集时,第一步是为其创建一个唯一的共享名称。共享名称包含以下信息:

  • 一个友好的字符串名称和可选的区域性信息(就像私有程序集一样)。
  • 一个版本标识符。
  • 一对公钥/私钥。
  • 一个数字签名。

共享名称的构成基于标准的公钥加密。当您创建共享程序集时,您必须生成一对公钥/私钥(您将在稍后完成)。该密钥对使用 .NET 感知的编译器包含在构建周期中,后者通过 [.publickeytoken] 标签在程序集的清单中列出公钥的令牌。私钥不列在清单中,而是用公钥进行签名。生成的签名存储在程序集本身中(对于多文件程序集,私钥存储在定义清单的文件中)。

现在,假设某个客户端引用了此共享程序集(这与引用私有程序集无异)。当编译器生成客户端二进制文件时,公钥将记录在其清单中。在运行时,.NET 运行时会确保客户端和共享程序集都使用相同的密钥对。如果这些密钥相同,客户端应用程序就可以确信已加载了正确的程序集。图 6-23 展示了基本图景。

正如您可能猜到的,关于密钥对还有其他细节。目前我们不需要更多细节,如果您愿意,可以查看在线帮助。

构建共享程序集

要为您的程序集生成强名称,您需要使用 sn.exe(强名称)实用程序。尽管此工具具有许多命令行选项,但我们只需要关心“-k”参数,该参数指示该工具生成一个新的强名称密钥,并将其保存到指定的文件中(图 6-24)。

如果您检查此新文件(theKey.snk)的内容,您将看到密钥对的二进制标记(图 6-25)。

为了继续举例,假设您创建了一个新的 C# 类库,当然就命名为 SharedAssembly,其中包含以下类定义:

using System;
using System.Windows.Forms;

namespace SharedAssembly
{
public class VWMiniVan
{
     public VWMiniVan(){}

     public void Play60sTunes()
     {
          MessageBox.Show("What a loooong, strange trip it's been. . .");
     }

     private bool isBustedByTheFuzz = false;
     public bool Busted
     {
          get { return isBustedByTheFuzz; }
          set { isBustedByTheFuzz = value; }
     }
}
}

下一步是将公钥记录在程序集清单中。最简单的方法是利用一个名为 AssemblyKeyFile 的属性。当您创建新的 C# 项目工作区时,您会注意到您的初始项目文件之一名为“AssemblyInfo.cs”(图 6-26)。

此文件包含许多(最初为空)属性,这些属性由 .NET 感知的编译器使用。如果您检查此文件,您会发现一个名为 AssemblyKeyFile 的属性。要指定共享程序集的强名称,只需将初始空值更新为指定 *.snk 文件位置的字符串:

[assembly: AssemblyKeyFile(@"D:\SharedAssembly\theKey.snk")]

使用此程序集级别的属性,C# 编译器现在会将必要的信息合并到相应的清单中,您可以使用 ILDasm.exe 进行查看(请注意图 6-27 中的 [.publickey] 标记)。

将程序集安装到 GAC

一旦您为共享程序集建立了强名称,最后一步就是将其安装到 GAC。将私有程序集安装到 GAC 的最简单方法是将文件拖放到活动窗口上(您也可以使用 gacutil.exe 命令行工具)。参见图 6-28。

请注意,您必须拥有计算机的管理员权限才能将程序集安装到 GAC。这是一个好事情,因为它阻止了普通用户意外破坏现有应用程序。

结果是您的程序集现在已放置在 GAC 中,并且可以被目标计算机上的多个应用程序共享。在相关方面,当您希望从 GAC 中删除程序集时,可以通过简单的右键单击来完成(只需从上下文菜单中选择“Delete”)。

使用共享程序集

为了证明这一点,假设您创建了一个新的 C# 控制台应用程序(名为 SharedAssemblyUser),设置了对 SharedAssembly 二进制文件的引用,并创建了以下类定义:

namespace SharedLibUser
{
using System;
using SharedAssembly;

public class SharedAsmUser
{
     public static int Main(string[] args)
     {
          try
          {
               VWMiniVan v = new VWMiniVan();
               v.Play60sTunes();
          } 
          catch(TypeLoadException e)
          {
               // Can't find assembly!
               Console.WriteLine(e.Message);
          }
          return 0;
     }
}
}

请记住,当您引用共享程序集时,IDE 会自动为客户端应用程序创建一个本地副本。然而,当您引用包含公钥的程序集时(如 SharedAssembly.dll 的情况),您不会获得本地副本。假设包含公钥的程序集被设计为共享的(因此被放置在 GAC 中)。

请注意,VS.NET IDE 允许您使用“Properties”窗口显式控制给定程序集的复制。例如,如果您已设置了对外部二进制文件的引用,请在“Solution Explorer”中使用此程序集,并将“Copy Local”设置为 false。这将删除本地副本(图 6-29)。

现在再次运行客户端应用程序。如果一切正常,一切都应该继续正常工作,因为 .NET 运行时在解析请求的程序集位置时会查询 GAC(图 6-30)。

理解 .NET 版本策略

正如您已经学到的,.NET 运行时不会对私有程序集执行版本检查。当发出加载共享程序集的请求时,版本控制的故事会发生显著变化。鉴于共享程序集的版本至关重要,让我们回顾一下版本号的构成。正如您所回忆的,版本号由四个独立的部分标记(例如 2.0.2.11)。然而,在逻辑上,.NET 运行时能够提取三个有意义的版本兼容性信息,如图 6-31 所示。

每当两个程序集在主版本号或次版本号上有所不同时(例如,2.0 vs. 2.5),.NET 运行时就会认为它们是完全不兼容的。当程序集在主版本号或次版本号上有所不同时,您可以假定发生了重大更改(例如,方法名称更改、添加或删除了类型、参数已更改等)。因此,如果客户端请求绑定到版本 2.0,但 GAC 中只有版本 2.5,则绑定请求会失败(除非被应用程序配置文件覆盖)。

如果两个程序集具有相同的 [ ]主版本号和次版本号,但具有不同的修订号(例如,2.5.0.0 vs. 2.5.1.0),.NET 运行时会假定它们可能彼此兼容(换句话说,假定向后兼容,但不保证)。举个具体的例子,服务包发布通常涉及修改程序集的修订号。

最后,您有快速修复工程 (QFE) 号。当两个程序集仅在 QFE 值上有所不同时,.NET 运行时会假定它们完全兼容。QFE 通常在软件补丁发布时进行修改。这里的想法是,所有调用约定(例如,方法名称、参数、支持的接口等)都与先前版本相同。

记录版本信息

您此时可能会问的一个问题是,这个版本号是在哪里指定的?请记住,每个 C# 项目都定义了一个名为 AssemblyInfo.cs 的文件。如果您检查此文件,您会看到一个名为 AssemblyVersion 的属性,该属性最初设置为一个字符串“1.0.*”。

[assembly: AssemblyVersion("1.0.*")]

每个新的 C# 项目都以 1.0 的版本开始。当您构建共享程序集的新版本时,您的任务之一就是更新共享程序集的四部分版本号。请注意,IDE 会自动递增内部版本和修订号(由“*”标记标记)。如果您想强制应用程序特定值作为程序集的内部版本号和/或修订号,只需相应地更新即可。

[assembly: AssemblyVersion("1.0.0.0")]

冻结当前 SharedAssembly

要真正理解 .NET 版本策略,我们需要一个具体的例子。当前目标是更新您之前的 SharedAssembly.dll 以支持其他功能,更新版本号,然后将新版本放入 GAC。此时,您可以尝试使用应用程序配置文件来指定各种版本策略,以及并行执行。

首先,让我们更新 VWMiniVan 类的构造函数,以显示一条消息来验证当前版本。

public VWMiniVan()
{
     MessageBox.Show("Using version 1.0.0.0!", "Shared Car");
}

接下来,将 AssemblyVersion 属性更新为完全限定版本 1.0.0.0(如前一节所述)。继续重新编译项目。

您需要做的下一件事是确保我们的原始 SharedAssembly.dll 已从 GAC 中删除(请将其删除)。然后,将您现有的 1.0.0.0 程序集移动到一个新文件夹(我称之为 Version1),以确保您冻结此版本(图 6-32)。

现在(再次!)将此程序集放回 GAC。请注意,此程序集的版本为 <1.0.0.0。(图 6-33)。

一旦版本 1.0.0.0 的 SharedAssembly 被插入 GAC,右键单击此程序集并从上下文相关弹出菜单中选择“Properties”。验证此二进制文件的路径映射到 Version1 子目录。最后,重新构建并运行当前的 SharedAssemblyUser 应用程序。一切应该继续正常工作。

构建 SharedAssembly 版本 2.0

为了说明 .NET 版本策略,让我们修改当前的 SharedAssembly 项目。更新您的 VWMiniVan 类,添加一个新成员(使用自定义枚举)以允许用户播放更现代的音乐选择。另外,请务必更新构造函数逻辑中显示的 #-} 消息。

// Which band do you want?
public enum BandName
{
     TonesOnTail, SkinnyPuppy, deftones, PTP
}

public class VWMiniVan
{
     public VWMiniVan()
     { MessageBox.Show("Using version 2.0.0.0!", "Shared Car"); }
. . .
     public void CrankGoodTunes(BandName band)
     {
          switch(band)
          {
               case BandName.deftones:
                    MessageBox.Show("So forget about me. . .");
                    break;
               case BandName.PTP:
                    MessageBox.Show("Tick tick tock. . .");
                    break;
               case  BandName.SkinnyPuppy:
                    MessageBox.Show("Water vapor, to air. . .");
                    break;
               case  BandName.TonesOnTail:
                    MessageBox.Show("Oooooh the rain. Oh the rain.");
                    break;
               default:
                    break;
          }
     } 
}

在编译之前,让我们将此程序集的版本升级到 2.0.0.0。

// Update your assemblyinfo.cs file as so. . .
[assembly: AssemblyVersion("2.0.0.0")]

如果您查看项目调试文件夹,您会发现此程序集有一个新版本(2.0),而前一个版本安全地存储在 Version1 目录下的存储中。最后,让我们将此新程序集安装到 GAC。请注意,您现在有两个相同程序集的版本(图 6-34)。

现在您在 GAC 中记录了一个明显版本化的程序集,您可以开始使用应用程序配置文件来控制客户端如何绑定到某个版本。但首先,关于默认绑定策略的几句话。

理解默认版本策略

正如本章前面提到的,如果客户端引用共享程序集,则主版本和次版本必须相同,绑定才能成功。然而,.NET 运行时在程序集引用在修订号或内部版本号上有所不同时会绑定到给定的程序集。此行为称为默认版本策略,用于确保客户端始终获得给定程序集的最新(且最棒的)服务版本(即,bug 修复)。因此,如果客户端的清单明确要求版本 1.0.0.0,但 GAC 中有一个更新的版本,指定了 QFE(如 1.0.2.2),则客户端会自动收到最新的修复。这样,客户端应用程序就能保证它所引用的程序集是向后兼容的,并且尽可能没有 bug。

指定自定义版本策略

当您希望动态控制应用程序如何绑定到程序集时(例如,禁用 QFE),您需要编写应用程序配置文件。正如您在讨论私有程序集时已经看到的,配置文件是用于自定义绑定过程的 XML 块。请记住,这些文件必须与拥有应用程序同名(带有 *.config 扩展名),并且必须直接放在应用程序目录中。除了 privatePath 标签(用于指定探测私有程序集的目录)之外,配置文件还可以为共享程序集指定信息。

第一个要点是使用应用程序配置文件来指定要加载的特定程序集版本,而不考虑清单中可能列出的内容。当您希望将客户端重定向到绑定到备用的共享程序集时,可以使用 <dependentAssembly. 和 <bindingRedirect. 属性。例如,以下配置文件强制版本 2.0.0.0。

<configuration>
    <runtime>
        <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
            <dependentAssembly>
                <assemblyIdentity name="sharedassembly" 
                         publicKeyToken="6c0646f072c6fe39" 
                         culture=""/>

                <bindingRedirect oldVersion= "1.0.0.0"
                                 newVersion= "2.0.0.0"/>

            </dependentAssembly>
        </assemblyBinding>
    </runtime>
</configuration>

在此,oldVersion 标签用于指定您希望覆盖的版本。newVersion 标签标记要加载的特定版本。

要亲自测试这一点,请创建之前的配置文件,并将其保存到 SharedAssemblyUser 应用程序的目录中(请确保为该配置文件命名正确)。现在,运行程序。您应该会看到图 6-35 中显示的警告消息。

如果您将 newVersion 属性更新为 1.0.0.0,您现在将看到图 6-36 中显示的警告消息。

非常酷。您刚刚观察到的是本章前面提到的“并行执行”的概念。因为 .NET 框架允许您将同一程序集的多个版本放置到 GAC 中,所以您可以根据您(或系统管理员)的需要轻松配置自定义版本策略。

正如您所见,.NET 框架确实认真对待共享程序集的版本。通过使用应用程序配置文件,您可以控制有关给定程序集哪个版本应被拥有应用程序加载的许多详细信息。正如您可能预期的那样,应用程序的配置文件中还可以列出其他属性。您可以根据需要调查这些详细信息。但是,还有一个最终方面需要考虑……

管理员配置文件

您在本章中检查过的配置文件都有一个共同的主题。它们仅适用于特定应用程序(这就是为什么它们与拥有应用程序同名的原因)。.NET 框架确实允许另一种类型的配置文件,称为管理员配置文件。每个了解 .NET 的计算机都有一个名为“machine.config”的文件,其中包含用于覆盖任何应用程序特定配置文件的列表。正如您可能猜到的,阅读此文件是了解更多 *.config 中心标签的好方法。

现在您已经对 .NET 程序集有了深入的了解,让我们完全切换方向,研究应用程序域和多线程程序集的相关主题。虽然这似乎是内容的重大变化,但您会发现程序集、应用程序域和线程是相互关联的。

传统 Win32 线程编程回顾

根据您的编程背景,您可能非常热衷于构建多线程二进制文件,可能对构建多线程二进制文件不感兴趣,或者对多线程的含义不太确定。为了缩小差距,让我们花点时间快速回顾一下多线程的基础知识。一旦您回顾了传统 Win32 角度的多线程,您将了解 .NET 平台下的情况发生了怎样的变化。

首先,请记住,在传统 Win32 下,每个应用程序都由一个进程托管。请理解,进程是一个通用术语,用于描述一组外部资源(例如 COM 服务器)以及给定应用程序所需的内存分配。对于加载到内存中的每个 EXE,操作系统都会创建一个单独且隔离的内存分区(即进程)供其在其生命周期内使用。

每个正在运行的进程至少有一个主“线程”,它充当应用程序的入口点。严格来说,在给定进程中创建的第一个线程称为主线程。简而言之,线程是 Win32 进程中的特定执行路径。传统 Windows 应用程序将 WinMain() 方法定义为应用程序的入口点。另一方面,控制台应用程序提供 main() 方法来实现相同目的。

只包含单条执行路径的应用程序自动“线程安全”,因为在任何给定时间只有一个线程可以访问应用程序中的数据。不利的一面是,如果单个线程正在执行复杂操作(例如打印长文本文件、执行奇异计算或连接到远程服务器),则单线程应用程序可能会显得有些无响应。

在 Win32 下,主线程可以通过使用 handful of Win32 API 函数(如 CreateThread())在后台生成其他辅助线程。每个线程(主线程或辅助线程)在进程中都成为唯一的执行路径,并对该进程中的所有数据具有并发访问权。正如您可能猜到的,开发人员通常会创建其他线程来帮助提高程序的整体响应能力。

多线程应用程序给人一种许多活动几乎同时发生的错觉。例如,您可以生成一个后台工作线程来执行劳动密集型工作单元(再次,例如打印大型文本文件)。当这个辅助线程忙碌地工作时,主线程仍然对用户输入做出响应,这使整个进程有可能提供更高的性能。然而,这仅仅是一种可能性。单个进程中的线程过多实际上会降低性能,因为 CPU 必须在进程中的活动线程之间切换(这需要时间)。

实际上,多线程通常是操作系统提供的一种简单错觉。具有单个 CPU 的机器无法在同一时间真正处理多个线程。相反,单个 CPU 将根据线程的优先级级别执行一个线程一段时间(称为时间片)。当一个线程的时间片用完时,现有的线程将被挂起,以便其他线程执行其任务。为了让线程记住在它被踢出之前发生了什么,每个线程都有能力写入线程本地存储(TLS),并提供一个单独的调用堆栈,如图 6-37 所示。

并发和线程同步问题

除了耗时之外,线程之间的切换过程还会导致其他问题。例如,假设一个给定线程正在访问一个共享数据点,并且在过程中开始修改它。现在假设第一个线程被告知等待,以便另一个线程可以访问同一数据点。如果第一个线程尚未完成其任务,第二个线程可能会修改处于不稳定状态的数据。

为了保护应用程序的数据免受潜在损坏,Win32 开发人员必须使用任意数量的 Win32 线程原语,例如临界区、互斥锁或信号量来同步对共享数据的访问。鉴于此,多线程应用程序更加易变,因为许多线程可以同时操作应用程序的数据。除非开发人员考虑到了这种情况并使用了线程原语(例如临界区),否则程序可能会导致大量数据损坏。

尽管 .NET 平台无法完全消除构建健壮的多线程应用程序的困难,但该过程已大大简化。使用 System.Threading 命名空间中定义的类型,您可以轻松地生成其他线程,无需过多麻烦。同样,当需要锁定共享数据点时,您会找到提供与 Win32 线程原语相同功能的其他类型。

理解 System.AppDomain

在我们详细检查 System.Threading 命名空间之前,我们需要检查应用程序域的概念。如您所知,.NET 应用程序是通过将任意数量的相关程序集组合在一起而创建的。然而,与传统的(非 .NET)Win32 EXE 应用程序不同,.NET 应用程序由一个称为“应用程序域”(又称 AppDomain)的实体托管。请务必注意,AppDomain 一词并非 Win32 进程的同义词。

实际上,单个进程可以托管任意数量的 AppDomain,每个 AppDomain 都完全独立于此进程(或任何其他进程)中的其他 AppDomain。运行在不同 AppDomain 中的应用程序无法共享任何类型的信息(全局变量或静态字段),除非它们使用 .NET Remoting 协议。整体图示如图 6-38 所示。

请注意与传统 Win32 进程的显著区别。在 .NET 下,单个进程可能包含多个 AppDomain。每个 AppDomain 可能包含多个线程。在某些方面,这种布局令人想起经典 COM 的“组件”架构。当然,.NET AppDomain 是托管类型,而 COM 组件架构建立在非托管(且复杂得多)的架构之上。

AppDomain 由 System.AppDomain 类型以编程方式表示。表 6-2 显示了一些需要注意的核心成员。

AppDomain 的乐趣

正如您所见,AppDomain 的成员提供了许多类似进程的行为,并带有 .NET 的特色。为了说明这种特色,请考虑以下命名空间定义。

namespace MyAppDomain
{
     using System;
     using System.Windows.Forms;

     // Need this namespace to work with the Assembly type.
     using System.Reflection;

     public class MyAppDomain
     {
          public static void PrintAllAssemblies()
          {
               // Ask the current AppDomain for a list of all
               // loaded assemblies.
               AppDomain ad = AppDomain.CurrentDomain;
               Assembly[] loadedAssemblies = ad.GetAssemblies();

               Console.WriteLine("Here are the assemblies loaded in " + 
                                 "this appdomain\n");

               // Now print the fully qualified name of each one.
               foreach(Assembly a in loadedAssemblies)
               {
                    Console.WriteLine(a.FullName);
               }
          }
          public static int Main(string[] args)
          {
               // Force the loading of the Windows Forms assembly.
               MessageBox.Show("Loaded System.Windows.Forms.dll");
               PrintAllAssemblies();
               return 0;
          }
     }
}

首先,请注意您正在使用一个新的命名空间 System.Reflection。此命名空间的完整详细信息参见第 7 章。目前,只需了解此命名空间定义了 Assembly 类型,鉴于 PrintAllAssemblies() 方法的作用,我们需要访问它。

此静态成员获取对托管 AppDomain 的引用,并枚举已加载程序集的列表。为了更有趣,请注意 Main() 方法会启动一个消息框,以强制程序集解析器加载 System.Windows.Forms.dll 程序集(这将加载其他引用的程序集)。图 6-39 显示了输出。

System.Threading 命名空间

System.Threading 命名空间提供了一系列支持多线程编程的类型。除了提供表示特定线程的类型外,该命名空间还定义了可以管理线程集合(ThreadPool)、一个简单的(非 GUI)Timer 类以及用于提供对共享数据同步访问的许多类型的类型。表 6-3 列出了一些(但不是全部)核心项目。

表 6-3. System.Treading 命名空间的选择类型

System.Threading 类型   

生命意义

Interlocked   

Interlocked 类用于提供对共享数据的同步访问。

Monitor   

提供使用锁和等待/信号进行线程对象的同步。

Mutex   

用于进程间同步的同步原语。

Thread   

表示在 CLR 中执行的线程。使用此类型,您可以生成拥有 AppDomain 中的其他线程。

ThreadPool   

此类型管理给定进程中的相关线程。

Timer   

指定在指定时间调用的委托。等待操作由线程池中的线程执行。

WaitHandle   

表示运行时中所有同步对象(允许多次等待)。

ThreadStart   

ThreadStart 类是一个委托,指向线程启动时应首先执行的方法。

TimerCallback   

Timers 的委托。

WaitCallback   

此类是定义 ThreadPool 用户工作项回调方法的委托。

检查 Thread 类

System.Threading 命名空间中最基础的类型是 Thread。此类表示特定 AppDomain 中给定执行路径的对象包装器。此类型定义了许多方法(静态和共享),允许您从当前线程创建新线程,以及挂起、停止和销毁给定线程。首先,请考虑表 6-4 中给出的核心静态成员列表。

表 6-4. Thread 类型的静态成员

Thread 静态成员   

生命意义

CurrentThread   

此(只读)属性返回对当前运行线程的引用。

GetData()   

检索当前线程的指定槽的值,针对该线程的当前域。

GetDomain()   

返回对当前线程正在运行的 AppDomain 的引用(或该域的 ID)。

Sleep()   

将当前线程挂起指定的时长。

Thread 还支持表 6-5 中所示的对象级别成员。

表 6-5. Thread 类型的实例方法

 

Thread 实例级别成员   

生命意义

IsAlive   

此属性返回一个布尔值,指示此线程是否已启动。

IsBackground   

获取或设置一个值,该值指示此线程是否为后台线程。

Name   

此属性允许您为执行路径设置一个友好的文本名称。

Priority   

获取或设置线程的优先级,该优先级可以从 ThreadPriority 枚举中赋值。

ThreadState   

获取此线程的状态,该状态可以从 ThreadState 枚举中赋值。

Interrupt()   

中断当前线程。

Join()   

指示线程等待给定线程。

Resume()   

恢复已挂起的线程。

Start()   

开始由 ThreadStart 委托指定的线程的执行。

Suspend()   

挂起线程。如果线程已挂起,则调用 Suspend() 无效。

生成辅助线程

当您希望创建其他线程来执行某些工作时,您需要与 Thread 类以及一个名为 ThreadStart 的特殊线程相关委托进行交互。一般过程很简单。首先,您需要创建一个函数来执行后台工作。为了保持重点,让我们构建一个简单的辅助类,它通过 DoSomeWork() 成员函数打印一系列数字。

internal class WorkerClass
{ 
     public void DoSomeWork()
     {
          // Get some information about this worker thread.
          Console.WriteLine("ID of worker thread is: {0}",
                            Thread.CurrentThread.GetHashCode()); 

          // Do the work.
          Console.Write("Worker says: ");
          for(int i = 0; i < 10; i++)
          {
               Console.Write(i + ", ");
          }
          Console.WriteLine();
     }
}

现在假设您有另一个类(MainClass)创建了一个 WorkerClass 的新实例。为了让 MainClass 继续处理其工作流程,它会创建一个新的线程并启动该线程,该线程由工作线程使用。在下面的代码中,请注意 Thread 类型需要一个新的 ThreadStart 委托类型。

public class MainClass
{
     public static int Main(string[] args)
     {
          // Get some information about the current thread.
          Console.WriteLine("ID of primary thread is: {0}",
                            Thread.CurrentThread.GetHashCode());

          // Make worker class.
          WorkerClass w = new WorkerClass();

          // Now make (and start) the background thread.
          Thread backgroundThread = 
                 new Thread(new ThreadStart(w.DoSomeWork));

          backgroundThread.Start();
 
          return 0;
     }
}

如果您运行应用程序(图 6-40),您会发现每个线程都有一个唯一的 ID(这是件好事,因为您现在应该有两个独立的线程)。

命名线程

Thread 类的一个有趣方面是它提供了为底层执行路径分配友好字符串名称的功能。为此,请使用 Name 属性。例如,您可以按如下方式更新 MainClass:

public class MainClass
{
     public static int Main(string[] args)
     {
          // Name the current thread.
          Thread primaryThread = Thread.CurrentThread;
          primaryThread.Name = "Boss man";

          Console.WriteLine("ID of  {0} is {1}", primaryThread.Name,
                                                 primaryThread.GetHashCode());

          // same code as before. . .
     }
}

输出现在如数字 6-41 所示。

正如您可能想到的,此属性提供了一种更友好的方式来识别您系统中的线程。

堵塞主线程

当前应用程序创建一个辅助线程来执行工作单元。问题在于打印 10 个数字根本不费时,因此我们实际上无法欣赏主线程可以继续处理这一事实。让我们更新应用程序以说明这一事实。首先,让我们更新 WorkerClass 以打印 30,000 个数字(使用 WriteLine() 而不是 Write(),这样您就可以看到打印输出),而不是仅仅 10 个。

internal class WorkerClass
{
     public void DoSomeWork()
     {
          . . .
          // Do a lot of work.
          Console.Write("Worker says: ");
          for(int i = 0; i < 30000; i++)
          {
               Console.WriteLine(i + ", ");
          }
          Console.WriteLine();
     }
}

接下来,让我们更新 MainClass,使其在创建后台工作线程后直接启动一个消息框。

public class MainClass
{
     public static int Main(string[] args)
     {
          // Name the current thread.
          . . .

          // Make worker class.
          . . .

          // Now make the thread.
          . . .
               
          // Now while background thread is working, 
          // do some additional work.
          MessageBox.Show("I'm busy");
 
          return 0;
     }
}

如果您现在运行应用程序,您会发现消息框已显示并且可以移动到桌面上的任何位置,而后台工作线程正忙于将数字推送到控制台(图 6-42)。

现在,将此行为与单线程应用程序中可能发现的行为进行对比。假设 Main() 方法已更新为允许用户输入 AppDomain 中使用的线程数的逻辑。

public static int Main(string[] args)
{
     Console.Write("Do you want [1] or [2] threads? ");
     string threadCount = Console.ReadLine();

     // Name the current thread.
     . . .

     // Make worker class.
     WorkerClass w = new WorkerClass();

     // Only make a new thread if the user said so.
     if(threadCount = = "2")
     {
          // Now make the thread.
          Thread backgroundThread = 
               new Thread(new ThreadStart(w.DoSomeWork));
          backgroundThread.Start();
     }
     else
          w.DoSomeWork();

     // Do some additional work.
     MessageBox.Show("I'm busy");
            
     return 0;
}

您可能猜测,如果用户输入值“1”,他/她必须等待所有 30,000 个数字打印完毕才能看到消息框出现,因为 AppDomain 中只有一个线程。但是,如果用户输入“2”,他/她就可以与消息框进行交互,而辅助线程则会继续运行。

让线程休眠

静态 Thread.Sleep() 方法可用于将当前线程挂起指定的时长(以毫秒为单位)。为了说明这一点,让我们再次更新 WorkerClass。这一次,DoSomeWork() 方法不会在控制台打印 30,000 行,而是打印 5 行。诀窍是,在每次调用 Console.WriteLine() 之间,后台线程会休眠大约 5 秒钟。

internal class WorkerClass
{
     public void DoSomeWork()
     {
          // Get some information about the worker thread.
          Console.WriteLine("ID of worker thread is: {0}",
                            Thread.CurrentThread.GetHashCode());

          // Do the work (and take a nap).
          Console.Write("Worker says: ");
          for(int i = 0; i < 5; i++)
          {
               Console.WriteLine(i + ", ");
               Thread.Sleep(5000);
          }
          Console.WriteLine();
     }
}

输出显示在图 6-43 中。

并发性重访

鉴于此前的例子,您可能会认为线程是您一直在寻找的神奇子弹。只需为应用程序的每个部分创建线程,最终结果就是提高了应用程序的性能。您已经知道这是一个有争议的问题,因为前面的陈述是错误的。如果使用不谨慎和不周到,过多的线程实际上会降低应用程序的性能。

更重要的是,给定 AppDomain 中的每一个线程都可以直接访问应用程序的共享数据。在当前示例中,这不是问题。但是,想象一下,如果主线程和辅助线程都在修改共享数据点,会发生什么。正如您所知,线程调度程序会迫使线程在随机时间暂停工作。既然如此,如果线程 A 在其工作完全完成之前就被踢出去了怎么办?答案是线程 B 现在正在读取不稳定的数据。

为了说明这一点,让我们构建一个名为 MultiThreadSharedData 的新的多线程 C# 控制台应用程序。此应用程序还有一个名为 WorkerClass 的类,该类在功能上类似于之前的同名类型。

internal class WorkerClass
{
     public void DoSomeWork()
     {
          // Do the work.
          for(int i = 0; i < 5; i++)
          {
               Console.WriteLine("Worker says: {0},", i);
          }
     }
}

您还有一个名为 MainClass 的类型。在此应用程序中,MainClass 负责创建三个不同的辅助线程。问题在于,这些线程都在调用 WorkerClass 类型的共享实例。

public class MainClass
{
     public static int Main(string[] args)
     {
          // Make the single worker object.
          WorkerClass w = new WorkerClass();

          // Create three secondary threads,
          // each of which makes calls to the same shared object.
          Thread workerThreadA = 
                 new Thread(new ThreadStart(w.DoSomeWork));
          Thread workerThreadB = 
                 new Thread(new ThreadStart(w.DoSomeWork));
          Thread workerThreadC = 
                 new Thread(new ThreadStart(w.DoSomeWork));

          // Now start each one.
          workerThreadA.Start();
          workerThreadB.Start();
          workerThreadC.Start();
          
          return 0;
     }
}

在看到一些测试运行之前,让我们回顾一下问题。此 AppDomain 的主线程在启动三个辅助工作线程时开始。每个工作线程都被指示调用共享的 WorkerClass 对象实例。鉴于我们没有采取任何预防措施来锁定此共享资源,因此一个给定线程很有可能在 WorkerClass 能够打印当前线程的结果之前就被踢出去了。因为您不知道这何时可能发生,所以您一定会得到一些奇怪的结果。例如,请查看图 6-44。

图 6-45 显示了另一次运行。

再说一次,以防万一,如图 6-46 所示。

嗯。显然存在一些问题。鉴于每个线程都在以随机的方式告诉 WorkerClass “做一些工作”,输出被弄乱了(至少可以说)。我们需要一种方法来以编程方式强制同步访问共享类型。与 Win32 API 一样,.NET 基类库提供了许多同步技术。让我们研究一种可能的方法。

C#“lock”关键字

为 DoSomeWork() 方法提供同步访问的第一种方法是利用 C# lock 语句。这个内建关键字允许您锁定一段代码,以便进入的线程必须排队等待当前线程完成其工作。使用 lock 语句非常简单。

internal class WorkerClass
{
     public void DoSomeWork()
     {
          // Only 1 thread at a time can tell the worker to get busy!
          lock(this)
          {
               // Do the work.
               for(int i = 0; i < 5; i++)
               {
                    Console.WriteLine("Worker says: {0},", i);
               }
          }
     }
}

如果您重新运行应用程序,您可以看到线程被指示礼貌地排队等待当前线程完成其工作(图 6-47)。

正如您可能猜到的,使用 C# lock 语句在语义上等同于使用原始 Win32 CRITICAL_SECTION 和相关的 API 函数调用。

使用 System.Threading.Monitor

C# lock 语句实际上只是使用 System.Threading.Monitor 类类型的一种简写符号。因此,如果您能看到 lock() 实际上解析成了什么,您会发现以下内容:

internal class WorkerClass
{
     public void DoSomeWork()
     {
          // Define the item to monitor for synchronization. 
          Monitor.Enter(this);
          try
          {
               // Do the work.
               for(int i = 0; i < 5; i++)
               {
                    Console.WriteLine("Worker says: {0},", i);
               }
          }
          finally
          {
               // Error or not, you must exit the monitor.
               Monitor.Exit(this);
          }
     }
}

如果您运行修改后的应用程序,您将看不到输出的任何变化(这是好事)。在这里,我们使用 Monitor 类型的静态 Enter() 和 Exit() 成员来进入(和退出)一个锁定的代码块。

使用 System.Threading.Interlocked

在相关说明中,System.Threading 命名空间还提供了一个类型,允许您以线程安全的方式将变量加 1 或减 1。为了说明这一点,假设您有一个类类型(名为 IHaveNoIdea),它维护一个内部引用计数器。该类的一个方法负责将此数字加 1,而另一个方法负责将此数字减 1(看起来很熟悉?)。

public class IHaveNoIdea
{
     private long refCount = 0;

     public void AddRef()
     { ++refCount; }

     public void Release()
     {
          if(ÑrefCount = = 0)
          {
               GC.Collect();
          }
     }
}

如果我们当前 AppDomain 中有许多执行线程都在调用 AddRef() 和 Release(),那么在将集合请求发布到垃圾回收器之前,内部 refCount 成员变量的值实际上可能小于零。想象一下 threadA 调用 Release(),并在递减 refCount 之后被线程调度程序挤出去了。下一个调用 Release() 的线程将再次递减计数,此时 refCount 目前为 -1!

为了防止此行为,您可以使用 System.Threading.Interlocked,它会原子地递增或递减给定变量。请注意,传入的是要修改的变量的引用,因此您需要使用 C# 的“ref”关键字。

public class IHaveNoIdea
{
     private long refCount = 0;

     public void AddRef()
     {
          Interlocked.Increment(ref refCount);
     }

     public void Release()
     {
          if(Interlocked.Decrement(ref refCount) = = 0)
          {
               GC.Collect();
          }
     }
}

此时,您已经拥有足够的信息,可以在多线程程序集的世界中变得危险。虽然本章没有深入探讨 System.Threading 命名空间中的每一个方面,但您应该能够根据需要进一步研究其他详细信息。

摘要

本章深入探讨了您开发计算机上那些看似普通的 .NET DLL 和 EXE 文件背后的细节。您通过检查程序集的核心概念:元数据、清单和 MSIL 来开始这段旅程。接下来,您对比了共享程序集和私有程序集,并研究了程序集解析器使用应用程序配置文件来定位给定二进制文件所采取的步骤。

程序集是 .NET 应用程序的构建块。本质上,程序集可以被理解为包含可由另一个应用程序使用的任意数量类型的二进制单元。正如您所见,程序集可以是私有的或共享的。与经典的 COM 形成鲜明对比的是,私有程序集是默认的。当您希望配置共享程序集时,您是在做出明确的选择,并且需要生成相应的强名称。

正如您也学到的,.NET 框架定义了 AppDomain 的概念。在许多方面,AppDomain 可以被视为轻量级进程。单个 AppDomain 中可以存在任意数量的线程。使用 System.Threading 命名空间中定义的类型,您可以构建线程安全的类型,这些类型(如您所见)可以为最终用户提供更具响应性的应用程序。

版权 © 2001 APress
© . All rights reserved.