博客类:
4.类型基础
new操作符
CLR 要求所有对象都用 new
操作符创建。以下代码展示了如何创建一个 Employee
对象:
Employee e = new Employee("ConstructorParam1");
以下是 new
操作符所做的事情。
- 计算类型及其所有基类型(一直到
System.Object
,虽然它没有定义自己的实例字段)中定义的所有实例字段需要的字节数。堆上每个对象都需要一些额外的成员,包括“类型对象指针”(type object pointer)和“同步块索引”(sync block index)。CLR 利用这些成员管理对象。额外成员的字节数要计入对象大小。
称为 overhead 成员,或者说“开销成员”。——译注
- 从托管堆中分配类型要求的字节数,从而分配对象的内存,分配的所有字节都设为零(0).
- 初始化对象的“类型对象指针”和“同步块索引”成员。
- 调用类型的实例构造器,传递在
new
调用中指定的实参(上例就是字符串”ConstructorParam1”)。大多数编译器都在构造器中自动生成代码来调用基类构造器。每个类型的构造器都负责初始化该类型定义的实例字段。最终调用System.Object
的构造器,该构造器什么都不做,简单地返回。
new
执行了所有这些操作之后,返回指向新建对象一个引用(或指针)。在前面的示例代码中,该引用保存到变量 e
中,后者具有 Employee
类型。
顺便说一句,没有和 new
操作符对应的 delete
操作符;换言之,没有办法显示释放为对象分配的内存。 CLR 采用了垃圾回收机制(详情在第 21 章讲述),能自动检测到一个对象不再被使用或访问,并自动释放对象的内存。
值类型or引用类型
设计自己的类型时,要仔细考虑类型是否应该定义成值类型而不是引用类型。值类型有时能提供更好的性能。具体地说,除非满足以下全部条件,否则不应该将类型声明为值类型。
-
类型具有基元类型的行为。也就是说,是十分简单的类型,没有成员会修改类型的任何实例字段。如果类型没有提供会更改其字段的成员,就说该类型是不可变(immutable)类型。事实上,对于许多值类型,我们都建议将全部字段标记为 readonly(详情参见第 7 章 “常量和字段”)。
-
类型不需要从其他任何类型继承。
-
类型也不派生出其他任何类型。
类型实例大小也应在考虑之列,因为实参默认以传值方式传递,造成对值类型实例中的字段进行复制,对性能造成损害。同样地,被定义为返回一个值类型的方法在返回时,实例中的字段会复制到调用者分配的内存中,对性能造成损害。所以,要将类型声明为值类型,除了要满足以上全部条件,还必须满足以下任意条件。
-
类型的实例较小(16 字节或更小)。
-
类型的实例较大(大于 16 字节),但不作为方法实参传递,也不从方法返回。
值类型的主要优势是不作为对象在托管堆上分配。当然,与引用类型相比,值类型也存在自身的一些局限。下面列出了值类型和引用类型的一些区别。
- 值类型对象有两种表示形式: 未装箱 和 已装箱,详情参见下一节。相反,引用类型总是处于已装箱形式。
- 值类型从
System.ValueType
派生。该类型提供了与System.Object
相同的方法。但System.ValueType
重写了Equals
方法,能在两个对象的字段值完全匹配的前提下返回true
。此外,System.ValueType
重写了GetHashCode
方法。生成哈希码时,这个重写方法所用的算法会将对象的实例字段中的值考虑在内。由于这个默认实现存在性能问题,所以定义自己的值类型时应重写Equals
和GetHashCode
方法,并提供它们的显式实现。 本章末尾会讨论Equals
和GetHashCode
方法。 - 由于不能将值类型作为基类型来定义新的值类型或者新的引用类型,所以不应在值类型中引入任何新的虚方法。所有方法都不能是是抽象的,所有方法都隐式密封(不可重写)。
- 引用类型的变量包含堆中对象的地址。引用类型的变量创建时默认初始化
null
,表明当前不指向有效对象。试图使用null
引用类型变量会抛出NullReferenceException
异常。相反,值类型的变量总是包含其基础类型的一个值,而且值类型的所有成员都初始化为 0。值类型变量不是指针,访问值类型不可能抛出NullReferenceException
异常。CLR 确实允许为值类型添加”可空“(nullability)标识。可空类型将在第 19 章”可空值类型“详细讨论。 - 将值类型变量赋给另一个值类型变量,会执行逐字段的复制。将引用类型的变量赋给另一个引用类型的变量只复制内存地址。
- 基于上一条,两个或多个引用类型变量能引用堆中同一个对象,所以对一个变量执行的操作可能影响到另一个变量引用的对象。相反,值类型变量自成一体,对值类型变量执行的操作不可能影响另一个值类型变量。
- 由于未装箱的值类型不在堆上分配,一旦定义了该类型的一个实例的方法不再活动,为它们分配的存储就会被释放,而不是等着进行垃圾回收。
值类型->引用类型
将值类型转换成引用类型要使用装箱机制。下面总结了对值类型的实例进行装箱时所发生的事情。
- 在托管堆中分配内存。分配的内存量是值类型各字段所需的内存量,还要加上托管堆所有对象都有的两个额外成员(类型对象指针和同步块索引)所需的内存量。
- 值类型的字段复制到新分配的堆内存。
- 返回对象地址。现在该地址是对象引用;值类型成了引用类型。
知道装箱如何进行后,接着谈谈拆箱。假定要用以下代码获取 ArrayList
的第一个元素:
Point p = (Point) a[0];
它获取 ArrayList
的元素0包含的引用(或指针),试图将其放到 Point
值类型的实例 p
中。为此,已装箱 Point
对象中的所有字段都必须复制到值类型变量 p
中,后者在线程栈上。 CLR 分两步完成复制。第一步获取已装箱 Point
对象中的各个 Point
字段的地址。这个过程称为拆箱(unboxing)。第二步将字段包含的值从堆复制到基于栈的值类型实例中。
虽然未装箱值类型没有类型对象指针,但仍可调用由类型继承或重写的虚方法(比如 Equals
,GetHashCode
或者 ToString
)。如果值类型重写了其中任何虚方法,那么 CLR 可以非虚地调用该方法,因为值类型隐式密封,不可能有类型从它们派生,而且调用虚方法的值类型实例没有装箱。然而,如果重写的虚方法要调用方法在基类中的实现,那么在调用基类的实现时,值类型实例会装箱,以便能够通过 this
指针将对一个堆对象的引用传给基方法。
8.方法
类型转换
CLR 允许在一个类型中定义仅返回类型不同的多个方法。但只有极少数语言支持这个能力。你可能已经注意到了,C++,C#,Visual Basic 和Java语言都不允许在一个类型中定义仅返回类型不同的多个方法。个别语言(比如IL汇编语言)允许开发人员显式选择调用其中哪一个方法。当然,IL 汇编语言的程序员不应利用这个能力,否则定义的方法无法从其他语言中调用。虽然 C# 语言没有向 C#程序员公开这个能力,但当一个类型定义了转换操作符方法时,C#编译器会在内部利用这个能力。
C# 编译器提供了对转换操作符的完全支持。如果检测到代码中正在使用某个类型的对象,但实际期望的是另一种类型的对象,编译器就会查找能执行这种转换的隐式转换操作符方法,并生成代码来调用该方法。如果存在隐式转换操作符方法,编译器会在结果IL 代码中生成对它的调用。如果编译器看到源代码是将对象从一种类型显式转换为另一种类型,就会查找能执行这种转换的隐式或显式转换操作符方法。如果找到一个,编译器就生成 IL 代码来调用它。如果没有找到合适的转换操作符方法,就报错并停止编译。
注意 使用强制类型转换表达式时,C# 生成代码来调用显式转换操作符方法。使用 C# 的 as
和 is
操作符时,则永远不会调用这些方法。(参见4.2节。)
10.属性
调用属性访问器方法时的性能
对于简单的 get
和 set
访问器方法, JIT 编译器会将代码内联(inline,或者说嵌入)。这样一来,使用属性(而不是使用字段)就没有性能上的损失。内联是指将方法(目前说的是访问器方法)的代码直接编译到调用它的方法中。这就避免了在运行时发出调用所产生的开销,代价是编译好的方法变得更大。由于属性访问器方法包含的代码一般很少,所以对内联会使生成的本机代码变得更小,而且执行得更快。
注意 JIT 编译器在调试代码时不会内联属性方法,因为内联的代码会变得难以调试。这意味着在程序的发型版本中,访问属性时的性能可能比较快;在程序的调试版本中,可能比较慢。字段访问在调式和发布版本中,速度都很快。
装箱和泛型
没有泛型的时候,要想定义常规化的算法,它的所有成员都要定义成操作Object
数据类型。要用这个算法来操作值类型的实例,CLR必须在调用算法的成员之前对值类型实例进行装箱。正如第 5 章“基元类型、引用类型和值类型”讨论的那样,装箱造成在托管堆上进行内存分配,造成更频繁的垃圾回收,从而损害应用程序的性能。由于现在能创建一个泛型算法来操作一种具体的类型,所以值类型的实例能以传值方式传递,CLR不再需要执行任何装箱操作。此外,由于不再需要进行强制类型转换(参见上一条),所以CLR无需验证这种转型是否类型安全,这同样提高了代码的运行速度。
泛型类型和继承
泛型类型仍然是类型,所以能从其他任何类型派生。使用泛型类型并指定类型实参时,实际是在CLR中定义一个新的类型对象,新的类型对象从泛型类型派生自的那个类型派生。换言之,由于List<T>
从Object
派生,所以List<String>
和List<Guid>
也从Object
派生。类似地,由于DictionaryStringKey<TValue>
从Dictionary<String, TValue>
派生,所以DictionaryStringKey<Guid>
也从Dictionary<String, Guid>
派生。指定类型实参不影响继承层次结构——理解这一点,有助于你判断哪些强制类型转换是允许的,哪些不允许。
泛型如何实现和代码爆炸
使用泛型类型参数的方法在进行 JIT 编译时,CLR 获取方法的 IL,用指定的类型实参替换,然后创建恰当的本机代码(这些代码为操作指定数据类型“量身定制”)。这正是你希望的,也是泛型的重要特点。但这样做有一个缺点:CLR 要为每种不同的方法/类型组合生成本机代码。我们将这个现象称为代码爆炸。它可能造成应用程序的工作集显著增大,从而损害性能。
幸好,CLR 内建了一些优化措施能缓解代码爆炸。首先,假如为特定的类型实参调用了一个方法,以后再用相同的类型实参调用这个方法,CLR 只会为这个方法/类型组合编译一次代码。所以,如果一个程序集使用List<DateTime>
,一个完全不同的程序集(加载到同一个 AppDomain 中)也使用List<DateTime>
编译一次方法。这样就显著缓解了代码爆炸。
CLR 还有另一个优化,它认为所有引用类型实参都完全相同,所以代码能够共享。例如,CLR 为 List<String>
的方法编译的代码可直接用于List<Stream>
的方法,因为String
和Stream
均为引用类型。事实上,对于任何引用类型,都会使用相同的代码。CLR 之所以能执行这个优化,是因为所有引用类型的实参或变量实际只是指向堆上对象的指针(32 位 Windows 系统上是 32 位指针;64 位 Windows 系统上是 64 为指针),而所有对象指针都以相同方式操纵。
泛型委托
CLR 支持泛型委托,目的是保证任何类型的对象都能以类型安全的方式传给回调方法。此外,泛型委托允许值类型实例在传给回调方法时不进行任何装箱。第 17 章“委托”会讲到,委托实际只是提供了4个方法的一个类定义。4个方法包括一个构造器、一个Invoke
方法,一个BeginInvoke
方法和一个EndInvoke
方法。如果定义的委托类型指定了类型参数,编译器会定义委托类的方法,用指定的类型参数替换方法的参数类型和返回值类型。
13.接口
接口方法真正的实现
类型加载到 CLR 中时,会为该类型创建并初始化一个方法表(参见第 1 章“CLR的执行模型”)。在这个方法表中,类型引入的每个新方法都有对应的记录项;另外,还为该类型继承的所有虚方法添加了记录项。继承的虚方法既有继承层次结构中的各个基类型定义的,也有接口类型定义的。所以,对于下面这个简单的类型定义:
internal sealed class SimpleType : IDisposable {
public void Dispose() { Console.WriteLine("Dispose"); }
}
类型的方法表将包含以下方法的记录项。
Object
(隐式继承的基类)定义的所有虚实例方法。IDisposable
(继承的接口)定义所有接口方法。本例只有一个方法,即Dispose
,因为IDisposable
接口只定义了这个方法。SimpleType
引入的新方法Dispose
。
为简化编程,C#编译器假定 SimpleType
引入的Dispose
方法是对IDisposable
的Dispose
方法的可访问性是public
,而接口方法的签名和新引入的方法完全一致。也就是说,两个方法具有相同的参数和返回类型。顺便说一句,如果新的Dispose
方法被标记为virtual
,C#编译器仍然认为该方法匹配接口方法。
C#编译器将新方法和接口方法匹配起来之后,会生成元数据,指明 SimpleType
类型的方法表中的两个记录项应引用同一个实现。
14.字符串和文本
连接字符串
// 三个字面值(literal)字符串连接成一个字面值字符串
String s = "Hi" + " " + "there.";
在上述代码中,由于所有字符串都是字面值,所以 C# 编译器能在编译时连接它们,最终只将一个字符串(即"Hi there."
)放到模块的元数据中。对非字面值字符串使用+
操作符,连接则在运行时进行。运行时连接不要使用+
操作符,因为这样会在堆上创建多个字符串对象,而堆是需要垃圾回收的,对性能有影响。相反,应该使用System.Text.StringBuilder
类型(本章稍后详细解释)。
动态生成和字符串池
• 动态生成的字符串不会自动被放入字符串池中,主要是为了避免性能开销和内存管理问题。 • 编译时的字符串字面量会自动被放入字符串池中,确保相同的字符串字面量在内存中只存在一份。 • 通过显式调用 string.Intern 方法,可以将动态生成的字符串放入字符串池中。
15.枚举类型和位标志
枚举到底是什么
internal enum Color {
White, // 赋值 0
Red, // 赋值 1
Green, // 赋值 2
Blue, // 赋值 3
Orange // 赋值 4
}
//编译枚举类型时,C# 编译器把每个符号转换成类型的一个常量字段。例如,编译器将前面的 `Color` 枚举类型看成是以下代码
internal struct Color : System.Enum {
// 以下是一些公共常量,它们定义了 Color 的符号和值
public const Color White = (Color) 0;
public const Color Red = (Color) 1;
public const Color Green = (Color) 2;
public const Color Blue = (Color) 3;
public const Color Orange = (Color) 4;
// 以下是一个公共实例字段,包含 Color 变量的值,
// 不能写代码来直接引用该字段
public Int32 value__;
}
C# 编译器不会实际地编译上述代码,因为它禁止定义从特殊类型 System.Enum
派生的类型。不过,可通过上述伪类型定义了解内部的工作方式。简单地说,枚举类型只是一个结构,其中定义了一组常量字段和一个实例字段。常量字段会嵌入程序集的的元数据中,并可通过反射来访问。这意味着可以在运行时获得与枚举类型关联的所有符号及其值。还意味着可以将字符串符号转换成对应的数值。这些操作是通过System.Enum
基类型来提供的,该类型提供了几个静态和实例方法,可利用它们操作枚举类型的实例,从而避免了必须使用反射的麻烦。
Enum.IsDefined
重要提示 Enum.IsDefined
方法很方便,但必须慎用。首先,IsDefined
总是执行区分大小写的查找,而且完全没有办法让它执行不区分大小写的查找。其次,IsDefined
相当慢,因为它在内部使用了反射。如果写代码来手动检查每一个可能的值,应用程序的性能极有可能变得更好。最后,只有当枚举类型本身在调用IsDefined
的同一个程序集中定义,SetColor
方法在另一个程序集中定义。SetColor
方法调用 IsDefined
,假如颜色是 White
,Red
,Green
,Blue
或者 Orange
,那么 SetColor
能正常执行。然而,假如 Color
枚举将来发生了变化,在其中包含了 Purple
,那么 SetColor
现在就会接受 Purple
,这是以前没有预料到的。因此,方法现在可能返回无法预料的结果。
17. 委托
CLR实际做的事
首先重新审视这一行代码:
internal delegate void Feedback(Int32 value);
看到这行代码后,编译器实际会像下面这样定义一个完整的类:
internal class Feedback : System.MulticastDelegate {
// 构造器
public Feedback(Object @object, IntPtr method);
// 这个方法的原型和源代码指定的一样
public virtual void Invoke(Int32 value);
// 以下方法实现对回调方法的异步问题
public virtual IAsyncResult BeginInvoke(Int32 value, AsyncCallback callback, Object @object);
public virtual void EndInvoke(IAsyncResult result);
}
编译器定义的类有 4 个方法:一个构造器、Invoke
、BeginInvoke
和EndInvoke
。本章重点解释构造器和Invoke
。BeginInvoke
和EndInvoke
方法将留到第 27 章讨论。
事实上,可用 ILDasm.exe 查看生成的程序集,验证编译器真的会自动生成这个类
委托内部
由于所有委托类型都派生自MulticastDelegate
,所以它们继承了MulticastDelegate
的字段、属性和方法。在所有这些成员中,有三个非公共字段是最重要的。
字段 | 类型 | 说明 |
---|---|---|
_target |
System.Object |
当委托对象包装一个静态方法时,这个字段为null 。当委托对象包装一个实例方法时,这个字段引用的是回调方法要操作的对象。换言之,这个字段指出要传给实例方法的隐式参数 this 的值 |
_methodPtr |
System.IntPtr |
一个内部的整数值,CLR用它标识要回调的方法 |
_invocationList |
System.Object |
该字段通常为 null 。构造委托链时它引用一个委托数组(详情参见下一节) |
然而,C# 编译器知道要构造的是委托,所以会分析源代码来确定引用的是哪个对象和方法。对象引用被传给构造器的 object
参数,标识了方法的一个特殊 IntPtr
值(从 MethodDef
或 MemberRef
元数据 token 获得)被传给构造器的 method
参数。对于静态方法,会为 object
参数传递 null
值。在构造器内部,这两个实参分别保存在 _target
和 _methodPtr
私有字段中。除此以外,构造器还将 _invocationList
字段设为null
,对这个字段的讨论将推迟到 17.5 节 “用委托回调多个方法(委托链)”进行。
所以,每个委托对象实际都是一个包装器,其中包装了一个方法和调用该方法时要操作的对象。例如,在执行以下两行代码之后:
Feedback fbStatic = new Feedback(Program.FeedbackToConsole);
Feedback fbInstance = new Feedback(new Program().FeedbackToFile);
fbStatic
和 fbInstance
变量将引用两个独立的、初始化好的 Feedback
委托对象,
在引用的委托上调用Invoke
时,该委托发现私有字段_invocationList
不为null
,所以会执行一个循环来遍历数组中的所有元素,并依次调用每个委托包装的方法。在本例中,FeedbackToConsole
首先被调用,随后是FeedbackToMsgBox
,最后是FeedbackToFile
。
Remove和InvocationList
fbChain = (Feedback) Delegate.Remove(fbChain, new Feedback(FeedbackToMsgBox));
Remove
方法被调用时,它扫描第一个实参(本例是fbChain
)所引用的那个委托对象内部维护的委托数组(从末尾向索引 0 扫描)。Remove
查找的是其_target
和 _methodPtr
字段与第二个实参(本例是新建的Feedback
委托)中的字段匹配的委托。如果找到匹配的委托,并且(在删除之后)数组中只剩余一个数据项,就返回那个数据项。如果找到匹配的委托,并且数组中还剩余多个数据项,就新建一个委托对象————其中创建并初始化的 _invocationList
数组中还剩余多个数据项,当然被删除的数据项除外————并返回对这个新建委托对象的引用。如果从链中删除了仅有的一个元素,Remove
会返回null
。注意,每次 Remove
方法调用只能从链中删除一个委托,它不会删除有匹配的_target
和 _methodPtr
字段的所有委托。
GetInvocationList
public abstract class MulticastDelegate : Delegate {
// 创建一个委托数组,其中每个元素都引用链中的一个委托
public sealed override Delegate[] GetInvocationList();
}
GetInvocationList
方法操作从 MulticastDelegate
派生的对象,返回包含 Delegate
引用的一个数组,其中每个引用都指向链中的一个委托对象。在内部,GetInvocationList
构造并初始化一个数组,让它的每个元素都引用链中的一个委托,然后返回对该数组的引用。如果_invaocationList
字段为null
,返回的数组就只有一个元素,该元素引用链中唯一的委托,即委托实例本身。
19.可空值类型
操作可空实例
注意,操作可空实例会生成大量代码。例如以下方法:
private static Int32? NullableCodeSize(Int32? a, Int32? b) {
return (a + b);
}
编译这个方法会生成相当多的 IL 代码,而且操作可空类型的速度慢于非可空类型。编译器生成的 IL 代码等价于以下 C# 代码:
private static Nullable<Int32> NullableCodeSize(Nullable<Int32> a, Nullable<Int32> b) {
Nullable<Int32> nullable1 = a;
Nullable<Int32> nullable2 = a;
if (!(nullable1.HasValue & nullable2.HasValue)) {
return new Nullable<Int32>();
}
return new Nullable><Int32> (nullable1.GetvalueOrDefault() + nullable2.GetValueOrDefault());
}
可空值类型的装箱、拆箱
当 CLR 对 Nullable<T>
实例进行装箱时,会检查它是否为 null
。如果是,CLR 不装箱任何东西,直接返回 null
。如果可空实例不为 null
,CLR 从可空实例中取出值并进行装箱。也就是说,一个值为 5 的 Nullable<Int32>
会装箱成值为 5 的已装箱 Int32
。
CLR 允许将已装箱的值类型 T
拆箱为一个 T
或者 Nullable<T>
。如果对已装箱类型的引用是 null
,而且要把它拆箱为一个 Nullable<T>
,那么 CLR 会将 Nullable<T>
的值设为 null
。
20.异常和状态管理
finaly代码会自动生成
只要使用了 lock
,using
和foreach
语句,C# 编译器就会自动生成 try/finally
块,另外,重写类的析构器(Finalize
方法)时,C#编译器也会自动生成try/finally
块。使用这些构造时,编译器将你写的代码放到 try
块内部,并将清理代码放到 finally
块中。具体如下所示。
- 使用
lock
语句时,锁在finally
块中释放。 - 使用
using
语句时,在finally
块中调用对象的Dispose
方法。 - 使用
foreach
语句时,在finally
块中调用IEnumerator
对象的Dispose
方法。 - 定义析构器方法时,在
finally
块中调用基类的Finalize
方法。
21.托管堆和垃圾回收
CLR如何从托管堆分配对象
C# 的 new
操作符导致 CLR 执行以下步骤。
-
计算类型的字段(以及从基类型继承的字段)所需的字节数。
-
加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。对于 32 位应用程序,这两个字段各自需要 32 位,所以每个对象要增加 8 字节。对于 64 位应用程序,这两个字段各自需要 64 位,所以每个对象要增加 16 字节。
-
CLR 检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在
NextObjPtr
指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为this
参数传递NextObjPtr
),new
操作符返回对象引用。就在返回这个引用之前,NextObjPtr
指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。
C#的GC算法
鉴于引用计数垃圾回收器算法存在的问题,CLR 改为使用一种引用跟踪算法。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们将所有引用类型的变量都称为根。
CLR 开始 GC 时,首先暂停进程中的所有线程。这样可以防止线程在 CLR 检查期间访问对象并更改其状态。然后,CLR 进入 GC 的 标记阶段。在这个阶段,CLR 遍历堆中的所有对象,将同步块索引字段中的一位设为 0。这表明所有对象都应删除。然后,CLR 检查所有活动根。查看它们引用了哪些对象(堆中的被分配了内存空间的对象)。这正是 CLR 的 GC 称为引用跟踪 GC 的原因。如果一个根包含 null, CLR 忽略这个根并继续检查下个根。
任何根如果引用了堆上的对象,CLR 都会标记那个对象,也就是将该对象的同步块索引中对的位设为 1。一个对象被标记后, CLR 会检查那个对象中的根,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这就避免了因为循环引用而产生死循环。
- 根对象(Root Objects) GC从一组称为“根”的对象开始,这些对象通常包括: • 静态变量 • 局部变量 • CPU寄存器中的变量
- 标记阶段(Mark Phase) 在标记阶段,GC会遍历所有根对象,并递归地标记所有可达的对象。可达对象是指从根对象开始,通过引用链可以访问到的对象。这个过程可以分为以下几步: • 从根对象开始,标记它们为“已访问”。 • 对每个已访问的对象,标记它们引用的所有对象。 • 重复上述步骤,直到所有可达对象都被标记。
- 清除阶段(Sweep Phase) 在清除阶段,GC会遍历堆中的所有对象,释放那些未被标记为“已访问”的对象所占用的内存。这个过程可以分为以下几步: • 遍历堆中的所有对象。 • 如果对象未被标记为“已访问”,则释放其内存。 • 如果对象被标记为“已访问”,则清除其标记,以便下次GC循环使用。
- 压缩阶段(Compaction Phase) 在某些情况下,GC还会进行压缩操作,以减少内存碎片。压缩阶段会将存活的对象移动到堆的一个连续区域,从而使得新的对象可以在更大的连续内存块中分配。
原理: 从根对象(如全局变量、栈上的变量)开始,遍历所有可达的对象。 标记所有可达的对象。 未被标记的对象即为不可达对象,可以被回收。
分代GC
新分配的对象,处于0代,分配时检查可分配的0代最大对象内存,超出上限后进行一次GC,将幸存对象分配至1代。
重复上述步骤,此时也要检查1代可分配的最大对象内存空间,超出上限后进行一次GC,将幸存对象分配至2代。
CLR会根据内存使用情况和分配频率动态调整每代的可分配对象最大内存。
注意每次分代完成后,都会整理每代的内存碎片以提高内存利用效率。
托管堆只支持三代:第 0 代、第 1 代和第 2 代。没有第 3 代①。CLR 初始化时,会为每一代选择预算。然而,CLR 的垃圾回收器是自调节的。这意味着垃圾回收器会在执行垃圾回收的过程中了解应用程序的行为。例如,假定应用程序构造了许多对象,但每个对象用的时间都很短。在这种情况下,对第 0 代的垃圾回收会回收大量内存。事实上,第 0 代的所有对象都可能被回收。
程序集加载和反射
Assembly.Load
在内部,Load
导致 CLR 向程序集应用一个版本绑定重定向策略,并在 GAC(全局程序集缓存)中查找程序集。如果没找到,就接着去应用程序的基目录、私有路径子目录和 codebase
②位置查找。如果调用 Load
时传递的是弱命名程序集,Load
就不会向程序集应用版本绑定重定向策略,CLR 也不会去 GAC 查找程序集。如果 Load
找到指定的程序集,会返回对代表已加载的那个程序集的一个 Assembly
对象的引用。如果 Load
没有找到指定程序集,会抛出一个 System.IO.FileNotFoundException
异常。
24.运行时序列化
BinaryFormatter如何进行序列化
类型的全名和类型定义程序集的全名会被写入流。BinaryFormatter
默认输出程序集的完整标识,其中包括程序集的文件名(无扩展名)、版本号、语言文化以及公钥信息。反序列化对象时,格式化器首先获取程序集标识信息。并通过调用 System.Refleciton.Assembly
的 Load
方法(参见 23.1 节“程序集加载”),确保程序集已加载到正在执行的 AppDomain 中。
Assembly.LoadFrom和Assembly.Load
有的可扩展应用程序使用 Assembly.LoadFrom
加载程序集,然后根据加载的程序集中定义的类型来构造对象。这些对象序列化到流中是没有问题的。但在反序列化时,格式化器会调用 Assembly
的 Load
方法(而非 LoadFrom
方法)来加载程序集。大多数情况下,CLR 都将无法定位程序集文件,从而造成 SerializationException
异常。
这是因为Assembly.Load和Assembly.LoadFrom使用不同的加载上下文,反序列化时CLR可能无法找到通过Assembly.LoadFrom加载的程序集。这是因为Assembly.Load不会在Assembly.LoadFrom的上下文中查找程序集,导致SerializationException异常。
如果应用程序使用 Assembly.LoadFrom
加载程序集,再对程序集中定义的类型进行序列化,那么在调用格式化器的 Deserialize
方法之前,我建议你实现一个方法,它的签名要匹配 System.ResolveEventHandler
委托,并向 System.AppDomain
的 AssemblyResolve
事件注册这个方法。(Deserialize
方法返回后,马上向事件注销这个方法。)现在,每次格式化器加载一个程序集失败,CLR 都会自动调用你的 ResolveEventHandler
方法。加载失败的程序集的标识(Identity)会传给这个方法。方法可以从程序集的标识中提取程序集文件名,并用这个名称来构造路径,使应用程序知道去哪里寻找文件。然后,方法可调用 Assembly.LoadFrom
加载程序集,最后返回对结果程序集的引用。(如果你用了LoadFrom加载程序集,那么反序列化时可能失败,因为格式化器会调用Load来确保程序集加载,但是这两个方法加载出的程序集被放到了不同的上下文中)
更好的序列化方式
序列化对象图时,也许有的对象的类型能序列化,有的不能。考虑到性能,在序列化之前,格式化器不会验证对象图中的所有对象都能序列化。所以,序列化对象图时,在抛出 SerializationException
异常之前,完全有可能已经有一部分对象序列化到流中。如果发生这种情况,流中就会包含已损坏的数据。序列化对象图时,如果你认为也许有一些对象不可序列化,那么写的代码就应该能得体地从这种情况中恢复。一个方案是先将对象序列化到一个 MemoryStream
中。然后,如果所有对象都成功序列化,就可以将 MemoryStream
中的字节复制到你真正希望的目标流中(比如文件和网络)。
26.线程基础
何为进程
进程实际是应用程序的实例要使用的资源的集合。每个进程都被赋予了一个虚拟地址空间,确保在一个进程中使用的代码和数据无法由另一个进程访问。此外,进程访问不了 OS 的内核代码和数据;所以,应用程序代码破坏不了操作系统代码或数据。由于应用程序代码破坏不了其他应用程序或者 OS 自身,所以用户的计算体验变得更好了。除此之外,系统变得比以往更安全,因为应用程序代码无法访问另一个应用程序或者 OS 自身使用的用户名、密码、信用卡资料或其他敏感信息。
何为线程
作为一个 Windows 概念, 线程 的职责是对 CPU 进行虚拟化。 Windows 为每个进程都提供了该进程专用的线程(功能相当于一个 CPU)。应用程序的代码进入死循环,与那个代码关联的进程会“冻结”,但其他进程(它们有自己的线程)不会冻结,它们会继续执行。但和一切虚拟化机制一样,线程有空间(内存耗用)和时间(运行时的执行性能)上的开销。
创建线程
线程有空间(内存耗用)和时间(运行时的执行性能)上的开销。
每个线程都包含以下要素
-
线程内核对象(thread kernel object)
OS 为系统中创建的每个线程都分配并初始化这种数据结构之一。数据结构包含一组对线程进行描述的属性。 -
线程环境块(thread environment block,TEB)
这是在用户模式(应用程序代码能快速访问的地址空间)中分配和初始化的内存块。 -
用户模式栈(user-mode stack)
用户模式栈存储传给方法的局部变量和实参。它还包含一个地址;指出当前方法返回时,线程应该从什么地方接着执行。 -
内核模式栈(kernel-mode stack)
应用程序代码向操作系统中的内核模式函数传递实参时,还会使用内核模式栈。出于对安全的考虑,针对从用户模式的代码传给内核的任何实参,Windows 都会把它们从线程的用户模式栈复制到线程的内核模式栈。一经复制,内核就可验证实参的值。由于应用程序代码不能访问内核模式栈,所以应用程序无法更改验证后的实参值。OS 内核代码开始处理复制的值。 -
DLL 线程连接(attach)和线程分离(detach)通知
Windows 的一个策略是,任何时候在进程中创建线程,都会调用进程中加载的所有非托管 DLL 的DllMain
方法,并向该方法传递DLL_THREAD_ATTACH
标志。类似地,任何时候线程终止,都会调用进程中的所有非托管 DLL 的DLLMain
方法,并向方法传递DLL_THREAD_DETACH
标志。
当CPU切换线程
Windows 任何时刻只将一个线程分配给一个 CPU。那个线程能运行一个“时间片”(有时也称为“量”或者“量程”,即 quantum)的长度。时间片到期,Windows 就上下文切换到另一个线程。每次上下文切换都要求 Windows 执行以下操作。
- 将
CPU
寄存器的值保存到当前正在运行的线程的内核对象内部的一个上下文结构中。 - 从现有线程集合中选出一个线程供调度。如果该线程由另一个进程拥有,Windows 在开始执行任何代码或者接触任何数据之前,还必须切换 CPU “看见” 的虚拟地址空间。
- 将所选上下文结构中的值加载到
CPU
的寄存器中。
上下文切换完成后,CPU 执行所选的线程,直到它的时间片到期。然后发生下次上下文切换。Windows 大约每 30 毫秒执行一次上下文切换。上下文切换是净开销;也就是说,上下文切换所产生的开销不会换来任何内存或性能上的收益。Windows 执行上下文切换,向用户提供一个健壮的、响应灵敏的操作系统。
抢占式 / CPU调度
抢占式(preemptive)操作系统必须使用算法判断在什么时候调度哪些线程多长时间(多个线程抢占CPU资源,让CPU来处理自己的任务)。本节讨论 Windows 采用的算法。本章早些时候说过,每个线程的内核对象都包含一个上下文结构。上下文(context)结构反映了线程上一次执行完毕后 CPU 寄存器的状态。在一个时间片(time-slice)之后, Windows 检查现在的所有线程内核对象。在这些对象中,只有那些没有正在等待什么的线程才适合调度。
每个线程都分配了从 0(最低)到 31(最高)的优先级。系统决定为 CPU 分配哪个线程时,首先检查优先级 31 的线程,并以一种轮流(round-robin)方式调度它们。如果优先级 31 的一个线程可以调度,就把它分配给 CPU。在这个线程的时间片结束时,系统检查是否有另一个优先级 31 的线程可以运行;如果是,就允许将那个线程分配给 CPU。
只要存在可调度的优先级 31 的线程,系统就永远不会将优先级 0~30 的任何线程分配给 CPU。这种情况称为饥饿(starvation)。较高优先级的线程占用了太多 CPU 时间,造成较低优先级的线程无法运行,就会发生这种情况。多处理器机器发生饥饿的可能性要小得多,因为这种机器上优先级为 31 的线程和优先级为 30 的线程可以同时运行。系统总是保持各个 CPU 出于忙碌状态,只有没有线程可调度的时候,CPU 才会空闲下来。
较高优先级的线程总是抢占低优先级的线程,无论正在运行的是什么较低优先级的线程。例如,如果有一个优先级为 5 的线程在运行,而系统确定有一个较高优先级的线程准备好运行,系统会立即挂起(暂停)较低优先级的线程(即使后者的时间片还没有用完),将 CPU 分配给较高优先级的线程,该线程将获得一个完整的时间片。
前台线程和后台线程
CLR 将每个线程要么视为前台线程,要么视为后台线程。一个进程的所有前台线程停止运行时,CLR 强制终止仍在运行的任何后台线程。这些后台线程被直接终止:不抛出异常。
// 创建新线程(默认为前台线程)
Thread t = new Thread(Worker);
// 使线程称为后台线程
t.IsBackground = true;
27. 计算限制的异步操作
计算限制
“计算限制”(Compute-bound)是指程序或操作的性能主要受限于处理器的计算能力,而不是其他因素如I/O操作(例如磁盘读写或网络通信)。换句话说,计算限制的操作需要大量的CPU资源来完成复杂的计算任务,而不是等待外部资源。
取消
CancellationTokenSource用于控制取消操作。它提供了生成和管理取消令牌的方法。在创建时他会生成一个token来关联自己。
CancellationToken是一个结构体,用于传播取消通知。它由CancellationTokenSource生成,并传递给需要支持取消的操作。
在异步操作中可以将CancellationToken传入work item,并且检查IsCancellationRequested属性检查是否已请求取消。
等待Task完成
// 创建一个 Task(现在还没有开始运行)
Task<Int32> t = new Task<Int32>(n => Sum((Int32)n), 1000000000);
// 可以后再启动任务
t.Start();
// 可选择显式等待任务完成
t.Wait(); // 注意:还有一些重载的版本能接受 timeout/CancellationToken 值
// 可获得结果(Result 属性内部会调用 Wait)
Console.WriteLine("The Sum is: " + t.Result); // 一个 Int32 值
线程调用 Wait
方法时,系统检查线程要等待的 Task
是否已开始执行。如果是,调用 Wait
的线程来执行 Task
。在这种情况下,调用Wait
的线程不会阻塞;它会执行 Task
并立即返回。好处在于,没有线程会被阻塞,所以减少了对资源的占用(因为不需要创建一个线程来替代被阻塞的线程),并提升了性能(因为不需要花时间创建线程,也没有上下文切换)。不好的地方在于,假如线程在调用 Wait
前已获得了一个线程同步锁,而 Task
试图获取同一个锁,就会造成死锁的线程。
调度器
TaskScheduler
对象负责执行被调度的任务。
FCL 提供了两个派生自 TaskScheduler
的类型:线程池任务调度器(thread pool task scheduler),和同步上下文任务调度器(synchronization context task scheduler)。默认情况下,所有应用程序使用的都是线程池任务调度器。这个任务调度器将任务调度给线程池的工作者线程。
同步上下文任务调度器适合提供了图形用户界面的应用程序,例如 Windows 窗体。它将所有任务都调度给应用程序的 GUI 线程(让GUI线程来处理所有任务),使所有任务代码都能成功更新 UI 组件(按钮、菜单项等)。该调度器不使用线程池。
28. IO限制的异步操作
当调用FileStream.Read(),实际上发生了什么?
程序通过构造一个 FileStream
对象来打开磁盘文件,然后调用 Read
方法从文件中读取数据。调用 FileStream
的 Read
方法时,你的线程从托管代码转变为本机/用户模式代码,Read
内部调用 Win32 ReadFile
函数(①)。ReadFile
分配一个小的数据结构,称为 I/O 请求包(I/O Request Packet, IRP)(②)。IRP 结构初始化后包含的内容有:文件句柄,文件中的偏移量(从这个位置开始读取字节),一个 Byte[]
数组的地址(数组用读取的字节来填充),要传输的字节数以及其他常规性内容。
然后,ReadFile
将你的线程从本机/用户模式代码,向内核传递 IRP 数据结构,从而调用 Windows 内核(③)。根据 IRP 中的设备句柄, Windows 内核知道 I/O 操作要传送给哪个硬件设备。因此,Windows 将 IRP 传送给恰当的设备驱动程序的 IRP 队列(④)。每个设备驱动程序都维护着自己的 IRP 队列,其中包含了机器上运行的所有进程发出的 I/O 请求。IRP 数据包到达时,设备驱动程序将 IRP 信息传给物理硬件设备上安装的电路板。现在,硬件设备将执行请求的 I/O 操作(⑤)。
C#的异步函数
一旦将方法标记为 async
,编译器就会将方法的代码转换成实现了状态机中的一些代码并返回,方法不需要一直执行到结束。
private static async Task<String> IssueClientRequestAsync(String serverName, String message) {
using(var pipe = new NamedPipeClientStream(serverName, 'PipeName', PipeDirection.InOut, PipeOptions.Asynchronous | PipeOptions.WriteThrough)) {
pipe.Connect();
pipe.ReadMode = PipeTransmissionMode.Message;
Byte[] request = Encoding.UTF8.GetBytes(message);
await pipe.WriteAsync(request, 0, request.Length);
Byte[] response = new Byte[1000];
Int32 butesRead = await pipe.ReadAsync(response, 0, response.Length);
return Encoding.UTF8.GetString(response, 0, butesRead);
}
}
在上述代码中,很容易分辨 IssueClientRequestAsync
是异步函数,因为第一行代码的 static
后添加了 async
关键字。一旦将方法标记为 async
,编译器就会将方法的代码转换成实现了状态机中的一些代码并返回,方法不需要一直执行到结束。所以当线程调用 IssueClientRequestAsync
时,线程会构造一个 NamedPipeClientStream
,调用 Connect
, 设置它的 ReadMode
属性,将传入的消息转换成一个 Byte[]
,然后调用 WriteAsync
。WriteAsync
内部分配一个 Task
对象并把它返回给 IssueClientRequestAsync
。此时,C# await
操作符实际会在 Task
对象上调用 ContinueWith
,向它传递用于恢复状态机的方法。然后线程从 IssueClientRequestAsync
返回。
将来某个时候,网络设置驱动程序会结束向管道的写入,一个线程池线程会通知 Task
对象,后者激活 ContinueWith
回调方法,造成一个线程恢复状态机。更具体地说,一个线程会重新进入 IssueClientRequestAsync
方法,但这次是从 await
操作符的位置开始。方法现在执行编译器生成的、用于查询 Task
对象状态的代码。如果操作失败,会设置代表错误的一个异常。如果操作成功完成,await
操作符会返回结果。在本例中,WriteAsync
返回一个 Task
而不是 Task<TResult>
,所以无返回值。
将来某个时候,服务器向客户机发送一个响应,网络设备驱动程序获得这个响应,一个线程池线程通知 Task<Int32>
对象,后者恢复状态机。await
操作符造成编译器生成代码来查询 Task
对象的 Result
属性(一个 Int32
)并将结果赋给局部变量 byteRead
;如果操作失败,则抛出异常,然后执行 IssueClientRequestAsync
剩余的代码,返回结果字符串并关闭管道。此时,状态机执行完毕,垃圾回收器会回收任何内存。
在await出调用ConitnueWith(),将之后的代码视为ContinueWith回调的一部分,任务完成后线程池通知Task,Task的状态机设置为Complete,线程从await的地方继续调用,查询Task对象的状态(这部分代码由编译器生成),操作成功将返回操作结果。
编译器如何将异步函数转换成状态机
对于一个异步方法,编译器会为其生成一个状态机结构StateMachine并实现IAsyncStateMachine接口。这个接口会为异步方法生成状态机和相关实现代码,仅供编译器使用。
其中比较重要的成员有:
- AsyncTaskMethodBuilder
m_builder:表示返回任务的异步方法生成器,代表了状态机及其位置。 - Int32 m_state:当前的任务状态。
- 异步方法传入的实参。
- TaskAwaiter
m_awaiterType:表示异步方法内,要执行的await部分的任务对象(等待异步任务完成的对象,表示await关键字对象,描述了异步任务的完成状态),异步方法内有几处需要await的代码部分,就有几个该对象,每一个都表示了该处await代码的任务完成状态。 - MoveNext方法:在该方法内,会生成一个switch / case,每个case都检查任务状态。
- case -1(初始值),则从头开始执行任务。获取await部分的异步方法的awaiter,调用m_builder.AwaitUnsafeOnCompleted(ref awaiterType1, ref this),这会告诉awaiter在操作完成时调用MoveNext(指定的Awaiter完成时,安排状态机以继续下一操作)。之后调用return将线程返回给外部调用方。
- case 0:表示第一个awaiter以异步方式完成了,此时获取完成任务的awatier
- case 0+n:表示第n个awaiter完成了。同上
- 每完成一个异步代码(标记了await关键字)部分,都会将对应的awaiter作为最新的awaiter。
- 每次当有异步部分代码完成任务,都调用最新的awaiter.GetResult()获取该异步部分的结果并让m_resultType保存起来。
- 如果是包含循环的异步方法,则会额外使用goto语句来模拟循环过程。
任何时候使用 await
操作符,编译器都会获取操作数,并尝试在它上面调用 GetAwaiter
方法。调用 GetAwaiter
方法所返回的对象称为 awaiter
(等待者),正是它将被等待的对象与状态机粘合起来。
状态机获得 awaiter 后,会查询其 IsCompleted
属性。如果操作已经以同步方式完成了,属性将返回 true
,而作为一项优化措施,状态机将继续执行并调用 awaiter 的 GetResult
方法。该方法要么抛出异常(操作失败),要么返回结果(操作成功)。状态机继续执行以处理结果。如果操作以异步方式完成,IsCompleted
将返回 false
。状态机调用 awaiter 的 OnCompleted
方法并向它传递一个委托(引用状态机的 MoveNext
方法)。现在,状态机允许它的线程回到原地以执行其他代码。将来某个时候,封装了底层任务的 awaiter 会在完成时调用委托以执行 MoveNext
。可根据状态机中的正确位置,使方法能从它当初离开时的位置继续。这时,代码调用 awaiter 的 GetResult
方法。执行将从这里继续,以便对结果进行处理。
原生代码:
private static async Task<String> MyMethodAsync(Int32 argument) {
Int32 local = argument;
try {
Type1 result1 = await Method1Async();
for (Int32 x = 0; x < 3; x++) {
Type2 result2 = await Method2Async();
}
}
catch (Exception) {
Console.WriteLine("Catch");
}
finally {
Console.WriteLine("Finally");
}
return "Done";
}
编译器生成IL代码,然后逆向为C#代码:
// AsyncStateMachine 特性指出这是一个异步方法(对使用反射的工具有用)
// 类型指出实现状态机的是哪个结构
[DebuggerStepThrough, AsyncStateMachine(typeof(StateMachine))]
private static Task<String> MyMethodAsync(Int32 argument) {
// 创建状态机实例并初始化它
StateMachine stateMachine = new StateMachine() {
// 创建 builder ,总这个存根方法返回 Task<String>
// 状态机访问 builder 来设置 Task 完成/异常
m_builder = AsyncTaskMethodBuilder<String>.Create(),
m_state = -1, // 初始化状态机位置
m_argument = argument // 将实参拷贝到状态机字段
};
// 开始执行状态机
stateMachine.m_builder.Start(ref stateMachine);
return stateMachine.m_builder.Task; // 返回状态机的 Task
}
// 这是状态机结构
[CompilerGenerated, StructLayout(LayoutKind.Auto)]
private struct StateMachine : IAsyncStateMachine {
// 代表状态机 builder(Task)及其位置的字段
public AsyncTaskMethodBuilder<String> m_builder;
public Int32 m_state;
// 实参和局部变量现在成了字段
public Int32 m_argument, m_local, m_x;
public Type1 m_resultType1;
public Type2 m_resultType2;
// 每个 awaiter 类型一个字段。
// 任何时候这些字段只有一个是重要的,那个字段引用最近执行的、以异步方式完成的 await
private TaskAwaiter<Type1> m_awaiterType1;
private TaskAwaiter<Type2> m_awaiterType2;
// 这是状态机方法本身
void IAsyncStateMachine.MoveNext() {
String result = null; // Task 的结果值
// 编译器插入 try 块来确保状态机的任务完成
try {
Boolean executeFinally = true; // 先假定逻辑上离开 try 块
if (m_state == -1) { // 如果第一次在状态机方法中,
m_local = m_argument; // 原始方法就从头开始执行
}
// 原始代码中的 try 块
try {
TaskAwaiter<Type1> awaiterType1;
TaskAwaiter<Type2> awaiterType2;
switch (m_state) {
case -1: // 开始执行 try 块中的代码
// 调用 Method1Async 并获得它的 awaiter
awaiterType1 = Method1Async().GetAwaiter();
if (!awaiterType1.IsCompleted) {
m_state = 0; // Method1Async 要以异步方式完成
m_awaiterType1 = awaiterType1; // 保存 awaiter 以便将来返回
// 告诉 awaiter 在操作完成时调用 MoveNext
m_builder.AwaitUnsafeOnCompleted(ref awaiterType1, ref this);
// 上述代码调用 awaiterType1 的 OnCompleted,它会在被等待的任务上
// 调用 ContinueWith(t => MoveNext())。
// 任务完成后,ContinueWith 任务调用 MoveNext
executeFinally = false; // 逻辑上不离开 try 块
return; // 线程返回至调用者
}
// Method1Async 以同步方式完成了
break;
case 0: // Method1Async 以异步方式完成了
awaiterType1 = m_awaiterType1; // 恢复最新的 awaiter
break;
case 1: // Method2Async 以异步方式完成了
awaiterType2 = m_awaiterType2; // 恢复最新的 awaiter
goto ForLoopEpilog;
}
// 在第一个 await 后, 我们捕捉结果并启动 for 循环
m_resultType1 = awaiterType1.GetResult(); // 获取 awaiter 的结果
ForLoopPrologue:
m_x = 0; // for 循环初始化
goto ForLoopBody; // 跳过 for 循环主体
ForLoopEpilog:
m_resultType2 = awaiterType2.GetResult();
m_x++; // 每次循环迭代都递增 x
// ↓↓ 直通到 for 循环主体 ↓↓
ForLoopBody:
if (m_x < 3) { // for 循环测试
// 调用 Method2Async 并获取它的 awaiter
awaiterType2 = Method2Async().GetAwaiter();
if (!awaiterType2.IsCompleted) {
m_state = 1; // Method2Async 要以异步方式完成
m_awaiterType2 = awaiterType2; // 保存 awaiter 以便将来返回
// 告诉 awaiter 在操作完成时调用 MoveNext
m_builder.AwaitUnsafeOnCompleted(ref awaiterType2, ref this);
executeFinally = false; // 逻辑上不离开 try 块
return; // 线程返回至调用者
}
// Method2Async 以同步方式完成了
goto ForLoopEpilog; // 以同步方式完成就再次循环
}
}
catch (Exception) {
Console.WriteLine("Catch");
}
finally {
// 只要线程物理上离开 try 就会执行 finally。
// 我们希望在线程逻辑上离开 try 时才执行这些代码
if (executeFinally) {
Console.WriteLine("Finally");
}
}
result = "Done"; // 这是最终从异步函数返回的东西
}
catch (Exception exception) {
// 未处理的异常:通常设置异常来完成状态机的 Task
m_builder.SetException(exception);
return;
}
// 无异常:通过返回结果来完成状态机的 Task
m_builder.SetResult(result);
}
}
SynchronizationContext
SynchronizationContext
派生对象将应用程序模型连接到它的线程处理模型。等待一个 Task
时会获取调用线程的 SynchronizationContext
对象。线程池线程完成 Task
后,会使用该 SynchronizationContext
对象。确保为应用程序模型使用正确的线程处理模型。(制定了)
FileStream和异步
使用 FileStream
时必须先想好是同步还是异步执行文件 I/O,并指定(或不指定) FileOptions.Asynchronous
标志来指明自己的选择。如果指定了该标志,就总是调用 ReadAsync
。 如果没有指定这个标志,就总是调用 Read
。这样可以获得最佳的性能。
通过 FileOptions.Asynchronous
标志指定以同步还是异步方式进行通信。这等价于调用 Win32 CreateFile
函数并传递 FILE_FLAG_OVERLAPPED
标志。如果不指定这个标志,Windows 以同步方式执行所有文件操作。当然,仍然可以调用 FileStream
的 ReadAsync
方法。对于你的应用程序,操作表面上异步执行,但 FileStream
类是在内部用另一个线程模拟异步行为。这个额外的线程纯属浪费,而且会影响到性能。
指定 FileOptions.Asynchronous
标志后,可以调用 FileStream
的 Read
方法执行一个同步操作。在内部,FileStream
类会开始一个异步操作,然后立即使调用线程进入睡眠状态,直至操作完成才会唤醒,从而模拟同步行为。这同样效率低下。但相较于不指定 FileOPtions.Asynchronous
标志来构建一个 FileStream
并调用 ReadAsync
,它的效率还是要高上那么一点点的。
29. 基元线程同步构造
锁的问题
- 需要保证所有数据被共享的地方都被加锁;
- 获取和释放锁会影响性能;
- 假定一个线程池线程试图获取一个它暂时无法获取的锁,线程池就可能创建一个新线程(为了确保 CPU 保持忙碌,线程池可能会创建一个新的线程来处理其他任务。这是为了避免 CPU 资源的浪费),创建线程是一个昂贵的操作,会耗费大量内存和时间。更不妙的是,当阻塞的线程再次运行时,它会和这个新的线程池线程共同运行。也就是说,Windows 现在要调度比 CPU 数量更多的线程,这会增大上下文切换的机率,进一步损害到性能。
综上所述,在设计自己的应用程序时,应该尽可能地避免进行线程同步。
一些建议:
- 避免使用像静态字段这样的共享数据。
- 避免将线程中构造的对象引用传给可能同时使用对象的另一个线程,就不必同步对该对象的访问。
- 也可试着使用值类型,每个线程操作的数据都是它自己的副本。
- 另外,多个线程同时对共享数据进行只读访问是没有任何问题的。
基元
具有特殊语法支持的类型,比如int a = 0
,而不用 Int32 a = new Int32()
;
用户模式构造
CLR 保证对以下数据类型的变量的读写是原子性的:Boolean
,Char
,(S)Byte
,(U)Int16
,(U)Int32
,(U)IntPtr
,Single
以及引用类型。这意味着变量中的所有字节都一次性读取或写入。
对于读写非原子的类型,比如Int64
,另一个线程可能查询 x
,并得到值 0x0123456700000000
或 0x0000000089abcdef
值,因为读取和写入操作不是原子性的。这称为一次 torn read。这是因为一次读取被撕成两半。或者说在机器级别上,要分两个 MOV 指令才能读完。
Volatile关键字
声明为Volatile的字段,将被禁止编译器优化,这将告诉 C# 和 JIT 编译器不将字段缓存到 CPU 的寄存器中,确保字段的所有读写操作都在 RAM 中进行(随时都可以取到最新的值)。同时具有Volatile.Write()
和Volatile.Read()
的功能。
杂项
编码和字节
Unicode字符集,三种编码方式,UTF-8,UTF-16,UTF-32 从 0 开始,为每个符号指定一个编号,这叫做”码点”(code point)。比如,码点 0 (U+0000)的符号就是 null(表示所有二进制位都是 0)。 Unicode 只规定了每个字符的码点,到底用什么样的字节序表示这个码点,涉及到编码方法。
UTF-8 属于变长的编码方式,它可以由 1,2,3,4 四种字节组合,使用的是高位保留的方式来区别不同变长,具体方式如下:
-
对于只有一个字节的符号,字节的第一位设为0,后面 7 位为这个符号的 Unicode 码。此时,对于英语字母UTF-8 编码和 ASCII 码是相同的。
-
对于 n 字节的符号(n > 1),第一个字节的前 n 位都设为 1,第 n + 1 位设为0,后面字节的前两位一律设为 10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码,如下表所示:
码点范围(十六进制) | UTF-8编码方式(二进制) | 字节数 |
---|---|---|
0000 0000-0000 007F | 0xxxxxxx | 1 |
0000 0080-0000 07FF | 110xxxxx 10xxxxxx | 2 |
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 3 |
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 4 |
根据上表,编码字符时就非常简单了,以汉字 “丑” 为例,它的码点为 0x4E11(0100 1110 0001 0001)在上表的第三行范围(0000 0800 ~ 0000 FFFF)内,因此 “丑” 需要以三个字节的形式编码:1110xxxx 10xxxxxx 10xxxxxx