C# 高级编程 - 讲义 第 3 部分,共 4 部分






4.91/5 (72投票s)
第三部分讨论了事件、异步和动态类型、TPL 以及反射。
这些文章代表讲义,最初是作为 Tech.Pro 上的教程提供的。
目录
- 引言
- 事件
- The .NET Standard Event Pattern(.NET 标准事件模式)
- Reflection(反射)
- Dynamic Types(动态类型)
- Accessing the File System(访问文件系统)
- 流
- 线程
- Thread-Communication(线程通信)
- The Task Parallel Library(任务并行库)
- Tasks and Threads(任务和线程)
- Awaiting Async Methods(等待异步方法)
- Outlook(展望)
- Other Articles in This Series(本系列其他文章)
- 参考文献
- 历史
本教程旨在提供 C# 编程的简要而高级的介绍。理解本教程的前提是具备一定的编程知识、C 语言基础以及一点基本的数学知识。C++ 或 Java 的一些基本知识可能会有帮助,但不要求必需。
引言
这是 C# 系列教程的第三部分。在本部分中,我们将讨论 C# 中令人兴奋的功能,例如使用 DLR 的动态类型或使用称为反射的元数据信息。我们还将通过深入研究事件模式来扩展我们对 .NET 框架的知识,以及访问文件系统。最后,我们还将学习如何通过使用异步操作以及多个线程和任务来保持应用程序的响应性。使用任务并行库,我们将了解如何从多核处理器中获得最佳性能。
有关更多阅读,最后将提供参考文献列表。参考文献将对本教程中讨论的某些主题进行更深入的探讨。
Events(事件)
在之前的教程中,我们已经开始进行 Windows Forms 开发。UI 开发中的一个关键概念是正在运行的消息循环。这个循环将我们的应用程序与操作系统连接起来。关键问题是如何在这个循环中响应某些消息。当然,这个问题的答案就是事件的概念。
我们已经看到,我们可以将任意函数的指针存储在所谓的委托中。委托类型由名称、返回类型和参数列表(即它们的类型和名称)定义。这个概念使得引用方法变得容易且可靠。事件的概念与此非常相关。让我们从一个不使用事件的示例开始,但它会朝着与外部代码进行消息循环通信的方向发展。
static void Main()
{
Application.callback = () => Console.WriteLine("Number hit");
Application.Run();
}
static class Application
{
public static Action callback;
public static void Run()
{
Random r = new Random(14);
while (true)
{
double p = r.NextDouble();
if (p < 0.0001 && callback != null)
callback();
else if (p > 0.9999)
break;
}
}
}
代码做了什么?实际上没什么特别的,我们只是创建了一个名为 `ApplicationRun` 的新方法,它有一个永久运行的循环。现在里面有两个特殊情况。一种情况是我们要退出应用程序(类似于用户关闭程序时),另一种情况是我们要调用任意代码。
在此示例代码中,我们选择了一个随机数生成器的种子为 14。这相当随意。我们只做这一点是为了获得可重现的结果,该结果会调用回调方法多次。现在关键问题是:这与事件有什么关系?
事件实际上是一个回调。但是,有一些(面向编译器的)区别。第一个区别是语言扩展。除了仅使用委托之外,我们还需要使用 `event` 关键字。一旦委托变量被标记为 `event`,我们就不能从定义类的外部直接设置它。相反,我们只能添加或删除额外的事件处理程序。
我们可以绘制一个表示这种关系的图。
让我们分两部分修改我们的代码。
void Main()
{
Application.callback += () => Console.WriteLine("Number hit");
Application.Run();
}
static class Application
{
public static event Action callback;
public static void Run()
{
Random r = new Random(14);
while (true)
{
double p = r.NextDouble();
if (p < 0.0001 && callback != null)
callback();
else if (p > 0.9999)
break;
}
}
}
现在我们看到我们需要使用自增运算符(`+=`)来添加事件处理程序。可以通过使用自减运算符(`-=`)来删除事件处理程序。只有当事件处理程序已添加到给定方法时,才可能这样做。否则当然什么也无法删除(这不会导致异常,但可能导致意外行为,例如,当您认为您删除实际处理程序时,却删除了其他匹配所需签名的内容)。
显然,我们可以为同一个事件使用更多的处理程序。因此,在我们的 `Main` 方法中,以下操作也是可能的。
Application.callback += () => Console.WriteLine("Number hit");
Application.callback += () => Console.WriteLine("Aha! Another callback");
Application.Run();
现在调用 `Application` 类中的委托实例时,将调用两个方法。这怎么可能?神奇之处在于两件事。
- 编译器会创建方法,在将 `+=` 和 `-=` 与我们定义的事件结合使用时会被调用。当我们使用该变量以及其中一个运算符时,将调用相应的该方法。
- 编译器使用 `Delegate` 类的 `Combine` 方法,通过使用 `+=` 将多个委托组合成一个委托。此外,添加或删除处理程序是线程安全的。编译器将使用 `CompareExchange` 指令插入 `lock` 语句。
结果对我们来说相当不错。使用 `event` 关键字,我们不仅可以将委托标记为特殊的东西(即事件),而且编译器还会构建一些额外的辅助工具,这些工具非常有用。
我们稍后会看到,虽然添加或删除事件处理程序是线程安全的,但触发它们则不是。但是,目前我们对当前状态感到满意,能够创建我们自己的事件并将事件处理程序连接起来,以便在事件被触发时进行回调。
The .NET Standard Event Pattern(.NET 标准事件模式)
理论上,事件可以期望其处理程序返回一个值。但这只是理论,与事件仅使用 `delegate` 类型实例有关。实际上,事件的触发不期望任何返回值,因为事件的源头不需要任何处理程序处于活动状态。
实践中,可以重用事件处理程序来处理相同类型但不同的实例,甚至不同类型的不同实例,这些实例具有相同的事件模式。虽然后一种情况可能不太好(取决于场景,它确实是一个很好的解决方案,但通常我们希望避免这种情况),但前一种情况可能经常发生。让我们考虑以下代码片段。
void CreateNumberButtons()
{
for (int i = 1; i <= 9; i++)
{
Button bt = new Button();
bt.Text = i.ToString();
bt.Dock = DockStyle.Top;
bt.Click += MyButtonHandler;
this.Controls.Add(bt);
}
}
在这里,我们创建了 9 个按钮,它们将被添加到当前 `Form` 的控件列表中。我们将每个按钮都指定一个名为 `Click` 的事件的处理程序。我们不指定不同的处理程序,而是始终重用同一个处理程序。在单击事件触发时应调用的方法名为 `MyButtonHandler`。现在的问题是:在此处理程序中,我们如何区分各个按钮?答案很简单:让处理程序的第一个参数成为事件的发送者(源头)!这就是我们的方法的样子。
void MyButtonHandler(object sender, EventArgs e)
{
Button bt = sender as Button;
if (bt != null)
MessageBox.Show(bt.Text);
}
还可以通过两种方式专门化此签名。
- 我们可以为发送者使用更专业的类型。大多数 .NET 事件将使用 `Object` 作为发送者类型,这允许任何对象成为源头。重要的是要认识到这仅适用于事件的签名,而不适用于实际事件,例如 `Button` 的 `Click` 事件。
- 我们可以使用更专业的 `EventArgs` 版本。我们现在将讨论这个类型代表什么。
第二个参数是一个对象,它将变量/状态从事件的源头传输到处理程序。一些事件仅使用一个名为 `EventArgs` 的虚拟类型,而其他事件则使用更专业的 `EventArgs` 版本,其中包含一些属性(甚至方法)。理论上,这个参数不需要派生自 `EventArgs`,但在实践中,它是一种将类型标记为用作传输包的好方法。
现在我们已经看到了 .NET 标准事件模式是什么。它是一个委托,形式为:
delegate void EventHandler(object sender, EventArgs e);
其中 `Object` 和 `EventArgs` 可能根据事件进一步专门化。让我们看一个更专业的版本的示例。每个窗体都有一个名为 `MouseMove` 的事件。此事件使用另一个名为 `MouseEventHandler` 的委托。其定义如下:
delegate void MouseEventHandler(object sender, MouseEventArgs e);
此处理程序看起来差别不大。唯一的区别是使用了不同类型的包。它不是使用虚拟(空)`EventArgs` 包,而是使用派生的 `MouseEventArgs` 类型。此包包含属性,这些属性在触发事件时会填充相应的值。
class Form1 : Form
{
Label info;
public Form1()
{
info = new Label();
info.Dock = DockStyle.Bottom;
info.AutoSize = false;
info.Height = 15;
info.TextAlign = ContentAlignment.MiddleCenter;
this.Controls.Add(info);
this.MouseMove += HandleMove;
}
void HandleMove(object sender, MouseEventArgs e)
{
info.Text = string.Format("Current position: ({0}, {1}).", e.X, e.Y);
}
}
在给定的示例中,我们正在创建一个名为 `Form1` 的新 `Form`。我们向其添加一个 `Label`,该 `Label` 将停靠在窗体的底部。现在,我们将为窗体的 `MouseMove` 事件连接一个事件处理程序。最后一部分至关重要,因为它在鼠标移动到 `Label` 上时不起作用。虽然某些 UI 框架(如 HTML、WPF 等)具有事件冒泡的概念,即事件将在所有合格的层上触发,而不仅仅是顶层,但在 Windows Forms 中我们必须不使用此功能。
现在我们的事件处理程序能够检索与事件相关的信息。在这种情况下,我们可以访问 `X` 和 `Y` 等属性,它们将为相对于引发事件的控件(在此例中为 `Form` 本身)的 `X`(从左)和 `Y`(从上)值提供值。
事件不仅对 UI 至关重要,而且对于处理大量任意数据输入流的任何应用程序都是如此。Reactive Extensions (Rx) 是一个非常方便的库。在 UI 和多线程的情况下,它有助于为处理日益增长的复杂性提供一种精简的方法。有关 Rx 的更多信息可以在各种在线文章中找到,或者在 Kenneth Haugland 的精彩文章 Using Reactive Extensions - cold observables 中找到。
Reflection(反射)
程序员的职位描述通常不会提及代码的效率或有效性。薪资也通常不是按行代码计算的。所以复制/粘贴总是一个选项!尽管如此,大多数程序员都很懒惰,倾向于寻找更有效的方法,从而减少代码行数(无需复制/粘贴)并获得更健壮的代码(代码中的一个更改会触发所有其他必需的更改 - 没有什么会中断)。
CLR 以特殊方式存储程序集。除了实际的(MSIL)代码外,还保存了一组与程序集相关的元数据信息。此元数据包括有关我们定义的类型和方法的信息。它不包括确切的算法,而是包括结构。可以使用称为反射的概念访问和使用此信息。使用反射有多种方法。
- 通过调用任意对象(实例)的 `GetType()` 方法在运行时获取 `Type` 实例。
- 通过使用任意类型的 `typeof()`(例如 `typeof(int)`)在编译时获取 `Type` 实例。
- 使用 `Assembly` 类加载程序集(当前程序集、已加载的程序集或文件系统中的任意 CLR 程序集)。
当然,还有其他方法,但本教程中我们只对这三种感兴趣。在这三种方法中,我们可以跳过第二种,因为(最终)它将归结为第一种。因此,让我们用一种称为工厂设计模式的简单示例来深入探讨这个问题。此模式用于根据某些参数创建类型的专用版本。让我们从定义一些类开始。
class HTMLElement
{
string _tag;
public HTMLElement(string tag)
{
_tag = tag;
}
public string Tag
{
get { return _tag; }
}
}
class HTMLImageElement : HTMLElement
{
public HTMLImageElement() : base("img")
{
}
}
class HTMLParagraphElement : HTMLElement
{
public HTMLParagraphElement() : base("p")
{
}
}
我们现在有三个类,其中 `HTMLElement` 类是独立的,其他两个类派生自它。场景现在应该很简单:另一个程序员不应该担心为哪种参数创建哪个类(在这种情况下,参数将是一个简单的字符串),而应该只调用 `Document` 类中名为 `CreateElement` 的另一个 `static` 方法。
class Document
{
public static HTMLElement CreateElement(string tag)
{
/* code to come */
}
}
实现此工厂方法的经典方法是以下代码。
switch(tag)
{
case "img":
return new HTMLImageElement();
case "p":
return new HTMLParagraphElement();
default:
return new HTMLElement(tag);
}
现在这段代码的问题在于我们必须一遍又一遍地指定标签名称。当然,我们可以将“`img”或“`p” `string` 更改为常量,但仍然必须维护一个不断增长的 `switch`-`case` 块。添加新类只是工作的一半。这会导致维护问题。好的代码应该能够自行维护。这就是反射派上用场的地方。
让我们使用反射重写实现。
class Document
{
//A (static) key-value dictionary to store string - constructor information.
static Dictionary<string, ConstructorInfo> specialized;
public static HTMLElement CreateElement(string tag)
{
//Has the key-value dictionary been initialized yet? If not ...
if (specialized == null)
{
//Get all types from the current assembly (that includes those HTMLElement types)
var types = Assembly.GetCallingAssembly().GetTypes();
//Go over all types
foreach(var type in types)
{
//If the current type is derived from HTMLElement
if (type.IsDerivedFrom(typeof(HTMLElement)))
{
//Get the constructor of the type - with no parameter
var ctor = type.GetConstructor(Type.Empty);
//If there is an empty constructor
//(otherwise, we do not know how to create an object)
if (ctor != null)
{
//Call that constructor and treat it as an HTMLElement
var element = ctor.Invoke(null) as HTMLElement;
//If all this succeeded, add a new entry to the dictionary
//using the constructor and the tag
if (element != null)
specialized.Add(element.Tag, ctor);
}
}
}
}
//If the given tag is available in the dictionary
//then call the stored constructor to create a new instance
if (specialized.ContainsKey(tag))
return specialized[tag].Invoke(null) as HTMLElement;
//Otherwise, this is an object without a special implementation;
//we know how to handle this!
return new HTMLElement(tag);
}
}
很明显,代码变长了很多。但是,我们也会意识到这是一个健壮的解决方案,对于这种情况非常有效。代码到底做了什么?大部分代码用于构建一个字典,然后用于将某些场景(在这种情况下是某些标签)映射到适当的类型(在这种情况下是构造函数中的适当方法)。完成此操作后(这只需要调用一次),之前的 `switch`-`case` 块就简化为这三行。
if (specialized.ContainsKey(tag))
return specialized[tag].Invoke(null) as HTMLElement;
return new HTMLElement(tag);
简短易懂,不是吗?这就是反射的美妙和魔力!通过添加新类,代码现在可以自行扩展。
class HTMLDivElement : HTMLElement
{
public HTMLDivElement() : base("div")
{
}
}
class HTMLAnchorElement : HTMLElement
{
public HTMLAnchorElement() : base("a")
{
}
}
我们刚刚添加了两个新类,但我们不必担心维护我们的工厂方法。实际上,一切都将开箱即用!
让我们暂时离开一下,考虑另一个使用反射的示例。在上一篇教程中,我们研究了匿名对象。使用匿名对象的一种方法是使用 `var` 关键字(用于启用类型推断)对其进行初始化。因此,我们可以这样做。
var person = new { Name = "Florian", Age = 28 };
这工作得很好,并且我们可以在当前作用域内访问匿名对象的成员。但是,一旦我们必须传递这种类型的对象,我们就缺少正确的类型。现在我们有三个选项。
- 我们不使用匿名类型,而是创建一个类来覆盖数据封装。
- 我们在下一节中使用 DLR。
- 我们将调用方法的特定参数类型更改为非常通用的 `Object` 类型并使用反射。
由于当前部分讨论反射,我们将尝试第三种选择。让我们在更大的上下文中看看这个代码片段。
void CreateObject()
{
var person = new { Name = "Florian", Age = 28 };
AnalyzeObject(person);
}
void AnalyzeObject(object o)
{
/* use reflection here */
}
现在的问题是:`AnalyzeObject` 方法的目的是什么?假设我们只对给定对象的属性感兴趣。我们想列出它们的名称、类型和当前值。当然,`GetType()` 方法将在此处发挥非常重要的作用。实现可能看起来像以下代码片段。
//Get the type information
Type type = o.GetType();
//Get an array with property information
PropertyInfo[] properties = type.GetProperties();
//Iterate over all properties
foreach(var property in properties)
{
//Get the name of the property
string propertyName = property.Name;
//Get the name of the type of the property
string propertyType = property.PropertyType.Name;
//Get the value of the property given in the instance o
object propertyValue = property.GetValue(o);
Console.WriteLine("{0}\t{1}\t{2}", propertyName, propertyType, propertyValue);
}
这一切工作都很顺利。这里的教训是关于 `PropertyInfo` 类的 `GetValue` 方法。此方法显然对获取具有此特定属性的实例的值感兴趣。区分通过使用实例的 `GetType` 获取的纯类型信息和实例很重要。实例是基于类型中描述的结构构建的。类型本身不知道任何实例。
但是,有一个特殊情况,在这种情况下,仅传入 `null` 作为实例就足够了。考虑以下情况。
class MyClass : IDisposable
{
static int instances = 0;
bool isDisposed;
public static int Instances
{
get { return instances; }
}
public MyClass()
{
instances++;
}
public void Dispose()
{
isDisposed = true;
instances--;
}
~MyClass()
{
if (!isDisposed)
Dispose();
}
}
此类会跟踪其实例。如果我们想通过反射获取属性 `Instances` 的值怎么办?由于 `Instances` 是一个 `static` 属性,该属性本身与特定类实例无关。因此,以下代码在这种情况下可以正常工作。
var propertyInfo = typeof(MyClass).GetProperty("Instances");
var value = propertyInfo.GetValue(null);
反射在很多情况下都需要将 `null` 作为参数传递,但是,我们应该始终阅读有关方法的文档,然后再决定什么参数最适合特定情况。
在进入动态类型世界之前,我们将研究反射的另一个有趣的选项:获取方法信息。当然,还有其他几种有趣的可能性,例如读取属性或使用 `Emit` 方法即时创建新类型。
方法信息与获取属性信息非常相似,甚至更类似于获取构造函数信息。事实上,`PropertyInfo`、`MethodInfo` 和 `ConstructorInfo` 都继承自 `MemberInfo`,其中 `MethodInfo` 和 `ConstructorInfo` 间接继承自它,同时直接继承自 `MethodBase`。
让我们对匿名对象做同样的事情,但现在读取所有可用的方法。
//Get the type information
Type type = o.GetType();
//Get an array with method information
MethodInfo[] methods = type.GetMethods();
//Iterate over all methods
foreach(var method in methods)
{
//Get the name of the method
string methodName = method.Name;
//Get the name of the return type of the method
string methodReturnType = method.ReturnType.Name;
Console.WriteLine("{0}\t{1}", methodName, methodReturnType);
}
在这种情况下,读取值要困难得多,因为只有在方法没有参数时才能做到。否则,我们将需要找出实际需要哪些参数。尽管如此,这是可能的,并且可以产生一个非常简单的单元测试工具,该工具只查看 `public` 方法并尝试使用默认值调用它们(任何类的默认值为 `null`,任何结构体的逻辑默认值为 `0`,例如 `Int32` 的默认值为 `0`)。
如果我们执行上面的代码,我们会感到惊讶。考虑到任何类型都派生自 `Object`,它已经为我们提供了四种方法,这可能是我们预期的输出。
Equals Boolean
GetHashCode Int32
ToString String
GetTypes Type
然而,这是显示的输出。
get_Name String
get_Age Int32
Equals Boolean
GetHashCode Int32
ToString String
GetTypes Type
我们看到编译器插入了两个新方法。一个名为 `get_Name`,返回 `String` 对象,另一个名为 `get_Age`,返回一个整数。很明显,编译器将我们的属性转换为方法。所以总的来说,任何属性仍然是一个方法 - `GetProperty()` 或 `GetProperties()` 方法只是访问它们的快捷方式,而无需遍历所有方法。
最终,反射因此也可以教会我们很多关于 MSIL 和 C# 编译器的知识。我们在这里调查的所有内容都反映(双关语)在以下图表中,该图表显示了反射使用的对象树。
Dynamic Types(动态类型)
在上一节中,我们已经提到传递匿名对象的另一种可能性是使用动态类型。在深入了解允许我们使用动态类型而不是 CLR 中的静态类型的动态语言运行时 (DLR) 之前,让我们看一些代码。
void CreateObject()
{
var person = new { Name = "Florian", Age = 28 };
UseObject(person);
}
void UseObject(object o)
{
Console.Write("The name is . . . ");
//This will NOT work!
Console.WriteLine(o.Name);
}
我们执行的操作与之前完全相同,但现在我们不关心分析给定实例类型的 `Object` 的信息,而是关心实际使用给定 `Object` 的某些属性或方法。当然,上面的代码无法编译,因为 `Object` 没有 `Name` 属性。但是,如果实际类型具有此名称的属性呢?有没有办法告诉编译器忽略错误,并在运行时尝试映射它?答案当然是*是*。与 `var` 一样,魔法在于一种称为 `dynamic` 的关键字类型。让我们更改代码。
void UseObject(dynamic o)
{
Console.Write("The name is . . . ");
//This will work!
Console.WriteLine(o.Name);
}
一切都像以前一样工作。我们所要做的就是更改方法的签名。如果我们现在在 `UseObject` 方法的 `o.` 主体内输入,我们将不会获得任何 IntelliSense 支持。这有点令人恼火,但另一方面,在使用反射时我们也不会获得 IntelliSense 支持!
那么,故事就此结束了吗?当然不是!首先,我们需要认识到每个标准的 CLR 对象都可以被视为动态对象。所以以下所有内容都可以正常工作。
int a = 1;//a is of type Int32
var b = 1;//b is of type Int32 (inferred)
dynamic c = 1;//c is of type dynamic -- only known at runtime (but will be Int32)
object d = 1;//d is of type object, but the actual type is Int32
这似乎在各种定义之间没有区别。但是,实际上有很多区别。让我们使用这些变量。
var a2 = a + 2; //works, Int32 + Int32 = Int32
var b2 = b + 2; //works, Int32 + Int32 = Int32
var c2 = c + 2; //works, dynamic + Int32 = dynamic
var d2 = d + 2; //does not work, object + Int32 = undef.
虽然 `int` 是一个真实类型(映射到 `Int32`),而 `var` 在这种情况下只是一个用于让编译器推断类型的关键字(将是 `Int32`)。`object` 是一个真实类型,在这种情况下它会装箱实际类型 `Int32`。现在前三个操作都成功了 - 它们相等吗?我们必须再次回答“否”。让我们看看这个代码片段。
a = "hi"; //Ouch!
b = "hi"; //Ouch!
c = "hi";//Works!
d = "hi";//Works!
在这里,前两个赋值将导致编译错误。`string` 不能赋给 `Int32` 类型的变量。但是,`string` 可以强制转换为 `Object`。另外,`dynamic` 意味着实际类型可以在运行时更改为任何类型。
到目前为止,我们了解到 `dynamic` 变量实际上是非常动态的。它们提供变量后面的实际类型的全部功能,但不能更改该类型。然而,拥有巨大的力量就意味着承担巨大的责任。这可能会导致以下代码片段中的问题。
dynamic a = "32";
var b = a * 5;
编译器不会抱怨使用 `String` 进行乘法运算,但在运行时,我们将在此时遇到一个非常糟糕的异常。检测此类行可能在示例中看起来很简单,但在现实中,它更像是以下代码片段。
dynamic a = 2;
/* lots of code */
a = "Some String";
/* some more code */
var b = 2;
/* and again more code */
var c = a * b;
现在不再那么明显了。复杂性源于可能的代码路径的数量。动态类型在映射功能方面提供了一些优势。例如,使用带有动态类型的函数将始终导致选择最接近的匹配重载。让我们看一个示例,了解这意味着什么。
var a = (object)2; //This will be inferred to be Object
dynamic b = (object)2; //This is dynamic and the actual type is Int32
Take(a); //Received an object
Take(b); //Received an integer
void Take(object o)
{
Console.WriteLine("Received an object");
}
void Take(int i)
{
Console.WriteLine("Received an integer");
}
尽管我们将 `Object` 类型的变量赋给了动态变量,但 DLR 仍然能够选择正确的重载。每个人都必须回答自己的问题:这是否是我们要的?通常,如果我们已经选择了动态类型,答案是肯定的,但来自静态编程语言,答案是否定的。不过,很高兴知道 C# 和 DLR 可以实现这种行为。
到目前为止,我们应该已经学到了以下要点。
- `dynamic` 告诉编译器在运行时由 DLR 处理该变量。
- 动态变量可以与任何其他变量组合,再次生成 `dynamic` 实例。
- CLR 类型仍然可用,但仅在运行时可用。这使得在编译时无法进行预测。
- 如果 `dynamic` 变量的操作或方法调用不可用,我们将收到难看的异常。
现在我们可能对一件事感兴趣:在哪里可以使用这种动态编程?所以让我们看看一张有趣的图片。
我们看到 DLR 是连接 .NET 语言(如 C#)或方言(如 IronRuby)与各种对象(如 CLR 对象或 Python 动态对象等)的层。这意味着任何动态的东西都提供了一个绑定机制(我们也可以编写自己的),可以在 .NET 语言中得到支持。这意味着我们可以编写一个 C# 程序来与用 Python 编写的脚本进行交互!
在这个部分中,还有两个关键的教训应该被学到。第一个将向我们展示如何创建我们自己的 `dynamic` 类型。第二个将让我们对 DLR 的实际使用有所了解。
DLR 通过实现一种特殊类型的接口来定义其类型。在本节中,我们不关心确切的细节,而是关心一些已经实现了此接口的有趣类。目前,有两个非常有趣的类,名为 `ExpandoObject` 和 `DynamicObject`。例如,我们将构建我们自己的类型,基于 `DynamicObject`。我们将其命名为 `Person`。
class Person : DynamicObject
{
//This will be responsible for storing the properties
Dictionary<string, object> properties = new Dictionary<string, object>();
public override bool TryGetMember(GetMemberBinder binder, out object result)
{
//This will get the corresponding value from the properties
return properties.TryGetValue(binder.Name, out result);
}
public override bool TrySetMember(SetMemberBinder binder, object value)
{
//binder.Name contains the name of the variable
properties[binder.Name] = value;
return true;
}
public Dictionary<string, object> GetProperties()
{
return properties;
}
public override string ToString()
{
//Our object also has a specialized string output
StringBuilder sb = new StringBuilder();
sb.AppendLine("--- Person attributes ---");
foreach (var key in properties.Keys)
{
//We use the chaining property of the StringBuilder methods
sb.Append(key).Append(": ").AppendLine(properties[key].ToString());
}
return sb.ToString();
}
}
如何使用这种类型?让我们看一个示例。
dynamic person = new Person();
person.Name = "Florian";
person.Age = 28;
Console.WriteLine(person);
person.Country = "Germany";
Console.WriteLine(person);
这使得扩展现有对象非常容易,而且一切都如魔法般开箱即用。现在让我们继续看一个实际示例。通常,人们会选择与动态脚本语言(JavaScript、Python、PHP、Ruby 等)进行通信,但我们将做一些不同的事情。
`ExpandoObject` 还可以用于包装 .NET Framework 类 `XmlDocument`,使其更易于使用,从而我们可以以更优雅的方式访问节点成员。常规方法如下面的代码片段所示。
var document = new XmlDocument();
document.Load("path/to/an/xml/document.xml");
var element = document.DocumentElement.GetElementsByTagName("child")[0];
让我们想象一下,我们可以让 API 变得更好一点。在最好的情况下,类似以下的内容可以工作,假设 `DocumentElement` 被称为 `root`。
var document = new XmlDocument("path/to/an/xml/document.xml");
var element = document.GetElement("root").GetElement("child");
将此更优美的 API 包装在 `ExpandoObject` 中可以让我们更进一步。使用 DLR,我们可以将其重写为。
dynamic document = new XmlDocument("path/to/an/xml/document.xml");
var element = document.root.child;
重要的是要注意,`element` 将再次是 `dynamic` 类型,因为 `document` 已经是 `dynamic` 类型。此外,再次需要注意的是,如果 `root` 和 `child` 都不作为节点存在,我们将面临严重的异常。
DLR 的另一个用例是与 COM 应用程序(如 Microsoft Office(Access、Excel、Word 等))的互操作。
Accessing the File System(访问文件系统)
在进入有趣的多线程、并发和异步编程主题之前,我们将开始使用 .NET Framework 的 `System.IO` 命名空间。此命名空间中的所有类都处理输入/输出,主要是文件系统。
让我们考虑一些简单的任务:我们想获取目录信息,或者我们想知道特定文件的所有信息。这意味着我们需要从文件系统中读取信息。但是,由于巧合,Windows 知道一切,并且有一些好的 API 可以进行通信。感谢 .NET Framework,这些 API 以面向对象的方式供我们访问。
我们应该从哪里开始?`static` 类 `Path` 包含许多有用的辅助程序和通用变量,如路径分隔符(Windows 使用反斜杠)。我们还有 `Directory` 和 `File` 等类,可用于执行一些直接操作。此外,我们还有 `DirectoryInfo`、`FileInfo` 或 `DriveInfo` 类等数据封装。整个 ACL(访问控制列表)模型也可以使用特殊的类和方法进行访问。
让我们使用 Windows Forms 创建一个示例项目。在主窗口上,我们放置两个按钮和一个 `ListBox` 控件。两个按钮应为 `Click` 事件获得事件处理程序,`ListBox` 控件应为 `SelectedIndexChanged` 事件获得事件处理程序。当用户更改此控件中当前选定的项目时,将调用此事件。
我们的示例应用程序应加载来自特定目录的所有文件(我们只关心这些文件的名称),以及所有当前使用的驱动器字母。当我们按下第一个按钮时,`Listbox` 控件应被填充。只有当我们选择了 `ListBox` 控件中的有效文件时,第二个按钮才应启用。然后,此 `Button` 控件应触发 `MessageBox` 以显示所选文件内容的可视化文本表示。
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
//What happens if the first button is clicked?
private void button1_Click(object sender, EventArgs e)
{
//String Literals have an @ symbol before the string
//There \ is no escape sequence starter
string path = @"C:\Windows";
//Reading out the files of the given directory
string[] files = Directory.GetFiles(path);
//Reading out all drives
DriveInfo[] drives = DriveInfo.GetDrives();
//Adding all files
foreach (var file in files)
{
//The listbox collection takes arbitrary objects as input
//and uses the ToString() for drawing
FileInfo fi = new FileInfo(file);
listBox1.Items.Add(fi);
}
//Adding all drives, however, one-by-one.
foreach (var drive in drives)
{
listBox1.Items.Add(drive);
}
}
//What happens if the second button is clicked?
private void button2_Click(object sender, EventArgs e)
{
//Just be sure that we really have an item selected
var fi = listBox1.SelectedItem as FileInfo;
//if we have an item selected AND that item is of type FileInfo then ...
if (fi != null)
{
//Read that file and show it in the MessageBox
//(beware of large and non-text files!)
string text = fi.OpenText().ReadToEnd();
MessageBox.Show(text);
}
}
//What if we change the selected index (i.e. pick another item)?
private void listBox1_SelectedIndexChanged(object sender, EventArgs e)
{
//We only want to allow button2 to be enabled if a file is selected
button2.Enabled = (listBox1.SelectedItem as FileInfo) != null;
}
}
在此示例中,我们已经使用了 `System.IO` 命名空间中的各种类型。我们主动使用 `Directory` 来获取包含文件名(`String`)的字符串数组。我们还使用 `FileInfo` 来将给定的文件名(`String`)封装为文件对象。此外,我们使用 `DriveInfo` 来获取 `DriveInfo` 实例的数组。`DriveInfo` 对象是与特定驱动器相关的所有信息的封装。也可以使用 `DirectoryInfo` 类的 `static GetFiles` 方法。此方法将直接为我们提供 `FileInfo` 对象数组。
现在我们对文件系统的通信方式有了初步印象,是时候开始读写文件了。
Streams(流)
`System.IO` 命名空间未命名为 `System.FS` 或 `System.FileSystem` 的原因很简单:文件系统通信只是输入输出的一个用例 - 还有更多。实际上,将字符串放入控制台已经是一种输出形式。从控制台读取字符串或任意键盘输入也是输入。
一些输入和输出操作在流中进行管理,即随时间提供的数据元素的序列。流可以被认为是传送带,它允许项目逐个处理,而不是批量处理。有各种各样的流,如内存流、文件流、输入流(例如来自键盘)或输出流(例如到显示器)。所有 IO 操作都涉及流式传输数据。这就是 `System.IO` 命名空间主要涉及 `Stream` 类及其实现的原因。
`Stream` 类本身是 `abstract` 的,因为每个数据流都依赖于相应的设备,例如 HDD、RAM、以太网、调制解调器。因此,必须有相应的设备的特定实现。有时,实现我们自己的 `Stream` 可能是有意义的。通过使用 `FileStream` 等专用类可以读取文件。在上一节中,我们已经看到有一些可用的辅助方法。在那里,我们使用了 `FileInfo` 类的 `OpenText` 方法来创建一个 `StreamReader` 对象。这是 `TextReader` 类的专用版本,它需要一个 `Stream`。最终,我们可以直接使用 `ReadToEnd` 方法,而无需担心如何使用 `Stream`。
让我们看看如何使用 `FileStream` 类。在接下来的代码片段中,我们将打开一个名为*test.txt*的文件。
//Open the file test.txt (for reading only)
FileStream fs = new FileStream("test.txt", FileMode.Open);
//Allocate some memory
byte[] firstTenBytes = new byte[10];
//Read ten bytes and store them in the allocated memory starting
while (fs.Read(firstTenBytes, 0, 10) != 0)
{
Console.WriteLine("Could read some more bytes!")
}
//Quite important: Closing the stream will free the handle!
fs.Close();
上面的代码打开一个文件并读取 10 个字节,直到没有更多字节可读。我们还可以通过使用 `ReadByte` 方法逐字节前进。虽然 `Read` 返回读取的实际字节数(在这种情况下,直到到达末尾,任何从 0 到 10 的数字都可以),但 `ReadByte` 以 `Int32` 的形式返回字节的实际值。原因是 `byte` 类型是无符号 8 位整数,无法告诉我们流已到达末尾。使用 `Int32`,我们可以通过检查结果是否为 `-1` 来检查是否已到达流的末尾(在这种情况下,文件末尾)。
写入文件与读取文件非常相似。在这里,我们仅使用 `FileMode.Create` 来指定我们不想打开现有文件,而是创建一个新文件。现在可以调用 `WriteByte` 或 `Write` 等方法,因为流是可写的。上面看到的一件事现在变得更加重要:在我们的操作之后,我们必须通过处理/关闭 `Stream` 对象来关闭文件。这可以通过 `Close` 方法来实现。
`Stream` 类还有两个重要的概念。
- 根据设备的不同,字节可能会在实际写入之前进行缓冲。因此,如果需要立即写入,我们必须使用 `Flush` 方法。
- 每个流都有一个 `Position` 属性,这是当前的插入/标记点。任何写入或读取操作都将从该点开始。因此,如果我们使用流从头到尾,我们必须将 `Position` 标记重置到开头才能再次开始。
在继续之前,我们应该仔细查看已经提到的 `TextReader` 和 `TextWriter` 类。这两个类不派生自 `Stream`,因为它们服务于不同的目的。这两个类专门用于读取或写入文本。文本可以是原始字节数组、字符串或流。对于每种情况,都有特定的实现。由于本节是关于流的,我们将以 `StreamReader` 和 `StreamWriter` 的形式介绍实现。
为什么我们要使用 `StreamReader` 实例来处理文本文件的 `FileStream`?魔术词是:编码!为了保存文本,我们需要指定字符如何映射到数字。前 127 个数字始终根据 ASCII 标准进行映射。在此标准中,我们有普通字母,例如 `a` 为 `0x61`,`A` 为 `0x41`,以及数字,例如 `0` 为 `0x30`。但是,我们也有特殊字符,例如换行符 `\n` 为 `0x0a` 或退格符 `\b` 为 `0x08`。现在的问题是,其他(通常更常见的地区性)字符取决于映射。有几种编码,如UTF-8、UTF-16 或Windows-1252。主要问题是:如何找到并使用。
.NET Framework 具有(相当广泛的列表)可用编码。任何 `Encoding` 实例都有 `GetString` 或 `GetChar` 等方法,但是 `TextReader` / `TextWriter` 方法已经在使用它们。我们可以在创建 `StreamReader` 或 `StreamWriter` 时指定编码,或者让对象检测编码。虽然 `Stream` 对象的货币单位是 `Byte`,但 `TextReader` 的货币单位是 `Char`。
让我们看看如何使用 `StreamWriter` 创建一些文本。
StreamWriter sw = new StreamWriter("myasciifile.txt", false, Encoding.ASCII);
sw.WriteLine("My First ASCII Line!");
sw.WriteLine("How does ASCII handle umlauts äöü?");
sw.Close();
从第一个教程中,我们知道 `Char` 数据类型是一个 16 位无符号整数。因此,我们可能知道或不知道 C# 使用 UTF-16 来存储字符。虽然 UTF-8 显示的字符可以由 1 到 4 个 UTF-8 字符组成,但 UTF-16 显示的字符可以由 1 到 2 个 UTF-16 字符组成。这里的最小负载是 2,或者与 UTF-8 相比是两倍。这应该只是促使我们在将 .NET 中纯粹使用的内存对象中的某些字符传递给其他系统时思考编码。如果编码不同或未预期的,(显示的)文本将与原始文本不同。
现在我们实际上已经到了事情开始变得有趣的地步。`Stream` 类还包含以 `async` 结尾的方法。在某些情况下,我们实际上可能更感兴趣使用 `ReadAsync` 和 `WriteAsync` 而不是它们的顺序对应项 `Read` 和 `Write`。在接下来的部分中,我们将深入研究使用 C# 的异步编程。
Threads(线程)
本教程中的最早示例之一展示了一个 `Application.Run()` 消息循环的模拟。代码被设计成很快退出,但是,注释掉退出条件将导致一个永久循环,触发大量事件。如果我们将在事件处理程序中放置大量代码,那么实际上将阻止事件循环继续。
这正是 UI 应用程序中经常发生的问题。一个事件(例如,`Button` 控件的 `Click` 事件)已被触发,我们在处理程序中执行了大量操作。最糟糕的是,我们不仅进行了一些计算,还进行了一些 IO 操作,如写入文件或下载数据。如果操作花费的时间很长(例如超过一秒),用户将体验到应用程序在那段时间内变得无响应。原因很简单:就像以前一样,消息循环被阻止继续,这阻止了其他事件(如移动窗体、单击其他按钮或其他内容)被触发。在我们的操作完成后,所有排队的消息都将被处理。
当然,因此我们不想阻塞 UI 线程。由于当应用程序空闲时会处理消息循环中的消息,因此应用程序的空闲状态非常重要。在 UI 线程中做太多工作(如在事件处理程序中)将导致应用程序无响应。因此,操作系统创建了两种模型:线程和回调。回调只是事件,我们已经知道了。如果我们能够通过事件响应状态更改,那么我们应该选择这种方式。否则,我们可能选择生成一个使用轮询来在状态更改时获得通知的线程的解决方案。在本节中,我们将研究如何在 .NET Framework 中创建和管理线程。
每个应用程序(无论是控制台还是图形)都已带有一个线程:这是应用程序/GUI 线程。如果我们使用多个线程,我们可能会获得更快的执行速度,因为有多个(CPU)核心。原因是操作系统将线程分布在不同的核心上(如果可用),因为在进程的上下文中,线程被视为独立的工作单元。即使在单个核心上,操作系统也通过分配 CPU 时间来处理线程。因此,即使在单个核心上,我们也可能获得更具响应性的 UI 的优势。当操作系统调度 CPU 时间时,它会考虑到线程仅处于自旋锁状态,并且不需要最大的计算时间。
现在剩下的问题是:我们如何创建线程?首先,我们需要一个应该在该线程中运行的方法。每个应用程序中的第一个线程由标准进程模型启动 - 在 C# 中就是 `Main` 方法。现在我们正在手动编写其他线程,我们可以启动任何方法。
`Thread` 类代表一个线程,其构造函数需要一个要运行的方法的委托。此类位于 `System.Threading` 命名空间中。让我们看一个示例。
static void Main(string[] args)
{
//Creating a thread and starting it is straight forward
Thread t = new Thread(DoALotOfWork);
//Just pass in a void Method(), or void Method(obj) and invoke start
t.Start();
//Wait for the process to be finished by pressing the ENTER key
Console.ReadLine();
}
static void DoALotOfWork()
{
double sum = 0.0;
Random r = new Random();
Debug.WriteLine("A lot of work has been started!");
//some weird algorithm
while (true)
{
double d = r.NextDouble();
//The Math class contains a set of useful functions
if (d < 0.333) sum += Math.Exp(-d);
else if (d > 0.666) sum += Math.Cos(d) * Math.Sin(d);
else sum = Math.Sqrt(sum);
}
Debug.WriteLine("The thread has been stopped!");
}
在示例中,我们启动了一个新线程,该线程使用 `DoALotOfWork` 方法作为入口点。现在我们可以输入一些文本或在工作仍在进行时停止程序。这是因为线程是并发运行的。如果我们有两个线程,那么可以完成两件事。没有人能告诉我们工作的顺序。但是,新线程和现有线程之间有一个很大的区别:工作线程中的异常会冒泡到进程线程!
此外,还应考虑以下几点。
- 生成多个线程会产生开销,因此对于许多线程,我们应该考虑使用 `ThreadPool` 类。
- 无法从工作线程更改 UI,并将导致异常。
- 我们必须避免竞态条件,即解决非独立问题变得具有挑战性,因为执行顺序不能保证。
因此,最大的问题之一是:如何进行线程间通信?
Thread-Communication(线程通信)
现在,我们只知道线程是什么以及如何启动它们。此时,线程概念看起来更像是一种开销,因为我们可能会获得响应迅速的应用程序,但我们无法将线程执行的任何结果传回。
为了同步线程,C# 引入了 `lock` 关键字。`lock` 语句会阻止对某些代码行(这些代码行被压缩在作用域块中)的使用。这就像一个屏障。屏障有助于减少竞态条件,其状态(设置/未设置)由指针(内存地址)确定。指针可以由引用类型(如任何 `Object`)提供。
让我们看一个使用两个线程的简短示例。
//This object is only used for the locks
static Object myLock = new Object();
static void Main(string[] args)
{
//Create the two threads
Thread t1 = new Thread(FirstWorker);
Thread t2 = new Thread(SecondWorker);
//Run them
t1.Start();
t2.Start();
Console.ReadLine();
}
static void FirstWorker()
{
//This will run without any rule
Console.WriteLine("First worker started!");
//The rule for the following block is: only enter
//when myLock is not in use, otherwise wait
lock (myLock)
{
Console.WriteLine("First worker entered the critical block!");
Thread.Sleep(1000);
Console.WriteLine("First worker left the critical block!");
}
//Finally print this
Console.WriteLine("First worker completed!");
}
static void SecondWorker()
{
Console.WriteLine("Second worker started!");
//The rule for the following block is: only enter
//when myLock is not in use, otherwise wait
lock (myLock)
{
Console.WriteLine("Second worker entered the critical block!");
Thread.Sleep(5000);
Console.WriteLine("Second worker left the critical block!");
}
//Finally print this
Console.WriteLine("Second worker completed!");
}
如果我们多次运行程序,我们(通常)会得到不同的输出。这是正常的,因为我们无法告诉操作系统哪个线程会先启动。实际上,我们的程序只是告诉操作系统启动一个线程,即操作系统可以决定何时执行操作。
使用 `lock` 语句可以很简单地标记关键块并确保多线程程序的连贯性。然而,这并不能解决 GUI 编程中的问题,因为我们不允许从非 GUI 线程更改 UI。
为了解决这个问题,每个 Windows Forms 控件都有一个名为 `Invoke` 的方法。此外,其他 UI 框架如 WPF 也有类似的东西。在 WPF 中,我们可以使用(更通用的)`Dispatcher` 属性。但是,线程安全 UI 调用最通用的方法是通过 `SynchronizationContext` 类。此类在任何地方都可用,甚至适用于控制台应用程序。其思想如下:线程之间的直接通信是不可能的(因为它不是线程安全的),但是,一个线程可以调用(或启动)另一个线程上下文中的方法。
这是什么意思?考虑到 GUI,我们可以轻松构建一个用例。我们创建一个 Windows Forms 应用程序,其中包含一个 `Label`、一个 `ProgresBar` 和一个 `Button` 控件。一旦用户按下按钮,就会启动一个新线程,该线程执行(耗时的)计算。此计算有一些固定点,在这些点我们知道已经完成了总体计算的某个百分比。在这些点,我们使用全局可用的 `SynchronizationContext` 实例在 GUI 线程中启动一个方法,该方法将 `ProgressBar` 的值设置为给定值。计算结束时,我们再次使用 `SynchronizationContext` 将 `Label` 的 `Text` 属性更改为给定值。
让我们看看这个示例的图。
public class Form1 : Form
{
SynchronizationContext context;
bool running;
public Form1()
{
InitializeComponent();
//The Current property gets assigned when a Form / UI element is created
context = SynchronizationContext.Current;
}
void ButtonClicked(object sender, EventArgs e)
{
//We only want to do the computation once at a time
if (running)
return;
running = true;
Thread t = new Thread(DoCompute);
t.Start();
}
void DoCompute()
{
/* Start of long lasting computation */
context.Send(_ => {
progressBar1.Value = 50;
}, null);
/* End of long lasting computation */
context.Send(_ => {
progressBar1.Value = 100;
label1.Text = "Computation finished!";
running = false;
}, null);
}
}
`SynchronizationContext` 的 `static` 属性 `Current` 携带当前(!)线程的(设置的)同步上下文。因此,如果我们想使用映射到 GUI 线程的 `Current` 的值,我们需要在 GUI 线程中存储它。此属性不是自动设置的。它必须在某处设置。在我们的例子中,然而,这个属性是由 Windows Forms Form 实例设置的。
现在我们对如何避免竞态条件和跨线程异常有了大致了解,我们可以转向一个更强大、更通用的概念:任务!
The Task Parallel Library(任务并行库)
在 .NET Framework 4.0 中,引入了一个新库:任务并行库 (TPL)。这具有强大的含义。对我们来说最重要的是新的 `datatype` `Task`。有些人认为 `Task` 是一个包装良好的 `Thread`,然而,`Task` 远不止于此。`Task` 可以是一个正在运行的线程,但是,`Task` 也可以是我们从回调中获得的所有东西。事实上,`Task` 对所使用的资源没有任何说明。如果使用 `Thread`,那么方式会更可靠、性能更高。TPL 处理一个优化的线程池,该线程池专门用于在短时间内创建和加入多个线程。
那么 TPL 是什么?它是一组用于任务的有用类和方法,以及强大的(并行)LINQ 扩展,形式为 PLINQ。PLINQ 部分可以通过在调用其他 LINQ 扩展方法之前调用 `AsParallel` 扩展方法来触发。应该注意的是,PLINQ 查询通常比它们的顺序对应项运行得慢,因为大多数查询没有足够的计算时间来证明创建线程的开销是合理的。
下图说明了 TPL 的位置以及相关的可能性。
TPL 为我们将计算密集型方法并行化到不同线程提供了非常优雅的方法。例如,如果我们使用 `Parallel.For()`,我们可以将循环分成块,这些块分布在不同的核心上。但是,我们需要注意竞态条件以及创建和管理相应线程的开销。因此,最佳情况显然是在具有大量迭代且循环体中工作量巨大的循环中,该工作量独立于其他迭代。
让我们看看 TPL 如何帮助我们并行化 `for` 循环。我们从顺序版本开始。
int N = 10000000;
double sum = 0.0;
double step = 1.0 / N;
for (var i = 0; i < N; i++)
{
double x = (i + 0.5) * step;
sum += 4.0 / (1.0 + x * x);
}
return sum * step;
最简单的并行版本如下。
object _ = new object();
int N = 10000000;
double sum = 0.0;
double step = 1.0 / N;
Parallel.For(0, N, i =>
{
double x = (i + 0.5) * step;
double y = 4.0 / (1.0 + x * x);
lock(_)
{
sum += y;
}
});
return sum * step;
需要 `lock` 块的原因是所需的同步。因此,这个非常简单的版本实际上并不高效,因为同步开销远大于我们通过使用多个处理器获得的(同步以外的工作量太小)。一个更好的版本使用了 `For` 方法的另一个重载,该重载允许创建线程本地变量。
object _ = new object();
int N = 10000000;
double sum = 0.0;
double step = 1.0 / N;
Parallel.For(0, N, () => 0.0, (i, state, local) =>
{
double x = (i + 0.5) * step;
return local + 4.0 / (1.0 + x * x);
}, local =>
{
lock (_)
{
sum += local;
}
});
return sum * step;
这看起来差别不大。必然会有几个问题。
- 为什么这更有效?答案:因为我们只为每个线程使用一次 `lock` 部分,而不是每次迭代使用一次,所以我们实际上减少了很多同步开销。
- 为什么我们需要将另一个委托作为第三个参数传递?在此重载中,第三个参数是创建线程本地变量的委托。在这种情况下,我们创建了一个 `double` 变量。
- 为什么我们不能只传递线程本地变量?如果变量已经创建,它将不是线程本地的而是全局的。我们将向每个线程传递相同的变量。如何区分?
- 主体委托的签名也已更改。`state` 和 `local` 是什么?`state` 参数使我们能够执行诸如中断或停止循环执行的操作(或了解我们所处的状态),而 `local` 变量是我们访问线程本地变量的访问点(在这种情况下只是一个 `double`)。
- 如果我需要更多的线程本地变量怎么办?如何创建匿名对象或实例化一个已定义的类?
由于 TPL 不是魔法棒,我们仍然需要注意竞态条件和共享资源。尽管如此,TPL 还引入了一套新的(并发)类型,这在处理这些问题时非常有用。
在本节的最后一部分,我们还应该讨论 `Task` 类型的影响。任务有一些非常好的特性,最值得注意的是。
- 任务提供了对当前状态的更清晰的访问。
- 取消更加流畅和定义明确。
- 任务可以连接、调度和同步。
- 任务中的异常不会冒泡,除非请求!
- `Task` 没有返回类型,但 `Task<T>` 的返回类型是 `T`。
最后一个对我们来说非常重要。如果我们启动一个计算密集型的新任务,我们可能对计算结果感兴趣。虽然使用 `Thread` 完成此操作需要一些工作,但使用 `Task` 我们可以开箱即用地获得此行为。
如今,一切都围绕 `Task` 类。现在我们仔细看看其中一些属性。
Tasks and Threads(任务和线程)
如前所述,`Task` 和 `Thread` 之间存在很大差异。虽然 `Thread` 是操作系统提供的(一种资源),但 `Task` 只是一个类。在某种程度上,我们可以说 `Task` 是 `Thread` 的特例,但这并不正确,因为并非所有运行的 `Task` 实例都基于 `Thread`。事实上,.NET Framework 中所有 IO 绑定的异步方法,它们返回 `Task<T>`,都不使用线程。它们都基于回调,即它们使用某些系统通知或驱动程序或其他进程中已运行的线程。
让我们回顾一下我们学到的关于使用线程的内容。
- 工作量必须足够大,即指令数量至少等于创建和结束线程所需的时间(这大约是 100000 个周期或 1 毫秒,取决于架构、系统和操作系统)。
- 仅仅在更多核心上运行一个问题并不等于更快的速度,即如果我们想将一个大文件写入硬盘,使用多个线程是没有意义的,因为硬件可能已经达到了从一个核心接收到的字节量。
- 始终考虑 IO 密集型与 CPU 密集型。如果问题是 CPU 密集型的,那么多个线程可能是一个好主意。否则,我们应该寻找回调解决方案,或者(最坏的情况,但仍然比使用 GUI 线程好得多)只创建一个线程。
- 最大限度地减少所需通信对于使用多线程实现更高性能至关重要。
我们已经可以看到为什么通常首选使用基于 `Task` 的解决方案。我们只需要提供一种与代码交互的方式:`Task` 类,而不是提供两种解决问题的方式(要么通过创建新线程,要么通过使用回调)。这也是 .NET Framework 的早期异步编程模型被返回相应的 `Task` 实例所取代的原因。现在实际资源(回调处理程序或线程)不再重要。
让我们看看如何为计算目的创建一个 `Task`。
Task<double> SimulationAsync()
{
//Create a new task with a lambda expression
var task = new Task<double>(() =>
{
Random r = new Random();
double sum = 0.0;
for (int i = 0; i < 10000000; i++)
{
if (r.NextDouble() < 0.33)
sum += Math.Exp(-sum) + r.NextDouble();
else
sum -= Math.Exp(-sum) + r.NextDouble();
}
return sum;
});
//Start it and return it
task.Start();
return task;
}
没有强制要求,但是通常的约定是返回所谓的热任务,即我们只想返回已运行的任务。现在我们已经运行了这段代码,我们可以做一些事情。
var sim = SimulationAsync();
var res = sim.Result;//Blocks the current execution until the result is available
sim.ContinueWith(task =>
{
//use task.Result here!
}, TaskScheduler.FromCurrentSynchronizationContext()); //Continues with the given lambda
//from the current context
我们也可以生成多个模拟并使用第一个完成的。
var sim1 = SimulationAsync();
var sim2 = SimulationAsync();
var sim3 = SimulationAsync();
var firstTask = Task.WhenAny(sim1, sim2, sim3);//This creates another task! (callback)
firstTask.ContinueWith(task =>
{
//use task.Result here, which will the result of the first task that finished
//task is the first task that reached the end
}, TaskScheduler.FromCurrentSynchronizationContext());
不幸的是,并非所有功能都可以在本教程中涵盖。然而,有一项功能我们必须进行更多分析,那就是继续执行任务的可能性。原则上,这样的延续可以解决我们所有的问题。
Awaiting Async Methods(等待异步方法)
在最新版本的 C#(称为 C# 5)中,引入了两个新关键字:`await` 和 `async`。通过使用 `async`,我们将方法标记为异步,即方法的返回结果将被打包在 `Task`(如果没有任何返回)或 `Task<T>`(如果方法的返回类型是 `T`)中。因此,以下两个方法。
void DoSomething()
{
}
int GiveMeSomething()
{
return 0;
}
将转换为。
Task async DoSomethingAsync()
{
}
Task<int> async GiveMeSomethingAsync()
{
return 0;
}
名称的结尾也已更改,但这只是一个有用的约定,并非必需。将方法内部转换为 `Task` 的一个非常有用之处在于,它可以通过另一个 `Task` 实例继续执行。现在,如果我们没有另一个自动解决此延续步骤的关键字,我们在这里所做的一切工作都将毫无意义。这个关键字叫做 `await`。它只能在 `async` 标记的方法中使用,因为只有这些方法才会被编译器更改。此时,再次强调 `Task` 不是 `Thread`,即我们在这里没有说明生成新线程或使用哪些资源。
其目的是编写(看起来像)顺序代码,但同时并发运行。每个 `async` 标记的方法总是从当前线程进入,直到 `await` 语句触发新的代码执行,可能在另一个线程中(但不必是 - 请参见:IO 操作)。无论发生什么,UI 在此期间都保持响应,因为方法的其余部分已被转换为此触发 `Task` 的延续。其图示如下。
async Task ReadFromNetwork(string url)
{
//Create a HttpClient for webrequests
HttpClient client = new HttpClient();
//Do some UI (we are still in the GUI thread)
label1.Text = "Requesting the data . . .";
var sw = Stopwatch.StartNew();
//Wait for the result with a continuation
var result = await client.GetAsync(url);
//Continue on the GUI thread no matter what thread has been used in the task
sw.Stop();
label1.Text = "Finished in " + sw.ElapsedMilliseconds + ".";
}
最大的优点是代码的可读性非常接近顺序代码,同时保持响应性并并发运行。任务完成后,方法将恢复执行(通常我们可能希望在这些部分进行一些 UI 相关修改)。
我们还可以用图表来表达这个图(谁会想到!)。
这本身已经很方便了,但它变得更好。直到此时,我们才能通过编写更多代码轻松解决这个问题。所以,请等待:如果我们仍然想使用 `try`-`catch` 来处理异常怎么办?使用 `ContinueWith` 方法在这种情况下不太好用。我们将不得不使用一种不同的模式来捕获异常。当然,这将更复杂,并且会产生更多的代码行。
async Task ReadFromNetwork(string url)
{
/* Same stuff as before */
try
{
await client.GetAsync(url);
}
catch
{
label1.Text = "Request failed!";
return;
}
finally
{
sw.Stop();
}
label1.Text = "Finished in " + sw.ElapsedMilliseconds + ".";
}
这一切都可以正常工作,而且非常接近顺序代码,以至于没有人应该再对无响应的应用程序找借口了。即使是旧的遗留方法也很容易包装到 `Task` 中。假设有一个计算成本高昂的方法,它否则将在 `Thread` 中运行,但是,它太复杂了。现在我们可以这样做。
Task WrappedLegacyMethod()
{
return Task.Run(MyOldLegacyMethod);
}
这被称为async-over-sync。反之亦然,即sync-over-async。在这里,我们只需省略 `await` 并调用 `Result` 属性,正如我们之前所见。
在思考等待任务时,需要记住哪些事情?
- `async void` 应仅与事件处理程序一起使用。不返回 `Task` 是一种反模式,它之所以成为可能,只是为了允许在事件处理程序中使用 `await`,因为事件处理程序的签名是固定的。返回 `void` 是一种“即发即弃”机制,它不会触发 `try`-`catch` 块中的异常,通常会导致错误行为。
- 在 UI 上使用 sync-over-async 与一个会切换到 UI 的 `async` 函数将导致死锁,即 UI 已死。这一点对于希望使用 `async` 标记方法开发 API 的人来说非常重要。由于 API 将独立于 UI,因此上下文切换是不必要的,并且是潜在的风险因素。通过在等待的任务上调用 `ConfigureAwait(false)` 来避免它。
- 生成过多的(并行)任务会导致巨大的开销。
- 使用 lambda 表达式时,最好检查您是否实际上返回了 `Task`。
那么这一节的要点是什么?C# 使编写健壮的(异步)响应式应用程序几乎与编写经典的顺序(大多不响应)应用程序一样容易。
Outlook(展望)
这结束了本教程系列的第三部分。在下一部分中,我们将研究 C# 中强大但鲜为人知(或使用)的功能。我们将研究如何轻松构造 `IEnumerable<T>` 实例,以及协变和逆变是什么意思以及如何使用它们。此外,我们将仔细研究属性以及本机代码(例如 C 或 C++)与 C# 形式的托管代码之间的互操作。
下一篇教程的另一个重点将放在更高效的代码和更简洁的代码上,例如,使用优雅的基于编译器的属性来获取有关源的信息。
Other Articles in this Series(本系列其他文章)
- Lecture Notes Part 1 of 4 - An Advanced Introduction to C#(讲义 第一部分 - C# 高级入门)
- Lecture Notes Part 2 of 4 - Mastering C#(讲义 第二部分 - C# 精通)
- Lecture Notes Part 3 of 4 - Advanced Programming with C#(讲义 第三部分 - C# 高级编程)
- Lecture Notes Part 4 of 4 - Professional Techniques for C#(讲义 第四部分 - C# 专业技术)
参考文献
- 事件
- Reflection(反射)
- Dynamic typing(动态类型)
- 流
- The file system(文件系统)
- 线程
- Thread synchronization(线程同步)
- Thread communication(线程通信)
- Threadpool(线程池)
- Tasks(任务)
- The Task Parallel Library(任务并行库)
- Threads and Tasks(线程和任务)
- Await and async(await 和 async)
- 编码
历史
- v1.0.0 | Initial Release | 2016年4月20日
- v1.0.1 | Added article list | 2016年4月21日
- v1.1.0 | Thanks to Kenneth for mentioning the Reactive Extensions | 2016年4月22日
- v1.2.0 | Thanks to irneb for pointing out the problem with "dynamic programming" | 2016年4月23日
- v1.3.0 | Updated structure w. anchors | 2016年4月25日
- v1.3.1 | Added Table of Contents | 2016年4月29日
- v1.4.0 | Thanks to Christian Andritzky for the example improvement | 2016年5月10日