C-12-技术手册-九-

93 阅读1小时+

C#12 技术手册(九)

原文:zh.annas-archive.org/md5/e2c84fd09097e50aedbc4e5989f32a85

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:处理与垃圾回收

一些对象需要显式的撤销代码来释放资源,例如打开的文件、锁、操作系统句柄和非托管对象。在.NET 术语中,这称为 处理,通过 IDisposable 接口支持。未使用对象占用的托管内存也必须在某个时候被回收;这个功能称为 垃圾回收,由 CLR 执行。

处理与垃圾回收的区别在于处理通常是显式启动的;垃圾回收则完全自动化。换句话说,程序员负责释放文件句柄、锁定和操作系统资源,而 CLR 负责释放内存。

本章讨论了处理和垃圾回收,还描述了 C#终结器及其提供处理备用的模式。最后,我们讨论了垃圾收集器的复杂性和其他内存管理选项。

IDisposable、Dispose 和 Close

.NET 为需要撤销方法的类型定义了一个特殊接口:

public interface IDisposable
{
  void Dispose();
}

C#的 using 语句为实现 IDisposable 的对象调用 Dispose 提供了一种语法快捷方式,使用 try/finally 块:

using (FileStream fs = new FileStream ("myFile.txt", FileMode.Open))
{
  // ... Write to the file ...
}

编译器将其转换为以下内容:

FileStream fs = new FileStream ("myFile.txt", FileMode.Open);
try
{
  // ... Write to the file ...
}
finally
{
  if (fs != null) ((IDisposable)fs).Dispose();
}

finally 块确保在抛出异常或提前退出代码块时仍调用 Dispose 方法。

同样,以下语法确保在 fs 超出范围时即时处理:

using FileStream fs = new FileStream ("myFile.txt", FileMode.Open);

// ... Write to the file ...

在简单场景中,编写自己的可处理类型只是实现 IDisposable 和编写 Dispose 方法的问题:

sealed class Demo : IDisposable
{
  public void Dispose()
  {
    // Perform cleanup / tear-down.
    ...
  }
}
注意

这种模式在简单情况下效果很好,并且适用于密封类。在“从终结器中调用 Dispose”中,我们描述了一种更复杂的模式,可以为忘记调用 Dispose 的消费者提供备用。对于未密封的类型,有理由从一开始就遵循后一种模式 —— 否则,如果子类型希望添加这样的功能,情况会变得非常混乱。

标准处理语义

.NET 遵循一套事实上的处理逻辑规则。这些规则与.NET 或 C#语言没有任何硬连接;它们的目的是为消费者定义一致的协议。以下是它们:

  1. 一旦对象已处理,就无法挽救。它无法重新激活,并且调用其方法或属性(除了 Dispose )会抛出 ObjectDisposedException

  2. 反复调用对象的 Dispose 方法不会导致错误。

  3. 如果一次性对象 x “拥有”一次性对象 y,则 xDispose 方法会自动调用 yDispose 方法 —— 除非另有指示。

在编写自己的类型时,这些规则也很有帮助,尽管它们不是强制性的。除了可能会因此而受到同事的反对外,没有什么可以阻止您编写“取消处理”方法!

根据第三条规则,容器对象会自动处理其子对象的释放。一个很好的例子是 Windows Forms 的容器控件,比如FormPanel。容器可以承载许多子控件,但你不需要显式地释放每一个;关闭或释放父控件或窗体会照顾好所有的子控件。另一个例子是当你用DeflateStream包装一个FileStream时。释放DeflateStream也会释放FileStream——除非你在构造函数中另有指示。

关闭和停止

一些类型额外定义了一个叫做Close的方法,除了Dispose。.NET BCL 在Close方法的语义上并不完全一致,尽管在几乎所有情况下,它要么是以下两者之一:

  • 功能上与Dispose相同

  • Dispose的一个功能 子集

后者的一个例子是IDbConnection:一个Closed的连接可以重新打开;一个Dispose的连接不能。另一个例子是使用ShowDialog激活的 Windows FormClose隐藏它;Dispose释放其资源。

一些类定义了一个Stop方法(例如TimerHttpListener)。Stop方法可能释放非托管资源,像Dispose一样,但与Dispose不同的是,它允许重新启动

何时进行释放

在几乎所有情况下,一个安全的规则是“有疑问就释放”。封装了非托管资源句柄的对象几乎总是需要释放才能释放该句柄。例如文件或网络流、网络套接字、Windows Forms 控件、GDI+的笔、画刷和位图。相反,如果一个类型是可释放的,它通常(但并不总是)会直接或间接地引用一个非托管句柄。这是因为非托管句柄为对象可以在未正确释放时在外部“世界”(如 OS 资源、网络连接和数据库锁)造成麻烦提供了入口。

然而,有三种情况不释放:

  • 当你不“拥有”该对象时——例如通过静态字段或属性获取共享对象时

  • 当对象的Dispose方法执行了你不想要的操作时

  • 当对象的Dispose方法在设计上是不必要的,并且释放该对象会给你的程序增加复杂性时

第一类别很少见。主要情况出现在System.Drawing命名空间中:通过静态字段或属性获取的 GDI+对象(例如Brushes.Blue)绝不能被释放,因为同一个实例在应用程序的整个生命周期内都在使用。然而通过构造函数获取的实例(例如new SolidBrush应该被释放,像通过静态方法获取的实例(例如Font.FromHdc)也应该被释放。

第二类别更为常见。在System.IOSystem.Data命名空间中有一些很好的例子:

类型处置函数何时不释放
MemoryStream防止进一步的 I/O当你以后需要读写流时
StreamReader, StreamWriter刷新读取器/写入器并关闭底层流当你想保持底层流打开时(然后在完成后必须调用 FlushStreamWriter 上)
IDbConnection释放数据库连接并清除连接字符串如果需要重新Open它,应该调用 Close 而不是 Dispose
DbContext(EF Core)防止进一步使用当可能有延迟评估查询连接到该上下文时

MemoryStreamDispose 方法只禁用对象本身;它不执行任何关键的清理,因为 MemoryStream 不持有未托管的句柄或其他类似资源。

第三类包括诸如 StringReaderStringWriter 的类。这些类型是在其基类的压力下而不是通过真正需要执行基本清理时才能释放的。如果您恰好在一个方法中实例化和使用这样的对象,将其包装在 using 块中几乎没有什么不便。但是如果对象的寿命较长,跟踪其何时不再使用以便及时处置会增加不必要的复杂性。在这种情况下,可以简单地忽略对象的处置。

注意

忽略处理有时会导致性能成本(见“从终结器调用 Dispose”)。

清除处置中的字段

通常情况下,您不需要在对象的 Dispose 方法中清除对象的字段。然而,从对象在其生命周期内内部订阅的事件中取消订阅是一种良好的实践(例如,请参阅“托管内存泄漏”)。取消订阅这些事件可以防止接收到不需要的事件通知,并防止在垃圾回收器(GC)眼中无意中保持对象活动。

注意

Dispose 方法本身不会释放(托管)内存 —— 这只能通过垃圾回收(GC)来实现。

还值得一提的是,设置一个字段来指示对象已处置是很有必要的,这样如果消费者后来试图调用对象的成员,就可以抛出 ObjectDisposedException。一个好的模式是使用一个公共可读的自动属性来实现这一点:

public bool IsDisposed { get; private set; }

尽管在技术上不是必需的,但在 Dispose 方法中清除对象自身的事件处理程序(将它们设置为 null)也是一个好习惯。这样做可以消除在或之后事件触发的可能性。

偶尔,一个对象可能包含高价值的秘密,比如加密密钥。在这些情况下,在处理期间清除这些字段数据是有意义的(以避免当内存稍后释放到操作系统时,其他进程在机器上可能发现这些数据)。System​.Secu⁠rity.Cryptography 中的 SymmetricAlgorithm 类正是通过在保存加密密钥的字节数组上调用 Array.Clear 来做到这一点。

匿名处理

有时,实现IDisposable是有用的,而不必编写一个类。例如,假设您希望在一个类上公开暂停和恢复事件处理的方法:

class Foo
{
  int _suspendCount;

  public void SuspendEvents() => _suspendCount++;           
  public void ResumeEvents() => _suspendCount--;            

  void FireSomeEvent()
  {
    if (_suspendCount == 0)
      ... fire some event ...
  }
  ...
}

这样的 API 使用起来很笨拙。消费者必须记住调用ResumeEvents。并且为了健壮性,他们必须在finally块中执行此操作(以防抛出异常):

var foo = new Foo();
foo.SuspendEvents();
try
{
  ... do stuff ...      // Because an exception could be thrown here
}
finally
{
  foo.ResumeEvents();   // ...we must call this in a finally block
}

更好的模式是放弃ResumeEvents,而是让SuspendEvents返回一个IDisposable。消费者可以这样做:

using (foo.SuspendEvents())
{
  ... do stuff ...
}

这个问题是,这会将工作推给需要实现Suspend​Events方法的人。即使努力减少空白字符,我们最终还是会有额外的混乱:

public IDisposable SuspendEvents()
{
  _suspendCount++;
  return new SuspendToken (this);
}

class SuspendToken : IDisposable 
{
  Foo _foo;          
  public SuspendToken (Foo foo) => _foo = foo;
  public void Dispose()
  {
    if (_foo != null) _foo._suspendCount--;
    _foo = null;  // Prevent against consumer disposing twice
  }
}

匿名释放模式解决了这个问题。使用以下可重用类:

public class Disposable : IDisposable
{
  public static Disposable Create (Action onDispose)
    => new Disposable (onDispose);

  Action _onDispose;
  Disposable (Action onDispose) => _onDispose = onDispose;

  public void Dispose()
  {
    _onDispose?.Invoke();   // Execute disposal action if non-null.
    _onDispose = null;      // Ensure it can’t execute a second time.
  }
}

我们可以将我们的SuspendEvents方法简化为以下内容:

public IDisposable SuspendEvents()
{
  _suspendCount++;
  return Disposable.Create (() => _suspendCount--);
}  

自动垃圾收集

无论一个对象是否需要一个Dispose方法来进行自定义的拆卸逻辑,其在堆上占用的内存在某个时刻都必须被释放。CLR 完全自动地通过自动 GC 处理这一方面。您不需要自己释放托管内存。例如,考虑以下方法:

public void Test()
{
  byte[] myArray = new byte[1000];
  ...
}

Test执行时,会在内存堆上分配一个用于容纳 1000 字节的数组。该数组由存储在本地变量堆栈上的变量myArray引用。当方法退出时,此局部变量myArray超出作用域,意味着没有任何东西引用内存堆上的数组。然后,孤立的数组变得符合垃圾收集的条件。

注意

在调试模式下,关闭优化时,局部变量引用的对象的生命周期会延长到代码块的末尾,以便于调试。否则,在对象不再使用时,它会尽早成为可收集的对象。

对象孤立后,并不会立即进行垃圾收集。就像街上的垃圾收集一样,它是周期性进行的,尽管(不像街上的垃圾收集)没有固定的时间表。CLR 基于多种因素来决定何时进行收集,例如可用内存、内存分配量以及上次收集后的时间(GC 自动调整以优化应用程序特定的内存访问模式)。这意味着对象孤立和从内存中释放之间存在不确定的延迟。这种延迟可以从纳秒到几天不等。

注意

GC 并不会在每次收集时收集所有垃圾。相反,内存管理器将对象分为,并且 GC 更频繁地收集新代(最近分配的对象),而不是老代(长期存在的对象)。我们将在“GC 工作原理”中详细讨论这个问题。

是使对象保持活动的东西。如果一个对象不被直接或间接地根引用,它将符合垃圾收集的条件。

根是以下之一:

  • 在执行方法中的局部变量或参数(或者在其调用堆栈中的任何方法)

  • 静态变量

  • 放在存储准备终结对象的队列上的对象(请参阅下一节)

已删除对象不可能执行代码,因此如果有任何实例方法可能执行,它的对象必须以某种方式通过引用。

请注意,循环引用的一组对象如果没有根引用(参见图 12-1),被视为已死亡。换句话说,无法通过从根对象跟随箭头(引用)访问的对象是不可达的,因此可能会被收集。

Roots

图 12-1. Roots

Finalizer

在释放对象之前,如果对象有 finalizer,则会运行它。Finalizer 的声明类似于构造函数,但前面加上 ˜ 符号:

class Test
{
  ˜Test()
  {
    // Finalizer logic...
  }
}

(尽管在声明上与构造函数相似,finalizer 不能声明为公共或静态,不能有参数,并且不能调用基类。)

Finalizer 可能存在是因为垃圾回收工作在不同阶段。首先,GC 会识别出可以删除的未使用对象。那些没有 finalizer 的对象会立即删除。那些有待(未运行)finalizer 的对象会被保持活跃(暂时),并放入特殊队列中。

在那一刻,垃圾收集完成,你的程序继续执行。然后finalizer 线程开始并行运行,从特殊队列中拿出对象并运行它们的 finalization 方法。在每个对象的 finalizer 运行之前,它仍然是非常活跃的——那个队列充当了一个根对象。当它被出队并执行了 finalizer 后,该对象变成了孤儿,并将在下次收集(针对该对象的)中被删除。

Finalizer 可能很有用,但有一些注意事项:

  • Finalizer 会减慢内存的分配和回收(GC 需要跟踪哪些 finalizer 已经运行)。

  • Finalizer 会延长对象及其引用对象的生命周期(它们都必须等待下一次垃圾收集以进行实际删除)。

  • 不可能预测一组对象的 finalizer 将以何种顺序被调用。

  • 对于对象的 finalizer 何时被调用,你的控制能力有限。

  • 如果 finalizer 中的代码阻塞,其他对象将无法被终结。

  • 如果应用程序无法干净卸载,可以完全避开 finalizer。

总之,finalizer 类似于律师——虽然有些情况确实需要它们,但通常情况下你不希望使用它们,除非绝对必要。如果你确实要使用它们,你需要百分之百地理解它们为你做了什么。

这里有一些实现 finalizer 的指导原则:

  • 确保你的 finalizer 快速执行。

  • 不要在你的 finalizer 中阻塞(参见“阻塞”)。

  • 不要引用其他可终结的对象。

  • 不要抛出异常。

注意

即使在构造函数期间抛出异常,CLR 也可以调用对象的终结器。因此,在编写终结器时,不要假设字段已经正确初始化。

从终结器中调用 Dispose

一个常见的模式是在终结器中调用 Dispose。当清理不紧急并且通过调用 Dispose 加快清理过程更多是一种优化而不是必要时,这是有道理的。

注意

请记住,使用此模式将内存释放与资源释放耦合在一起 —— 这两者可能有潜在的分歧(除非资源本身是内存)。同时也增加了终结线程的负担。

这种模式也作为一种备份用例存在,用于消费者简单地忘记调用 Dispose 的情况。但在这种情况下,最好记录失败,以便修复该错误。

实现这一点的标准模式如下所示:

class Test : IDisposable
{
  public void Dispose()             // NOT virtual
  {
    Dispose (true);
    GC.SuppressFinalize (this);     // Prevent finalizer from running.
  }

  protected virtual void Dispose (bool disposing)
  {
    if (disposing)
    {
      // Call Dispose() on other objects owned by this instance.
      // You can reference other finalizable objects here.
      // ...
    }

    // Release unmanaged resources owned by (just) this object.
    // ...
  }

  ~Test() => Dispose (false);
}

Dispose 方法被重载以接受 bool disposing 标志。无参数版本未声明为 virtual,并且简单地调用带有 true 参数的增强版本。

增强版本包含实际的处理逻辑,并且是 protectedvirtual 的;这为子类添加其自己的处理逻辑提供了一个安全点。disposing 标志意味着它从 Dispose 方法中被“适当地”调用,而不是从终结器的“最后手段模式”中调用。其思想是,当以 disposing 设置为 false 调用时,该方法通常不应引用其他具有终结器的对象(因为这些对象可能已被终结,因此处于不可预测的状态)。这排除了很多情况!以下是 Dispose 方法在最后手段模式下仍然可以执行的几个任务:

  • 释放所有 直接引用 的操作系统资源(可能通过调用 Win32 API 的 P/Invoke 调用获取)

  • 删除在构造过程中创建的临时文件

为了使其更加健壮,任何可能抛出异常的代码都应包装在 try/catch 块中,并最好记录异常。任何日志记录应尽可能简单和健壮。

注意,在无参数的 Dispose 方法中调用了 GC.SuppressFinalize —— 这可以阻止垃圾回收稍后运行终结器。从技术上讲,这是不必要的,因为 Dispose 方法必须能够容忍重复调用。然而,这样做可以提高性能,因为它允许对象(及其引用的对象)在单个周期内被垃圾回收。

复活

假设一个终结器修改一个存活对象,使其引用回即将死亡的对象。当下次垃圾回收发生时(针对对象的代),CLR 将不再将先前的即将死亡对象视为孤立的 —— 因此它将逃避垃圾回收。这是一个高级场景,称为 复活

举例说明,假设我们想编写一个管理临时文件的类。当该类的实例被垃圾收集时,我们希望终结器删除临时文件。听起来很容易:

public class TempFileRef
{
  public readonly string FilePath;
  public TempFileRef (string filePath) { FilePath = filePath; }

  ~TempFileRef() { File.Delete (FilePath); }
}

不幸的是,这里有一个 bug:File.Delete可能会抛出异常(例如,由于缺少权限,文件正在使用或已经被删除)。这样的异常会导致整个应用程序崩溃(并阻止其他终结器运行)。我们可以简单地通过空的 catch 块“吞噬”异常,但这样我们就不会知道出了什么问题。调用某些复杂的错误报告 API 也不可取,因为它会负担终结器线程,从而阻碍其他对象的垃圾收集。我们希望将最终化操作限制为简单、可靠和快速的操作。

更好的选择是将失败记录到静态集合中,如下所示:

public class TempFileRef
{
  static internal readonly ConcurrentQueue<TempFileRef> FailedDeletions
    = new ConcurrentQueue<TempFileRef>();

  public readonly string FilePath;
  public Exception DeletionError { get; private set; }

  public TempFileRef (string filePath) { FilePath = filePath; }

  ~TempFileRef()
  {
    try { File.Delete (FilePath); }
    catch (Exception ex)
    {
      DeletionError = ex;
      FailedDeletions.Enqueue (this);   // Resurrection
    }
  }
}

将对象加入静态FailedDeletions集合可以为对象再次提供一个引用,确保对象直到最终出列之前一直存活。

注意

ConcurrentQueue<T>Queue<T>的线程安全版本,并且定义在System.Collections.Concurrent中(参见第二十二章)。使用线程安全集合有几个原因。首先,CLR 保留在多个线程并行执行终结器的权利。这意味着当访问共享状态(如静态集合)时,我们必须考虑同时终结两个对象的可能性。其次,我们迟早要从FailedDeletions中出列项目,以便我们可以采取措施处理它们。这也必须以线程安全的方式进行,因为它可能在终结器同时将另一个对象入队时发生。

GC.ReRegisterForFinalize

复活对象的终结器将不会第二次运行,除非您调用GC.ReRegisterForFinalize

在下面的示例中,我们尝试在终结器中删除临时文件(与最后一个示例中一样)。但是,如果删除失败,我们会重新注册对象,以便在下次垃圾收集时再次尝试:

public class TempFileRef
{
  public readonly string FilePath;
  int _deleteAttempt;

  public TempFileRef (string filePath) { FilePath = filePath; }

  ~TempFileRef()
  {
    try { File.Delete (FilePath); }
    catch
    {
      if (_deleteAttempt++ < 3) GC.ReRegisterForFinalize (this);
    }
  }
}

第三次失败尝试后,我们的终结器将悄然放弃删除文件的尝试。我们可以通过将其与前面的示例结合起来来增强这一点,换句话说,在第三次失败后将其添加到FailedDeletions队列中。

警告

要小心在终结器方法中仅调用ReRegisterForFinalize一次。如果调用两次,对象将重新注册两次,并且将必须进行两次终结!

GC 的工作原理

标准 CLR 使用一种分代标记-压缩的 GC,为存储在托管堆上的对象执行自动内存管理。GC 被认为是一种跟踪 GC,因为它不会干扰对对象的每次访问,而是间歇性地唤醒并跟踪存储在托管堆上的对象图,以确定哪些对象可以被视为垃圾,因此可以被收集。

GC 在执行内存分配(通过 new 关键字)时启动垃圾收集,可能在分配了一定内存阈值后或其他时间以减少应用程序的内存占用。还可以通过调用 System.GC.Collect 方法手动启动此过程。在垃圾收集期间,所有线程都可以被冻结(更多信息见下一节)。

GC 从其根对象引用开始,并遍历对象图,标记所有接触到的对象为可达对象。当此过程完成时,所有未标记的对象被视为未使用的对象,并且可以进行垃圾收集。

没有 finalizer 的未使用对象会立即丢弃;带有 finalizer 的未使用对象会在 GC 完成后被加入到最终器线程的处理队列中。然后,这些对象在下一个 GC 中成为其代的可收集对象(除非被复活)。

剩余的“存活”对象然后被移动到堆的开始处(压缩),为更多对象释放空间。这种压缩有两个目的:防止内存碎片化,并允许 GC 在分配新对象时采用非常简单的策略,即始终在堆的末尾分配内存。这避免了维护空闲内存段列表可能耗时的任务。

如果在垃圾收集后没有足够的空间来分配新对象的内存,并且操作系统无法再分配更多内存,则会抛出 OutOfMemoryException 异常。

注意

您可以通过调用 GC.GetGCMemoryInfo() 获取有关托管堆当前状态的信息。从 .NET 5 开始,此方法已增强以返回与性能相关的数据。

优化技术

GC 结合了各种优化技术来减少垃圾收集时间。

分代收集

最重要的优化是 GC 是分代的。这利用了这样一个事实,即虽然许多对象被快速分配和丢弃,但某些对象具有长寿命,因此不需要在每次收集期间进行跟踪。

基本上,GC 将托管堆分为三代。刚刚分配的对象位于 Gen0,经过一次收集周期后存活的对象位于 Gen1;所有其他对象位于 Gen2。Gen0 和 Gen1 被称为短暂(短寿)代。

CLR 将 Gen0 部分保持相对较小(典型大小为几百 KB 到几 MB)。当 Gen0 部分填满时,GC 会发起 Gen0 集合—这种情况相对频繁发生。GC 对 Gen1 也应用类似的内存阈值(作为 Gen2 的缓冲区),因此 Gen1 集合也相对快速和频繁。然而,包括 Gen2 的完整集合需要更长时间,因此不经常发生。图 12-2 显示了完整集合的效果。

堆代

图 12-2. 堆代

给出一些非常粗略的估算,Gen0 集合可能不到 1 毫秒,这在典型应用程序中不足以被注意到。然而,对于具有大对象图的程序,完整集合可能需要长达 100 毫秒。这些数字取决于许多因素,因此可能会有很大变化—尤其是对于大小不受限制(与 Gen0 和 Gen1 不同)的 Gen2 而言。

要点是,短寿命对象在 GC 的使用效率方面非常高。在以下方法中创建的StringBuilder几乎可以肯定会在快速的 Gen0 集合中被回收:

string Foo()
{
  var sb1 = new StringBuilder ("test");
  sb1.Append ("...");
  var sb2 = new StringBuilder ("test");
  sb2.Append (sb1.ToString());
  return sb2.ToString();
}

大对象堆

GC 使用称为大对象堆(LOH)的单独堆来存储大于某个阈值(目前为 85,000 字节)的对象。这可以避免大对象的压缩成本,并防止过多的 Gen0 集合—没有 LOH 的话,分配一系列 16 MB 对象可能会在每次分配后触发 Gen0 集合。

默认情况下,LOH 不会经过压缩,因为在垃圾收集期间移动大内存块将是极其昂贵的。这有两个后果:

  • 分配可能会更慢,因为 GC 不能总是简单地在堆的末尾分配对象—它还必须在中间寻找间隙,这需要维护一个空闲内存块的链表。¹

  • LOH 会受到碎片化的影响。这意味着释放对象可能会在 LOH 中留下一个难以填补的空洞。例如,由 86,000 字节对象留下的空洞只能通过 85,000 字节到 86,000 字节之间的对象填补(除非与另一个空洞相邻)。

如果您预计会出现碎片问题,可以指示 GC 在下次收集时压缩 LOH,方法如下:

GCSettings.LargeObjectHeapCompactionMode =
  GCLargeObjectHeapCompactionMode.CompactOnce;

如果您的程序频繁分配大数组,另一个解决方法是使用.NET 的数组池 API(参见“数组池”)。

LOH 也是非代数的:所有对象都被视为 Gen2。

工作站与服务器集合

.NET 提供了两种垃圾收集模式:工作站服务器工作站是默认值;您可以通过在应用程序的*.csproj文件中添加以下内容来切换到服务器*:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

在构建项目时,这些设置会被写入应用程序的*.runtime​con⁠fig.json*文件中,CLR 会从中读取:

  "runtimeOptions": {
    "configProperties": {
      "System.GC.Server": true
    ...

启用服务器收集后,CLR 为每个核分配单独的堆和 GC。这加速了收集过程,但消耗了额外的内存和 CPU 资源(因为每个核需要自己的线程)。如果机器上运行了许多其他启用了服务器收集的进程,这可能导致 CPU 过度订阅,特别是在工作站上,这会使整个操作系统感觉不响应。

服务器收集仅在多核系统上可用:在单核设备(或单核虚拟机)上,该设置将被忽略。

后台收集

在工作站模式和服务器模式中,CLR 默认启用后台收集。您可以通过将以下内容添加到应用程序的*.csproj*文件中来禁用它:

<PropertyGroup>
  <ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
</PropertyGroup>

在构建时,此设置被写入应用程序的*.runtimeconfig.json*文件:

  "runtimeOptions": {
    "configProperties": {
      "System.GC.Concurrent": false,
   ...

GC 必须在收集期间冻结(阻塞)执行线程。后台收集减少了这些延迟期间的时间,使您的应用程序更具响应性。但这是以稍微增加 CPU 和内存消耗为代价的。因此,通过禁用后台收集,您可以实现以下效果:

  • 稍微减少 CPU 和内存使用

  • 增加垃圾收集发生时的暂停(或延迟

背景收集通过允许应用程序代码与 Gen2 收集并行运行来工作。(Gen0 和 Gen1 收集被认为足够快速,不会从这种并行性中受益。)

背景收集是以前称为并发收集的改进版本:它消除了并发收集的限制,即如果 Gen0 区段在 Gen2 收集运行时填满,那么并发收集将停止并发。这使得持续分配内存的应用程序能够更具响应性。

GC 通知

如果禁用后台收集,您可以要求 GC 在进行全(阻塞)收集之前通知您。这适用于服务器农场配置:其想法是您在收集发生之前将请求重定向到另一台服务器。然后立即启动收集并等待其完成,然后再将请求重新路由回该服务器。

要启动通知,请调用GC.RegisterForFullGCNotification。然后,启动另一个线程(参见第十四章),首先调用GC.WaitForFullGCApproach。当此方法返回指示接近收集的GCNotificationStatus时,您可以将请求重新路由到其他服务器并强制手动收集(参见下一节)。然后调用GC.WaitForFullGCComplete:当此方法返回时,收集完成,您可以再次接受请求。然后重复整个周期。

强制垃圾收集

您可以随时通过调用GC.Collect手动强制进行垃圾收集。调用GC.Collect而不带参数会启动完整收集。如果传递一个整数值,只会收集到该值的代数,因此GC.Collect(0)仅执行快速的 Gen0 收集。

通常情况下,通过允许 GC 自行决定何时收集来获得最佳性能:强制收集可能会通过不必要地将 Gen0 对象提升为 Gen1(以及 Gen1 对象提升为 Gen2)来损害性能。它还可能会扰乱 GC 的自我调整能力,即 GC 在应用程序执行时动态调整每代的阈值以最大化性能。

然而,也存在例外情况。干预最常见的情况是应用程序暂时休眠:一个很好的例子是执行每日活动的 Windows 服务(例如检查更新)。这样的应用程序可能会使用System.Timers.Timer每 24 小时启动一次活动。完成活动后,接下来的 24 小时内不会执行进一步的代码,这意味着在此期间不会进行内存分配,因此垃圾回收器无法激活。服务在执行其活动时消耗的内存将继续在接下来的 24 小时内保持不变——即使对象图为空!解决方法是在每日活动完成后立即调用GC.Collect

为确保收集那些由于终结器延迟而延迟收集的对象,请额外调用WaitForPendingFinalizers并重新收集:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

通常情况下,这是通过循环完成的:运行终结器的操作可能会释放更多本身具有终结器的对象。

调用GC.Collect的另一个情况是测试具有终结器的类时。

在运行时调整垃圾回收

静态GCSettings.LatencyMode属性确定 GC 如何在延迟和整体效率之间进行平衡。将其从默认值Interactive更改为LowLatencySustainedLowLatency指示 CLR 偏向更快(但更频繁)的收集。如果您的应用程序需要对实时事件非常快速响应,则这是有用的。将模式更改为Batch以牺牲响应速度最大化吞吐量,这对于批处理处理非常有用。

如果在*.runtimeconfig.json*文件中禁用后台收集,则不支持SustainedLowLatency

您还可以通过调用GC.TryStartNoGCRegion暂时暂停垃圾收集器,并通过GC.EndNoGCRegion恢复。

内存压力

运行时根据多种因素(包括机器上的总内存负载)决定何时启动集合。如果您的程序分配了非托管内存(第二十四章),运行时将对其内存使用有一个不切实际的乐观看法,因为 CLR 仅了解托管内存。您可以通过调用GC.AddMemory​Pres⁠sure指示 CLR假设分配了指定数量的非托管内存来缓解这一问题。在释放非托管内存时,调用GC.Remove​Memor⁠yPressure以撤消此操作。

数组池

如果您的应用程序频繁实例化数组,您可以通过数组池避免大部分垃圾收集开销。数组池在.NET Core 3 中引入,通过“租用”一个数组,然后将其返回到池中以供重复使用。

要分配一个数组,请在System​.Buf⁠fers命名空间中的ArrayPool类上调用Rent方法,指定您想要的数组大小:

int[] pooledArray = ArrayPool<int>.Shared.Rent (100);  // 100 bytes

这将从全局共享数组池中分配至少 100 字节的数组。池管理器可能会提供一个比您请求的更大的数组(通常以 2 的幂分配)。

当您完成数组的使用后,请调用Return:这将释放数组到池中,允许再次租用同一数组:

ArrayPool<int>.Shared.Return (pooledArray);

您可以选择传递一个布尔值,指示池管理器在将数组返回给池之前清除数组。

警告

数组池的一个限制是,在将数组返回后,没有任何机制阻止您继续(非法)使用数组,因此需要小心编码以避免此情况。请记住,您不仅有可能破坏自己的代码,还有可能破坏使用数组池的其他 API,例如 ASP.NET Core。

与使用共享数组池不同,您可以创建一个自定义池并从中租用。这样可以避免破坏其他 API 的风险,但会增加总体内存使用量(因为减少了重用的机会):

var myPool = ArrayPool<int>.Create();
int[] array = myPool.Rent (100);
...

托管内存泄漏

在像 C++这样的非托管语言中,当对象不再需要时,必须记住手动释放内存;否则将导致内存泄漏。在托管世界中,由于 CLR 的自动垃圾回收系统,这种错误是不可能发生的。

尽管如此,大型和复杂的.NET 应用程序可能会展示出同样综合症的较轻形式,结果也相同:应用程序在其生命周期内消耗越来越多的内存,直到最终必须重新启动。好消息是,托管内存泄漏通常更容易诊断和预防。

未管理的内存泄漏是由于未使用的对象通过未使用或遗忘的引用仍然存活造成的。常见的候选对象是事件处理程序——除非目标是静态方法,否则这些处理程序将持有对目标对象的引用。例如,考虑以下类:

class Host
{
  public event EventHandler Click;
}

class Client
{
  Host _host;
  public Client (Host host)
  {
    _host = host;
    _host.Click += HostClicked;
  }

  void HostClicked (object sender, EventArgs e) { ... }
}

下面的测试类包含一个实例化 1,000 个客户端的方法:

class Test
{
  static Host _host = new Host();

  public static void CreateClients()
  {
    Client[] clients = Enumerable.Range (0, 1000)
     .Select (i => new Client (_host))
     .ToArray();

    // Do something with clients ... 
  }
}

你可能期望在 CreateClients 执行完毕后,这 1000 个 Client 对象将变得可以收集。不幸的是,每个客户端还有另一个引用者: _host 对象,其 Click 事件现在引用每个 Client 实例。如果 Click 事件不触发,或者 HostClicked 方法没有做任何吸引注意力的事情,这可能不会被注意到。

解决方法之一是让 Client 实现 IDisposable 并在 Dispose 方法中取消事件处理程序的挂钩:

public void Dispose() { _host.Click -= HostClicked; }

Client 的消费者使用完实例后进行处理:

Array.ForEach (clients, c => c.Dispose());
注意

在 “弱引用” 中,我们描述了另一种解决这个问题的方法,这在不倾向于使用一次性对象的环境中可能很有用(例如 Windows Presentation Foundation [WPF])。事实上,WPF 提供了一个名为 WeakEventManager 的类,使用一种利用弱引用的模式。

定时器

遗忘的定时器也会导致内存泄漏(我们在 第二十一章 中讨论定时器)。这取决于定时器的类型,有两种不同的场景。首先看看 System.Timers 命名空间中的定时器。在以下示例中,当实例化 Foo 类时,它每秒调用一次 tmr_Elapsed 方法:

using System.Timers;

class Foo
{
  Timer _timer;

  Foo() 
  {
    _timer = new System.Timers.Timer { Interval = 1000 };
    _timer.Elapsed += tmr_Elapsed;
    _timer.Start();
  }

  void tmr_Elapsed (object sender, ElapsedEventArgs e) { ... }
}

不幸的是,Foo 的实例永远无法进行垃圾回收!问题在于运行时本身保持对活动定时器的引用,以便触发它们的 Elapsed 事件;因此:

  • 运行时将保持 _timer 的活动状态。

  • 通过 tmr_Elapsed 事件处理程序,_timer 将保持 Foo 实例的活动状态。

当你意识到 Timer 实现了 IDisposable 时,解决方案就显而易见了。释放定时器将停止它,并确保运行时不再引用该对象:

class Foo : IDisposable
{
  ...
  public void Dispose() { _timer.Dispose(); }
}
注意

一个良好的准则是,如果类中的任何字段分配了实现 IDisposable 的对象,则自己实现 IDisposable

关于刚才讨论的内容,WPF 和 Windows Forms 的定时器行为方式相同。

然而,位于 System.Threading 命名空间中的定时器是特殊的。.NET 不会保持对活动线程定时器的引用;相反,它直接引用回调委托。这意味着如果你忘记释放线程定时器,将会触发一个终结器,自动停止和释放定时器:

static void Main()
{
  var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000);
  GC.Collect();
  System.Threading.Thread.Sleep (10000);    // Wait 10 seconds 
}

static void TimerTick (object notUsed) { Console.WriteLine ("tick"); }

如果此示例在“发布”模式下编译(禁用调试并启用优化),则定时器将在其有机会触发一次之前被收集和完成!同样,我们可以在使用完定时器后通过释放来修复这个问题:

using (var tmr = new System.Threading.Timer (TimerTick, null, 1000, 1000))
{
  GC.Collect();
  System.Threading.Thread.Sleep (10000);    // Wait 10 seconds 
}

using 块结束时隐式调用 tmr.Dispose 确保 tmr 变量在块结束之前被“使用”,因此 GC 不会将其视为死对象。讽刺的是,这个 Dispose 调用实际上使对象的生命周期更长!

诊断内存泄漏

避免托管内存泄漏的最简单方法是在编写应用程序时主动监视内存消耗。您可以通过以下方式获取程序对象的当前内存消耗(true 参数告诉 GC 首先执行一次收集):

long memoryUsed = GC.GetTotalMemory (true);

如果您正在实践测试驱动开发,一个可能的方法是使用单元测试来断言内存如预期地被回收。如果这样的断言失败,您只需要检查最近所做的更改。

如果您已经有一个存在托管内存泄漏的大型应用程序,windbg.exe工具可以帮助找到它。还有一些更友好的图形工具,如 Microsoft 的 CLR Profiler、SciTech 的 Memory Profiler 和 Red Gate 的 ANTS Memory Profiler。

CLR 还公开了许多事件计数器来帮助进行资源监视。

弱引用

有时,持有一个对 GC“不可见”的对象的引用是有用的,从而使对象保持活动状态。这称为弱引用,由System.WeakReference类实现。

要使用WeakReference,请使用目标对象构造它:

var sb = new StringBuilder ("this is a test");
var weak = new WeakReference (sb);
Console.WriteLine (weak.Target);     // This is a test

如果目标仅由一个或多个弱引用引用,则 GC 将考虑目标对象可用于收集。当目标对象被收集时,WeakReferenceTarget属性将为 null:

var weak = GetWeakRef();
GC.Collect();
Console.WriteLine (weak.Target);   // (nothing)

WeakReference GetWeakRef () => 
  new WeakReference (new StringBuilder ("weak"));

为了防止目标在测试其是否为 null 和使用它之间被收集,请将目标分配给局部变量:

var sb = (StringBuilder) weak.Target;
if (sb != null) { /* Do something with sb */ }

当将目标分配给局部变量时,它有一个强根,因此在使用该变量时不能收集它。

以下类使用弱引用来跟踪所有已实例化的Widget对象,而不会阻止这些对象被收集:

class Widget
{
  static List<WeakReference> _allWidgets = new List<WeakReference>();

  public readonly string Name;

  public Widget (string name)
  {
    Name = name;
    _allWidgets.Add (new WeakReference (this));
  }

  public static void ListAllWidgets()
  {
    foreach (WeakReference weak in _allWidgets)
    {
      Widget w = (Widget)weak.Target;
      if (w != null) Console.WriteLine (w.Name);
    }
  }
}

在这样的系统中唯一的注意事项是静态列表会随着时间的推移而增长,积累具有空目标的弱引用。因此,您需要实施一些清理策略。

弱引用和缓存

WeakReference的一个用途是缓存大型对象图。这允许将内存密集型数据短暂缓存,而不会导致过多的内存消耗:

_weakCache = new WeakReference (...);   // _weakCache is a field
...
var cache = _weakCache.Target;
if (cache == null) { /* Re-create cache & assign it to _weakCache */ }

在实践中,这种策略可能只能起到轻微的效果,因为您无法控制垃圾收集器何时触发以及选择收集哪一代。特别是,如果您的缓存仍然在 Gen0 中,它可以在微秒内被收集(请记住,垃圾收集器不仅在内存不足时收集——在正常内存条件下它定期进行收集)。因此,至少,您应该采用两级缓存的方法,即首先使用强引用,随着时间的推移将其转换为弱引用。

弱引用和事件

我们之前看到事件如何导致托管内存泄漏。最简单的解决方案是要么避免在这种情况下订阅,要么实现一个Dispose方法来取消订阅。弱引用提供了另一种解决方案。

想象一个仅持有其目标弱引用的委托。这样的委托不会保持其目标的生存——除非这些目标有独立的裁判。当然,这并不能防止一个激活的委托击中一个未引用的目标——在目标符合回收条件并且 GC 赶上之前的时间内。为了使这样的解决方案有效,您的代码必须在这种情况下表现稳健。假设情况如此,您可以按以下方式实现弱委托类:

public class WeakDelegate<TDelegate> where TDelegate : Delegate
{
  class MethodTarget
  {
    public readonly WeakReference Reference;
    public readonly MethodInfo Method;

    public MethodTarget (Delegate d)
    {
      // d.Target will be null for static method targets:
      if (d.Target != null) Reference = new WeakReference (d.Target);
      Method = d.Method;
    }
  }

  List<MethodTarget> _targets = new List<MethodTarget>();

  public void Combine (TDelegate target)
  {
    if (target == null) return;

    foreach (Delegate d in (target as Delegate).GetInvocationList())
      _targets.Add (new MethodTarget (d));
  }

  public void Remove (TDelegate target)
  {
    if (target == null) return;
    foreach (Delegate d in (target as Delegate).GetInvocationList())
    {
      MethodTarget mt = _targets.Find (w => 
        Equals (d.Target, w.Reference?.Target) &&
        Equals (d.Method.MethodHandle, w.Method.MethodHandle));

      if (mt != null) _targets.Remove (mt);
    }
  }

  public TDelegate Target
  {
    get
    {
      Delegate combinedTarget = null;

      foreach (MethodTarget mt in _targets.ToArray())
      {
        WeakReference wr = mt.Reference;

        // Static target || alive instance target
        if (wr == null || wr.Target != null)
        {
          var newDelegate = Delegate.CreateDelegate (
            typeof(TDelegate), wr?.Target, mt.Method);
            combinedTarget = Delegate.Combine (combinedTarget, newDelegate);
        }
        else
          _targets.Remove (mt);
      }

      return combinedTarget as TDelegate;
    }
    set
    {
      _targets.Clear();
      Combine (value);
    }
  }
}

CombineRemove方法中,我们通过as运算符而不是更常见的强制类型转换,执行从targetDelegate的引用转换。这是因为 C#不允许在这种类型的参数上使用强制类型转换操作符——由于自定义转换引用转换之间可能存在的歧义。

然后我们调用GetInvocationList,因为这些方法可能会使用多播委托——即具有多个方法接收者的委托。

Target属性中,我们构建了一个组合所有由活动目标的弱引用引用的委托的多播委托,从列表中移除剩余的(死亡的)引用,以防止_targets列表无限增长。(我们可以通过在Combine方法中执行相同操作来改进我们的类;另一个改进是为了线程安全添加锁 参见[“锁和线程安全”]。)我们还允许没有任何弱引用的委托;这些代表其目标为静态方法的委托。

下面说明了如何在实现事件时消费此委托:

public class Foo
{
  WeakDelegate<EventHandler> _click = new WeakDelegate<EventHandler>();

  public event EventHandler Click
  {
    add { _click.Combine (value); } remove { _click.Remove (value); }
  }

  protected virtual void OnClick (EventArgs e)
    => _click.Target?.Invoke (this, e);
}

¹ 在分代堆中由于固定(参见“fixed 语句”]),同样的情况有时也会发生。

第十三章:诊断

当出现问题时,重要的是有信息可用以帮助诊断问题。集成开发环境(IDE)或调试器可以极大地帮助此过程,但通常仅在开发期间可用。应用程序发布后,必须收集和记录诊断信息。为了满足此要求,.NET 提供了一组设施来记录诊断信息,监视应用程序行为,检测运行时错误,并在可能时与调试工具集成。

一些诊断工具和 API 是特定于 Windows 的,因为它们依赖于 Windows 操作系统的功能。为了防止平台特定的 API 混乱.NET BCL,微软已经将它们封装在单独的 NuGet 包中,您可以选择性地引用它们。有超过一打的特定于 Windows 的包,您可以使用 Microsoft.Windows.Compatibility “主” 包一次引用它们。

本章中的类型主要在 System.Diagnostics 命名空间中定义。

条件编译

您可以使用预处理指令在 C# 中有条件地编译任何代码段。预处理指令是以 # 符号开头的特殊指令(与其他 C# 结构不同,必须单独出现在一行上)。逻辑上,在主编译之前执行它们(尽管在实践中,编译器在词法分析阶段处理它们)。用于条件编译的预处理指令包括 #if#else#endif#elif

#if 指令指示编译器在指定的符号已被定义时忽略代码段。您可以通过使用 #define 指令在源代码中定义符号(在这种情况下,符号仅适用于该文件),或者在 .csproj 文件中使用 <DefineConstants> 元素(在这种情况下,符号适用于整个程序集):

#define TESTMODE            // #define directives must be at top of file
                            // Symbol names are uppercase by convention.
using System;

class Program
{
  static void Main()
  {
#if TESTMODE
    Console.WriteLine ("in test mode!");     // OUTPUT: in test mode!
#endif
  }
}

如果我们删除第一行,则程序将编译,Console.WriteLine 语句将完全从可执行文件中删除,就像被注释掉一样。

#else 语句类似于 C# 的 else 语句,而 #elif 相当于 #else 后跟 #if||&&! 运算符执行 操作:

#if TESTMODE && !PLAYMODE      // if TESTMODE and not PLAYMODE
  ...

但请记住,您并不是在构建普通的 C# 表达式,您操作的符号与变量(静态或其他)没有任何关联。

可以通过编辑 .csproj 文件(或在 Visual Studio 中,在项目属性窗口的“生成”选项卡中)定义适用于程序集中每个文件的符号。以下定义了两个常量,TESTMODEPLAYMODE

<PropertyGroup>
  <DefineConstants>TESTMODE;PLAYMODE</DefineConstants>
</PropertyGroup>

如果您在程序集级别定义了一个符号,然后希望在特定文件中“取消定义”它,可以使用 #undef 指令。

条件编译与静态变量标志对比

您可以使用简单的静态字段来实现前面的示例:

static internal bool TestMode = true;

static void Main()
{
  if (TestMode) Console.WriteLine ("in test mode!");
}

这具有允许运行时配置的优点。那么,为什么选择条件编译?原因在于条件编译可以带你到变量标志无法到达的地方,比如以下情况:

  • 条件包含属性

  • 更改变量的声明类型

  • using指令中切换不同的命名空间或类型别名;例如:

    using TestType =
      #if V2
         MyCompany.Widgets.GadgetV2;
      #else
         MyCompany.Widgets.Gadget;
      #endif
    

甚至可以在条件编译指令下执行重大重构,因此您可以即时在旧版和新版之间切换,并编写可以针对多个运行时版本进行编译的库,在可用时利用最新功能。

条件编译的另一个优点是调试代码可以引用部署中未包含的程序集中的类型。

条件属性

Conditional属性指示编译器忽略对特定类或方法的所有调用,如果指定的符号未被定义。

要看到这对您有多有用,请假设您编写了一个用于记录状态信息的方法,如下所示:

static void LogStatus (string msg)
{
  string logFilePath = ...
  System.IO.File.AppendAllText (logFilePath, msg + "\r\n");
}

现在想象一下,您只希望在定义了LOGGINGMODE符号时执行此操作。第一种解决方案是将对LogStatus的所有调用都包装在#if指令中:

#if LOGGINGMODE
LogStatus ("Message Headers: " + GetMsgHeaders());
#endif

这样可以得到理想的结果,但是这很繁琐。第二种解决方案是将#if指令放在LogStatus方法内部。然而,如果以以下方式调用LogStatus,这将会有问题:

LogStatus ("Message Headers: " + GetComplexMessageHeaders());

GetComplexMessageHeaders将始终被调用,这可能会带来性能损失。

我们可以通过将Conditional属性(定义在System.Diagnostics中)附加到LogStatus方法来将第一种解决方案的功能与第二种解决方案的便利性结合起来:

[Conditional ("LOGGINGMODE")]
static void LogStatus (string msg)
{
  ...
}

这会指示编译器将对LogStatus的调用视为包含在#if LOGGINGMODE指令中。如果未定义该符号,则在编译中将完全消除对LogStatus的任何调用,包括它们的参数评估表达式。(因此,任何具有副作用的表达式将被跳过。)即使LogStatus和调用方位于不同的程序集中,这也适用。

注意

[Conditional]的另一个好处是条件检查是在调用方编译时进行的,而不是在被调用方法编译时进行的。这很有益,因为它允许您编写包含诸如LogStatus之类方法的库,并且只构建该库的一个版本。

Conditional属性在运行时被忽略——它纯粹是对编译器的指示。

条件属性的替代方案

如果需要在运行时动态启用或禁用功能,则Conditional属性无效:相反,必须使用基于变量的方法。这留下了在调用条件日志方法时如何优雅地避免参数评估的问题。函数方法可以解决这个问题:

using System;
using System.Linq;

class Program
{
  public static bool EnableLogging;

  static void LogStatus (Func<string> message)
  {
    string logFilePath = ...
    if (EnableLogging)
      System.IO.File.AppendAllText (logFilePath, message() + "\r\n");
  }
}

使用 lambda 表达式可以在不增加语法复杂性的情况下调用此方法:

LogStatus ( () => "Message Headers: " + GetComplexMessageHeaders() );

如果EnableLoggingfalse,则GetComplexMessageHeaders永远不会被评估。

调试和跟踪类

DebugTrace是提供基本日志记录和断言能力的静态类。这两个类非常相似;主要区别在于它们的预期用途。Debug类用于调试版本;Trace类用于调试和发布版本。为此:

All methods of the Debug class are defined with [Conditional("DEBUG")].
All methods of the Trace class are defined with [Conditional("TRACE")].

这意味着,除非定义了DEBUGTRACE符号,否则编译器将消除对DebugTrace的所有调用(Visual Studio 在项目属性的构建选项卡中提供了定义这些符号的复选框,并默认使用新项目启用 TRACE 符号)。

DebugTrace类都提供WriteWriteLineWriteIf方法。默认情况下,这些方法将消息发送到调试器的输出窗口:

Debug.Write     ("Data");
Debug.WriteLine (23 * 34);
int x = 5, y = 3;
Debug.WriteIf   (x > y, "x is greater than y");

Trace类还提供TraceInformationTraceWarningTraceError方法。这些方法与Write方法的行为差异取决于活动的TraceListener(我们在TraceListener中介绍了这一点)。

失败和断言

DebugTrace类都提供FailAssert方法。Fail将消息发送给DebugTrace类的Listeners集合中的每个TraceListener(请参见下一节),默认情况下将消息写入调试输出:

Debug.Fail ("File data.txt does not exist!");

Assert如果bool参数为false,则简单调用Fail,这被称为进行断言,如果违反则表示代码中存在 bug。指定失败消息是可选的:

Debug.Assert (File.Exists ("data.txt"), "File data.txt does not exist!");
var result = ...
Debug.Assert (result != null);

除了消息外,WriteFailAssert方法还可以重载接受一个string类别,这在处理输出时非常有用。

另一种断言的替代方法是,如果相反条件为真,则抛出异常。这在验证方法参数时是一种常见做法:

public void ShowMessage (string message)
{
  if (message == null) throw new ArgumentNullException ("message");
  ...
}

此类“断言”被无条件编译,而且在控制失败断言的结果上不够灵活,无法通过TraceListener来控制。严格来说,它们并不是断言。断言是指当前方法代码中的 bug,如果违反则表明存在问题。基于参数验证抛出异常表明调用者的代码中存在 bug。

TraceListener

Trace类有一个静态的Listeners属性,返回一个TraceListener实例的集合。这些负责处理WriteFailTrace方法发出的内容。

默认情况下,每个的Listeners集合都包含一个监听器(Default​Tra⁠ceListener)。默认监听器有两个关键特性:

  • 当连接到诸如 Visual Studio 之类的调试器时,消息将被写入调试输出窗口;否则,将忽略消息内容。

  • 当调用Fail方法(或断言失败)时,应用程序将终止。

您可以通过(可选地)移除默认侦听器并添加一个或多个自定义侦听器来更改此行为。您可以从头编写跟踪侦听器(通过子类化TraceListener)或使用预定义类型之一:

  • TextWriterTraceListener写入到StreamTextWriter或附加到文件。

  • EventLogTraceListener写入到 Windows 事件日志(仅限 Windows)。

  • EventProviderTraceListener写入到 Windows 事件跟踪(ETW)子系统(跨平台支持)。

TextWriterTraceListener进一步被子类化为ConsoleTraceListenerDelimitedListTraceListenerXmlWriterTraceListenerEventSchemaTraceListener

以下示例清除Trace的默认侦听器,然后添加三个侦听器——一个附加到文件,一个写入控制台,一个写入 Windows 事件日志:

// Clear the default listener:
Trace.Listeners.Clear();

// Add a writer that appends to the trace.txt file:
Trace.Listeners.Add (new TextWriterTraceListener ("trace.txt"));

// Obtain the Console's output stream, then add that as a listener:
System.IO.TextWriter tw = Console.Out;
Trace.Listeners.Add (new TextWriterTraceListener (tw));

// Set up a Windows Event log source and then create/add listener.
// CreateEventSource requires administrative elevation, so this would
// typically be done in application setup.
if (!EventLog.SourceExists ("DemoApp"))
  EventLog.CreateEventSource ("DemoApp", "Application");

Trace.Listeners.Add (new EventLogTraceListener ("DemoApp"));

对于 Windows 事件日志,使用WriteFailAssert方法写入的消息始终在 Windows 事件查看器中显示为“信息”消息。但是,通过TraceWarningTraceError方法写入的消息将显示为警告或错误。

TraceListener还具有TraceFilter类型的Filter,您可以设置该过滤器以控制是否将消息写入该侦听器。为此,您可以实例化预定义的子类(如EventTypeFilterSourceFilter),或者子类化TraceFilter并重写ShouldTrace方法。例如,您可以使用这个功能按类别进行过滤。

TraceListener还定义了用于控制缩进的IndentLevelIndentSize属性,以及用于写入额外数据的TraceOutputOptions属性:

TextWriterTraceListener tl = new TextWriterTraceListener (Console.Out);
tl.TraceOutputOptions = TraceOptions.DateTime | TraceOptions.Callstack;

使用Trace方法时将应用TraceOutputOptions

Trace.TraceWarning ("Orange alert");

*DiagTest.vshost.exe Warning: 0 : Orange alert*
 *DateTime=2007-03-08T05:57:13.6250000Z*
 *Callstack=   at System.Environment.GetStackTrace(Exception e, Boolean*
*needFileInfo)*
 *at System.Environment.get_StackTrace()     at ...*

刷新和关闭侦听器

一些侦听器(如TextWriterTraceListener)最终会写入到可能受缓存影响的流中。这有两个影响:

  • 消息可能不会立即显示在输出流或文件中。

  • 您必须在应用程序结束之前关闭——或至少刷新——侦听器;否则,您将丢失缓存中的内容(默认情况下最多 4 KB,如果写入文件)。

TraceDebug类提供静态的CloseFlush方法,这些方法会调用所有侦听器的CloseFlush(进而调用底层的编写器和流的CloseFlush)。Close隐含调用Flush,关闭文件句柄,并防止进一步写入数据。

通常建议在应用程序结束之前调用Close,并在希望确保当前消息数据已写入时调用Flush。如果使用基于流或文件的侦听器,则适用此规则。

TraceDebug还提供AutoFlush属性,如果设置为true,则在每条消息后强制执行Flush

注意

如果使用任何基于文件或流的侦听器,则将AutoFlush设置为true是一个好策略,否则,如果发生未处理的异常或关键错误,则可能会丢失最后的 4 KB 诊断信息。

调试器集成

有时,如果可用,应用程序与调试器进行交互是有用的。在开发过程中,调试器通常是你的 IDE(如 Visual Studio);在部署中,调试器更可能是较低级别的调试工具之一,如 WinDbg、Cordbg 或 MDbg。

附加和中断

System.Diagnostics中的静态Debugger类提供了与调试器交互的基本功能,即BreakLaunchLogIsAttached

调试器必须先附加到一个应用程序才能进行调试。如果你从 IDE 内部启动应用程序,这将自动发生,除非你另有要求(选择“不带调试启动”)。但有时,在 IDE 内部以调试模式启动应用程序可能不方便或不可能。例如,Windows 服务应用程序或(具有讽刺意味的是)Visual Studio 设计器。一个解决方案是正常启动应用程序,然后在 IDE 中选择“调试进程”。但这样做不能让你在程序执行早期设置断点。

解决方法是在应用程序内部调用Debugger.Break。这个方法会启动调试器,附加到它,并在那一点暂停执行(Launch做同样的事情,但不会暂停执行)。附加后,你可以使用Log方法直接向调试器的输出窗口记录消息。你可以通过检查IsAttached属性验证是否已连接到调试器。

调试器属性

DebuggerStepThroughDebuggerHidden属性为调试器提供了关于如何处理特定方法、构造函数或类的单步执行建议。

DebuggerStepThrough 请求调试器在没有任何用户交互的情况下步入函数。这个属性在自动生成的方法和代理方法中特别有用,后者将实际工作转发到其他位置的方法。在后一种情况下,如果在“真实”方法内设置了断点,调试器仍会显示代理方法在调用堆栈中,除非你还添加了DebuggerHidden属性。你可以在代理上结合这两个属性,帮助用户专注于调试应用逻辑而不是底层管理:

[DebuggerStepThrough, DebuggerHidden]
void DoWorkProxy()
{
  // setup...
  DoWork();
  // teardown...
}

void DoWork() {...}   // Real method...

进程和进程线程

我们在第六章的最后一节中描述了如何使用Process.Start启动新进程。Process类还允许你查询和与运行在同一台或另一台计算机上的其他进程进行交互。Process类是.NET Standard 2.0 的一部分,尽管其功能对于 UWP 平台有所限制。

检查运行中的进程

Process.GetProcess*XXX* 方法通过名称或进程 ID 检索特定进程,或检索当前计算机或指定计算机上运行的所有进程。这包括托管和非托管进程。每个 Process 实例具有丰富的属性,映射统计信息,如名称、ID、优先级、内存和处理器利用率、窗口句柄等。以下示例枚举了当前计算机上所有正在运行的进程:

foreach (Process p in Process.GetProcesses())
using (p)
{
  Console.WriteLine (p.ProcessName);
  Console.WriteLine ("   PID:      " + p.Id);
  Console.WriteLine ("   Memory:   " + p.WorkingSet64);
  Console.WriteLine ("   Threads:  " + p.Threads.Count);
}

Process.GetCurrentProcess返回当前进程。

您可以通过调用其 Kill 方法终止进程。

检查进程中的线程

您也可以使用Process.Threads属性枚举其他进程的线程。但是,您获取的对象不是System.Threading.Thread对象;它们是ProcessThread对象,用于管理而不是同步任务。ProcessThread对象提供有关底层线程的诊断信息,并允许您控制一些方面,如其优先级和处理器亲和性:

public void EnumerateThreads (Process p)
{
  foreach (ProcessThread pt in p.Threads)
  {
    Console.WriteLine (pt.Id);
    Console.WriteLine ("   State:    " + pt.ThreadState);
    Console.WriteLine ("   Priority: " + pt.PriorityLevel);
    Console.WriteLine ("   Started:  " + pt.StartTime);
    Console.WriteLine ("   CPU time: " + pt.TotalProcessorTime);
  }
}

StackTrace 和 StackFrame

StackTraceStackFrame 类提供了执行调用堆栈的只读视图。您可以为当前线程或 Exception 对象获取堆栈跟踪信息。这些信息主要用于诊断目的,尽管您也可以在编程(黑客)中使用它。StackTrace 表示完整的调用堆栈;StackFrame 表示该堆栈内的单个方法调用。

注意

如果你只需知道调用方法的名称和行号,调用者信息属性可以提供更简单和更快的替代方法。我们在“调用者信息属性”中讨论了这个话题。

如果您使用不带参数或带有bool参数的StackTrace对象进行实例化,您将获得当前线程调用堆栈的快照。如果bool参数为true,则StackTrace将读取程序集的 .pdb(项目调试)文件(如果存在),从而使您能够访问文件名、行号和列偏移数据。在使用 /debug 开关进行编译时生成项目调试文件。(Visual Studio 默认使用此开关进行编译,除非您通过 高级构建设置 请求否定。)

获得StackTrace后,可以通过调用GetFrame来检查特定的帧,或者通过使用GetFrames获取整个堆栈:

static void Main() { A (); }
static void A()    { B (); }
static void B()    { C (); }
static void C()
{
  StackTrace s = new StackTrace (true);

  Console.WriteLine ("Total frames:   " + s.FrameCount);
  Console.WriteLine ("Current method: " + s.GetFrame(0).GetMethod().Name);
  Console.WriteLine ("Calling method: " + s.GetFrame(1).GetMethod().Name);
  Console.WriteLine ("Entry method:   " + s.GetFrame
                                       (s.FrameCount-1).GetMethod().Name);
  Console.WriteLine ("Call Stack:");
  foreach (StackFrame f in s.GetFrames())
    Console.WriteLine (
      "  File: "   + f.GetFileName() +
      "  Line: "   + f.GetFileLineNumber() +
      "  Col: "    + f.GetFileColumnNumber() +
      "  Offset: " + f.GetILOffset() +
      "  Method: " + f.GetMethod().Name);
}

下面是输出:

Total frames:   4
Current method: C
Calling method: B
Entry method: Main
Call stack:
  File: C:\Test\Program.cs  Line: 15  Col: 4  Offset: 7  Method: C
  File: C:\Test\Program.cs  Line: 12  Col: 22  Offset: 6  Method: B
  File: C:\Test\Program.cs  Line: 11  Col: 22  Offset: 6  Method: A
  File: C:\Test\Program.cs  Line: 10  Col: 25  Offset: 6  Method: Main
注意

中间语言(IL)偏移量指示将执行下一个指令的偏移量 —— 而不是当前正在执行的指令。奇怪的是,如果存在 .pdb 文件,行和列号通常指示实际执行点。

这是因为 CLR 尽其所能推断出从 IL 偏移计算行和列的实际执行点。编译器以使这种可能成为可能的方式发出 IL —— 包括在 IL 流中插入 nop(无操作)指令。

使用启用优化编译时,将禁用插入nop指令,因此堆栈跟踪可能显示下一条要执行的语句的行号和列号。获得有用的堆栈跟踪进一步受到优化可以引入的其他技巧的阻碍,包括折叠整个方法。

获取整个StackTrace的基本信息的快捷方式是对其调用ToString。以下是结果的样子:

   at DebugTest.Program.C() in C:\Test\Program.cs:line 16
   at DebugTest.Program.B() in C:\Test\Program.cs:line 12
   at DebugTest.Program.A() in C:\Test\Program.cs:line 11
   at DebugTest.Program.Main() in C:\Test\Program.cs:line 10

您还可以通过将Exception对象传递给StackTrace的构造函数来获取异常对象(显示导致抛出异常的内容)的堆栈跟踪。

注意

Exception已经有一个StackTrace属性;然而,该属性返回一个简单的字符串,而不是StackTrace对象。在没有*.pdb文件可用的部署后发生异常时,IL 偏移量比行号和列号更有用。使用 IL 偏移量和ildasm*,您可以准确定位发生错误的方法内部位置。

Windows 事件日志

Win32 平台提供了一个集中的日志记录机制,以 Windows 事件日志的形式存在。

我们之前使用的DebugTrace类如果注册了EventLogTraceListener,则写入 Windows 事件日志。然而,使用EventLog类,您可以直接将数据写入 Windows 事件日志,而无需使用TraceDebug。您还可以使用此类来读取和监视事件数据。

注意

在 Windows 服务应用程序中写入 Windows 事件日志是有意义的,因为如果发生问题,您无法弹出用户界面,将用户引导到某个特殊文件中,其中包含已写入的诊断信息。此外,由于服务通常写入 Windows 事件日志是一种常见做法,如果您的服务停止运行,管理员可能会首先查看此处。

有三个标准的 Windows 事件日志,它们分别由以下名称标识:

  • 应用程序

  • 系统

  • 安全

应用程序日志通常是大多数应用程序写入的地方。

写入事件日志

要写入 Windows 事件日志:

  1. 选择三个事件日志之一(通常是应用程序)。

  2. 决定一个源名称,如果需要则创建(创建需要管理员权限)。

  3. 使用日志名称、源名称和消息数据调用EventLog.WriteEntry

源名称是您的应用程序的易于识别的名称。您必须在使用之前注册源名称——CreateEventSource方法执行此功能。然后可以调用WriteEntry

const string SourceName = "MyCompany.WidgetServer";

// CreateEventSource requires administrative permissions, so this would
// typically be done in application setup.
if (!EventLog.SourceExists (SourceName))
  EventLog.CreateEventSource (SourceName, "Application");

EventLog.WriteEntry (SourceName,
  "Service started; using configuration file=...",
  EventLogEntryType.Information);

EventLogEntryType可以是InformationWarningErrorSuccessAuditFailureAudit。每种类型在 Windows 事件查看器中显示不同的图标。您还可以选择性地指定类别和事件 ID(每个都是您自己选择的数字),并提供可选的二进制数据。

CreateEventSource还允许您指定计算机名称:这是为了将日志写入另一台计算机的事件日志(如果您具有足够的权限)。

读取事件日志

要读取事件日志,请使用要访问的日志名称和(可选)日志所在计算机的名称实例化EventLog类。然后可以通过Entries集合属性读取每个日志条目:

EventLog log = new EventLog ("Application");

Console.WriteLine ("Total entries: " + log.Entries.Count);

EventLogEntry last = log.Entries [log.Entries.Count - 1];
Console.WriteLine ("Index:   " + last.Index);
Console.WriteLine ("Source:  " + last.Source);
Console.WriteLine ("Type:    " + last.EntryType);
Console.WriteLine ("Time:    " + last.TimeWritten);
Console.WriteLine ("Message: " + last.Message);

您可以通过静态方法EventLog.GetEventLogs(这需要管理员权限以获取完全访问权限)枚举当前(或另一个)计算机的所有日志:

foreach (EventLog log in EventLog.GetEventLogs())
  Console.WriteLine (log.LogDisplayName);

这通常至少打印应用程序安全性系统

监视事件日志

你可以通过EntryWritten事件在 Windows 事件日志中写入条目时得到提醒。这适用于本地计算机上的事件日志,并且不管哪个应用程序记录了事件,都会触发此事件。

要启用日志监视:

  1. 实例化一个EventLog并将其EnableRaisingEvents属性设置为true

  2. 处理EntryWritten事件。

例如:

using (var log = new EventLog ("Application"))
{
  log.EnableRaisingEvents = true;
  log.EntryWritten += DisplayEntry;
  Console.ReadLine();
}

void DisplayEntry (object sender, EntryWrittenEventArgs e)
{
  EventLogEntry entry = e.Entry;
  Console.WriteLine (entry.Message);
}

性能计数器

注意

性能计数器是仅适用于 Windows 的功能,需要 NuGet 包System.Diagnostics​.PerformanceCounter。如果您的目标是 Linux 或 macOS,请参阅“跨平台诊断工具”以获取替代方案。

我们迄今讨论的日志记录机制对于捕获未来分析的信息很有用。但是,要深入了解应用程序(或整个系统)的当前状态,需要一种更实时的方法。Win32 解决这种需求的方案是性能监控基础设施,它由系统和应用程序公开的一组性能计数器以及用于实时监控这些计数器的 Microsoft Management Console(MMC)插件组成。

性能计数器分为类别,如“系统”、“处理器”、“.NET CLR 内存”等。这些类别有时也被图形用户界面(GUI)工具称为“性能对象”。每个类别分组了一组相关的性能计数器,用于监视系统或应用程序的一个方面。在“.NET CLR 内存”类别中,性能计数器的示例包括“%GC 时间”、“所有堆中的字节数”和“每秒分配的字节数”。

每个类别可以选择性地具有一个或多个可以独立监视的实例。例如,在“处理器”类别的“%处理器时间”性能计数器中,这在监视 CPU 利用率时非常有用。在多处理器计算机上,此计数器支持每个 CPU 的实例,允许您独立监视每个 CPU 的利用率。

以下各节说明了如何执行常见的任务,例如确定公开的计数器、监视计数器以及创建自己的计数器以公开应用程序状态信息。

注意

读取性能计数器或类别可能需要在本地或目标计算机上具有管理员权限,这取决于所访问的内容。

枚举可用计数器

以下示例枚举计算机上所有可用的性能计数器。对于具有实例的计数器,它枚举每个实例的计数器:

PerformanceCounterCategory[] cats =
  PerformanceCounterCategory.GetCategories();

foreach (PerformanceCounterCategory cat in cats)
{
  Console.WriteLine ("Category: " + cat.CategoryName);

  string[] instances = cat.GetInstanceNames();
  if (instances.Length == 0)
  {
    foreach (PerformanceCounter ctr in cat.GetCounters())
      Console.WriteLine ("  Counter: " + ctr.CounterName);
  }
  else   // Dump counters with instances
  {
    foreach (string instance in instances)
    {
      Console.WriteLine ("  Instance: " + instance);
      if (cat.InstanceExists (instance))
        foreach (PerformanceCounter ctr in cat.GetCounters (instance))
          Console.WriteLine ("    Counter: " + ctr.CounterName);
    }
  }
}
注意

结果超过 10,000 行长!执行起来也需要一些时间,因为 PerformanceCounter​Cate⁠gory.Instance​Exists 的实现效率低下。在实际系统中,您希望仅在需要时检索更详细的信息。

下一个示例使用 LINQ 仅检索 .NET 性能计数器,并将结果写入 XML 文件:

var x =
  new XElement ("counters",
    from PerformanceCounterCategory cat in
         PerformanceCounterCategory.GetCategories()
    where cat.CategoryName.StartsWith (".NET")
    let instances = cat.GetInstanceNames()
    select new XElement ("category",
      new XAttribute ("name", cat.CategoryName),
      instances.Length == 0
      ?
        from c in cat.GetCounters()
        select new XElement ("counter",
          new XAttribute ("name", c.CounterName))
      :
        from i in instances
        select new XElement ("instance", new XAttribute ("name", i),
          !cat.InstanceExists (i)
          ?
            null
          :
            from c in cat.GetCounters (i)
            select new XElement ("counter",
              new XAttribute ("name", c.CounterName))
        )
    )
  );
x.Save ("counters.xml");

读取性能计数器数据

要检索性能计数器的值,请实例化 PerformanceCounter 对象,然后调用 NextValueNextSample 方法。NextValue 返回一个简单的 float 值;NextSample 返回一个 CounterSample 对象,该对象公开了一组更高级的属性,如 CounterFrequencyTimeStampBaseValueRawValue

PerformanceCounter 的构造函数接受类别名称、计数器名称和可选的实例。因此,要显示所有 CPU 的当前处理器利用率,您可以执行以下操作:

using PerformanceCounter pc = new PerformanceCounter ("Processor",
                                                      "% Processor Time",
                                                      "_Total");
Console.WriteLine (pc.NextValue());

或者显示当前进程的“真实”(即私有)内存消耗:

string procName = Process.GetCurrentProcess().ProcessName;
using PerformanceCounter pc = new PerformanceCounter ("Process",
                                                      "Private Bytes",
                                                      procName);
Console.WriteLine (pc.NextValue());

PerformanceCounter 没有公开 ValueChanged 事件,因此如果要监视更改,必须进行轮询。在下一个示例中,我们每 200 毫秒轮询一次,直到通过 EventWaitHandle 发出退出信号:

// need to import System.Threading as well as System.Diagnostics

static void Monitor (string category, string counter, string instance,
                     EventWaitHandle stopper)
{
  if (!PerformanceCounterCategory.Exists (category))
    throw new InvalidOperationException ("Category does not exist");

  if (!PerformanceCounterCategory.CounterExists (counter, category))
    throw new InvalidOperationException ("Counter does not exist");

  if (instance == null) instance = "";   // "" == no instance (not null!)
  if (instance != "" &&
      !PerformanceCounterCategory.InstanceExists (instance, category))
    throw new InvalidOperationException ("Instance does not exist");

  float lastValue = 0f;
  using (PerformanceCounter pc = new PerformanceCounter (category,
                                                      counter, instance))
    while (!stopper.WaitOne (200, false))
    {
      float value = pc.NextValue();
      if (value != lastValue)         // Only write out the value
      {                               // if it has changed.
        Console.WriteLine (value);
        lastValue = value;
      }
    }
}

使用这种方法同时监视处理器和硬盘活动:

EventWaitHandle stopper = new ManualResetEvent (false);

new Thread (() =>
  Monitor ("Processor", "% Processor Time", "_Total", stopper)
).Start();

new Thread (() =>
  Monitor ("LogicalDisk", "% Idle Time", "C:", stopper)
).Start();

Console.WriteLine ("Monitoring - press any key to quit");
Console.ReadKey();
stopper.Set();

创建计数器和写入性能数据

在写入性能计数器数据之前,需要创建性能类别和计数器。您必须在一步中创建性能类别以及属于它的所有计数器,如下所示:

string category = "Nutshell Monitoring";

// We'll create two counters in this category:
string eatenPerMin = "Macadamias eaten so far";
string tooHard = "Macadamias deemed too hard";

if (!PerformanceCounterCategory.Exists (category))
{
  CounterCreationDataCollection cd = new CounterCreationDataCollection();

  cd.Add (new CounterCreationData (eatenPerMin,
          "Number of macadamias consumed, including shelling time",
          PerformanceCounterType.NumberOfItems32));

  cd.Add (new CounterCreationData (tooHard,
          "Number of macadamias that will not crack, despite much effort",
          PerformanceCounterType.NumberOfItems32));

  PerformanceCounterCategory.Create (category, "Test Category",
    PerformanceCounterCategoryType.SingleInstance, cd);
}

当您选择添加计数器时,新的计数器将显示在 Windows 性能监视工具中。如果稍后要在同一类别中定义更多计数器,则必须首先调用 Performance​Coun⁠terCategory.Delete 删除旧类别。

注意

创建和删除性能计数器需要管理员权限。因此,通常作为应用程序设置的一部分来完成。

创建计数器后,可以通过实例化 PerformanceCounter,将 ReadOnly 设置为 false,并设置 RawValue 来更新其值。您还可以使用 IncrementIncrementBy 方法来更新现有值:

string category = "Nutshell Monitoring";
string eatenPerMin = "Macadamias eaten so far";

using (PerformanceCounter pc = new PerformanceCounter (category,
                                                       eatenPerMin, ""))
{
  pc.ReadOnly = false;
  pc.RawValue = 1000;
  pc.Increment();
  pc.IncrementBy (10);
  Console.WriteLine (pc.NextValue());    // 1011
}

Stopwatch 类

Stopwatch 类提供了一个方便的机制来测量执行时间。Stopwatch 使用操作系统和硬件提供的最高分辨率机制,通常小于一微秒。(相比之下,DateTime.NowEnvironment.TickCount 的分辨率约为 15 毫秒。)

要使用 Stopwatch,调用 StartNew —— 这将实例化一个 Stopwatch 并启动它开始计时。(或者,您可以手动实例化然后调用 Start。)Elapsed 属性以 TimeSpan 形式返回经过的时间间隔:

Stopwatch s = Stopwatch.StartNew();
System.IO.File.WriteAllText ("test.txt", new string ('*', 30000000));
Console.WriteLine (s.Elapsed);       // 00:00:01.4322661

Stopwatch 还公开了ElapsedTicks属性,它以long返回经过的“ticks”数。要从 ticks 转换为秒,请除以 StopWatch​.Fre⁠quency。还有一个ElapsedMilliseconds属性,通常是最方便的。

调用Stop会冻结ElapsedElapsedTicks。不会有由“运行中”的Stopwatch引起的后台活动,因此调用Stop是可选的。

跨平台诊断工具

在本节中,我们简要介绍了.NET 可用的跨平台诊断工具:

dotnet-counters

提供运行应用程序状态的概述

dotnet-trace

获取更详细的性能和事件监视信息

dotnet-dump

要在需求或崩溃后获取内存转储

这些工具不需要管理员权限,并且适用于开发和生产环境。

dotnet-counters

dotnet-counters工具监视.NET 进程的内存和 CPU 使用情况,并将数据写入控制台(或文件)。

要安装工具,请从命令提示符或带有dotnet路径的终端运行以下命令:

dotnet tool install --global dotnet-counters

您可以按以下方式开始监视进程:

dotnet-counters monitor System.Runtime --process-id *<<ProcessID>>*

System.Runtime 表示我们要监视System.Runtime类别下的所有计数器。您可以指定类别或计数器名称(dotnet-counters list命令列出所有可用的类别和计数器)。

输出将持续刷新并类似于以下内容:

Press p to pause, r to resume, q to quit.
    Status: Running

[System.Runtime]
    # of Assemblies Loaded                            63
    % Time in GC (since last GC)                       0
    Allocation Rate (Bytes / sec)                244,864
    CPU Usage (%)                                      6
    Exceptions / sec                                   0
    GC Heap Size (MB)                                  8
    Gen 0 GC / sec                                     0
    Gen 0 Size (B)                               265,176
    Gen 1 GC / sec                                     0
    Gen 1 Size (B)                               451,552
    Gen 2 GC / sec                                     0
    Gen 2 Size (B)                                    24
    LOH Size (B)                               3,200,296
    Monitor Lock Contention Count / sec                0
    Number of Active Timers                            0
    ThreadPool Completed Work Items / sec             15
    ThreadPool Queue Length                            0
    ThreadPool Threads Count                           9
    Working Set (MB)                                  52

以下是所有可用的命令:

命令目的
list显示计数器名称及其描述的列表
ps显示符合监视条件的 dotnet 进程列表
monitor显示所选计数器的值(定期刷新)
collect将计数器信息保存到文件

支持以下参数:

选项/参数目的
--version显示dotnet-counters的版本。
-h, --help显示程序的帮助信息。
-p, --process-id要监视的 dotnet 进程的 ID。适用于monitorcollect命令。
--refresh-interval设置所需的刷新间隔(以秒为单位)。适用于monitorcollect命令。
-o, --output设置输出文件名。适用于collect命令。
--format设置输出格式。有效的格式为csvjson。适用于collect命令。

dotnet-trace

跟踪是程序中事件的时间戳记录,例如调用方法或查询数据库。跟踪还可以包括性能指标和自定义事件,并可以包含局部上下文,例如局部变量的值。传统上,.NET Framework 和诸如 ASP.NET 之类的框架使用 ETW。在.NET 5 中,应用程序跟踪在 Windows 上运行时写入 ETW,在 Linux 上运行时写入 LTTng。

要安装工具,请执行以下命令:

dotnet tool install --global dotnet-trace

要开始记录程序的事件,请运行以下命令:

dotnet-trace collect --process-id *<<ProcessId>>*

这会使用默认配置文件运行dotnet-trace,该配置文件收集 CPU 和 .NET 运行时事件,并写入名为trace.nettrace的文件。您可以使用--profile开关指定其他配置文件:gc-verbose 跟踪垃圾回收和抽样对象分配,gc-collect 以低开销跟踪垃圾回收。-o开关允许您指定不同的输出文件名。

默认输出为*.netperf文件,可以直接在 Windows 机器上使用 PerfView 工具分析。或者,您可以指示dotnet-trace创建与 Speedscope 兼容的文件,Speedscope 是一个免费的在线分析服务,位于https://speedscope.app。要创建 Speedscope(.speedscope.json*)文件,请使用选项--format speedscope

注:

您可以从https://github.com/microsoft/perfview下载 PerfView 的最新版本。随 Windows 10 发货的版本可能不支持*.netperf*文件。

支持以下命令:

CommandsPurpose
collect开始将计数器信息记录到文件中。
ps显示可供监视的 dotnet 进程列表。
list-profiles列出具有每个提供程序和过滤器描述的预构建跟踪配置文件。
convert <file>nettrace.netperf)格式转换为另一种格式。当前唯一的目标选项是speedscope

自定义跟踪事件

您的应用程序可以通过定义自定义EventSource来发出自定义事件:

[EventSource (Name = "MyTestSource")]
public sealed class MyEventSource : EventSource
{
  public static MyEventSource Instance = new MyEventSource ();

  MyEventSource() : base (EventSourceSettings.EtwSelfDescribingEventFormat)
  {
  }

  public void Log (string message, int someNumber)
  {
    WriteEvent (1, message, someNumber);
  }
}

WriteEvent 方法被重载以接受各种简单类型的组合(主要是字符串和整数)。然后,您可以按以下方式调用它:

MyEventSource.Instance.Log ("Something", 123);

调用dotnet-trace时,必须指定要记录的任何自定义事件源的名称:

dotnet-trace collect --process-id *<<ProcessId>>* --providers MyTestSource

dotnet-dump

Dump,有时称为核心转储,是进程虚拟内存状态的快照。您可以按需转储正在运行的进程,或者配置操作系统在应用程序崩溃时生成转储。

在 Ubuntu Linux 上,以下命令在应用程序崩溃时启用核心转储(不同 Linux 版本间的必要步骤可能有所不同):

ulimit -c unlimited

在 Windows 上,使用regedit.exe在本地计算机 hive 中创建或编辑以下键:

SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps

在此之下,添加与您的可执行文件相同的键(例如foo.exe),并在该键下添加以下键:

  • DumpFolder(REG_EXPAND_SZ),其值指示要写入转储文件的路径

  • DumpType(REG_DWORD),其值为 2,以请求完整转储

  • (可选)DumpCount(REG_DWORD),指示在删除最老的转储文件之前的最大转储文件数

要安装该工具,请运行以下命令:

dotnet tool install --global dotnet-dump

安装完后,您可以按需进行转储(而无需结束进程),如下所示:

dotnet-dump collect --process-id *<<YourProcessId>>*

以下命令启动用于分析转储文件的交互式 shell:

dotnet-dump analyze *<<dumpfile>>*

如果异常导致应用程序崩溃,你可以使用printexceptions命令(简写为pe)来显示异常的详细信息。dotnet-dump shell 支持多个额外的命令,你可以使用help命令列出这些命令。

第十四章:并发性和异步性

大多数应用程序需要同时处理多个事物(并发)。在本章中,我们首先介绍必要的先决条件,即线程和任务的基础知识,然后详细描述了异步性原理和 C#的异步函数。

在第二十一章中,我们会更详细地讨论多线程,并在第二十二章中介绍相关的并行编程主题。

介绍

以下是最常见的并发场景:

编写响应式用户界面

在 Windows Presentation Foundation(WPF)、移动和 Windows Forms 应用程序中,必须与运行用户界面的代码并行运行耗时任务,以保持响应性。

允许请求同时处理

在服务器上,客户端请求可以同时到达,因此必须并行处理以保持可伸缩性。如果使用 ASP.NET Core 或 Web API,则运行时会自动处理。但是,您仍需注意共享状态(例如,使用静态变量进行缓存的影响)。

并行编程

如果工作负载在多核/多处理器计算机上分配,执行计算密集型计算的代码会更快(第二十二章专门讨论这一点)。

推测执行

在多核机器上,有时可以通过预测可能需要执行的任务并提前执行来提高性能。LINQPad 使用这种技术加速新查询的创建。另一种变体是并行运行多种不同算法来解决相同任务。首先完成的算法“获胜”——在无法预先知道哪种算法执行速度最快时,这种方法非常有效。

程序能够同时执行代码的一般机制称为多线程。多线程由 CLR 和操作系统支持,是并发中的基本概念。因此,理解线程的基础知识,特别是线程对共享状态的影响,是至关重要的。

线程

线程是可以独立进行的执行路径。

每个线程在操作系统进程内运行,提供一个隔离的环境来执行程序。在单线程程序中,只有一个线程在进程的隔离环境中运行,因此该线程具有独占访问权限。在多线程程序中,多个线程在同一个进程中运行,共享相同的执行环境(尤其是内存)。这部分原因解释了为什么多线程很有用:例如,一个线程可以在后台获取数据,而另一个线程在数据到达时显示数据。这些数据被称为共享状态

创建线程

一个客户端程序(控制台,WPF,UWP 或 Windows 窗体)在操作系统(“主”线程)自动创建的单个线程中启动。在这里,它作为单线程应用程序存在,除非你通过创建更多线程(直接或间接)进行其他操作。¹

你可以通过实例化一个Thread对象并调用其Start方法来创建和启动一个新线程。Thread的最简构造函数接受一个ThreadStart委托:一个无参数方法,指示执行应该从哪里开始。以下是一个示例:

// NB: All samples in this chapter assume the following namespace imports:
using System;
using System.Threading;

Thread t = new Thread (WriteY);          // Kick off a new thread
t.Start();                               // running WriteY()

// Simultaneously, do something on the main thread.
for (int i = 0; i < 1000; i++) Console.Write ("x");

void WriteY()
{
  for (int i = 0; i < 1000; i++) Console.Write ("y");
}

// Typical Output:
xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...

主线程在一个新线程t上创建并运行一个重复打印字符y的方法。同时,主线程重复打印字符x,如图 14-1 所示。在单核计算机上,操作系统必须分配时间“片段”给每个线程(在 Windows 中通常为 20 毫秒)以模拟并发,导致xy的重复块。在多核或多处理器机器上,两个线程可以真正并行执行(受计算机上其他活动进程的竞争影响),尽管在本例中由于Console处理并发请求的机制的细微差别,你仍然会得到xy的重复块。

开始一个新线程

图 14-1. 开始一个新线程
注意

线程在其执行与另一个线程上的代码执行交织的点被称为抢占。这个术语经常用来解释为什么事情出了问题!

一旦启动,线程的IsAlive属性返回true,直到线程结束的时候。线程结束是指传递给Thread构造函数的委托执行完毕。一旦线程结束,线程就无法重新启动。

每个线程都有一个Name属性,你可以为了调试的利益进行设置。这在 Visual Studio 中特别有用,因为线程的名称显示在“线程”窗口和“调试位置”工具栏中。你只能设置一次线程的名称;尝试稍后更改将引发异常。

静态的Thread.CurrentThread属性提供当前正在执行的线程:

Console.WriteLine (Thread.CurrentThread.Name);

加入和休眠

你可以通过调用其Join方法等待另一个线程结束:

Thread t = new Thread (Go);
t.Start();
t.Join();
Console.WriteLine ("Thread t has ended!");

void Go() { for (int i = 0; i < 1000; i++) Console.Write ("y"); }

这会打印“y” 1,000 次,然后紧接着打印“线程 t 已结束!”。在调用Join时,你可以包含超时,可以是毫秒或TimeSpan。然后,如果线程结束,则返回true;如果超时,则返回false

Thread.Sleep暂停当前线程一段指定的时间:

Thread.Sleep (TimeSpan.FromHours (1));  // Sleep for 1 hour
Thread.Sleep (500);                     // Sleep for 500 milliseconds

Thread.Sleep(0)立即放弃线程的当前时间片,自愿将 CPU 交给其他线程。Thread.Yield()也做同样的事情,不同的是它只放弃给同一处理器上运行的线程。

注意

Sleep(0)Yield在生产代码中偶尔对高级性能调整很有用。它也是帮助发现线程安全问题的优秀诊断工具:如果在代码中的任何地方插入Thread.Yield()导致程序中断,几乎可以肯定存在 bug。

在等待SleepJoin时,线程被阻塞。

阻塞

当线程的执行因某些原因暂停时,线程被认为是阻塞的,例如通过Sleep或通过Join等待另一个线程结束。阻塞的线程立即放弃其处理器时间片,并且从那时起,直到其阻塞条件满足之前,它不再消耗处理器时间。您可以通过其ThreadState属性测试线程是否阻塞:

bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
注意

ThreadState是一个标志枚举,以位操作方式组合三个“层次”的数据。然而,大多数值是冗余的、未使用的或已弃用的。以下扩展方法将ThreadState剥离为四个有用的值之一:UnstartedRunningWaitSleepJoinStopped

public static ThreadState Simplify (this ThreadState ts)
{
  return ts & (ThreadState.Unstarted |
               ThreadState.WaitSleepJoin |
               ThreadState.Stopped);
}

ThreadState属性对诊断目的很有用,但不适合用于同步,因为线程的状态可能在测试ThreadState和处理该信息之间发生变化。

当线程阻塞或解除阻塞时,操作系统执行上下文切换。这会产生一小部分开销,通常为一到两微秒。

I/O 绑定与计算绑定

大部分时间等待某事发生的操作称为I/O 绑定——例如下载网页或调用Console.ReadLine。(I/O 绑定操作通常涉及输入或输出,但这不是硬性要求:Thread.Sleep也被认为是 I/O 绑定。)相比之下,大部分时间执行 CPU 密集型工作的操作称为计算绑定

阻塞与自旋

I/O 绑定操作有两种工作方式:要么在当前线程上同步等待直到操作完成(如Console.ReadLineThread.SleepThread.Join),要么异步操作,在未来操作完成时触发回调(稍后详细介绍)。

等待同步操作的 I/O 绑定操作大部分时间都在阻塞线程。它们也可以定期在循环中“自旋”:

while (DateTime.Now < nextStartTime)
  Thread.Sleep (100);

撇开有更好方法的事实(例如定时器或信号构造),另一种选择是线程可以连续自旋:

while (DateTime.Now < nextStartTime);

一般来说,这对处理器时间非常浪费:就 CLR 和操作系统而言,线程正在执行重要的计算,因此分配了相应的资源。事实上,我们已经把本应是 I/O 绑定操作变成了计算绑定操作。

注意

关于自旋和阻塞有一些微妙之处。首先,当您期望条件很快满足(可能在几微秒内)时,非常短暂的自旋可能是有效的,因为它避免了上下文切换的开销和延迟。.NET 提供了特殊的方法和类来辅助此过程——参见在线补充材料“SpinLock and SpinWait”

其次,阻塞并非零成本。这是因为每个线程在其存活期间大约会占用 1MB 的内存,并且会导致 CLR 和操作系统的持续管理开销。因此,在需要处理数百或数千个并发操作的 I/O 密集型程序的情况下,阻塞可能会带来麻烦。相反,这些程序需要使用基于回调的方法,完全释放线程而不进行阻塞。这在我们稍后讨论的异步模式中部分体现出来。

本地状态与共享状态

CLR 为每个线程分配自己的内存堆栈,以便保持本地变量的分离。在下一个示例中,我们定义一个带有本地变量的方法,然后同时在主线程和新创建的线程上调用该方法:

new Thread (Go).Start();      // Call Go() on a new thread
Go();                         // Call Go() on the main thread

void Go()
{
  // Declare and use a local variable - 'cycles'
  for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');
}

每个线程的内存堆栈上都创建了cycles变量的单独副本,因此输出是可以预测的,会输出 10 个问号。

线程在具有对同一对象或变量的共同引用时共享数据:

bool _done = false;

new Thread (Go).Start();
Go();

void Go()
{
   if (!_done) { _done = true; Console.WriteLine ("Done"); }
}

两个线程共享_done变量,因此“Done”只会打印一次而不是两次。

由 lambda 表达式捕获的本地变量也可以被共享:

bool done = false;
ThreadStart action = () =>
{
  if (!done) { done = true; Console.WriteLine ("Done"); }
};
new Thread (action).Start();
action();

更常见的情况是,字段用于在线程之间共享数据。在下面的示例中,两个线程都在同一个ThreadTest实例上调用Go(),因此它们共享相同的_done字段:

var tt = new ThreadTest();
new Thread (tt.Go).Start();
tt.Go();

class ThreadTest 
{
  bool _done;

  public void Go()
  {
    if (!_done) { _done = true; Console.WriteLine ("Done"); }
  }
}

静态字段提供了另一种在线程之间共享数据的方式:

class ThreadTest 
{
  static bool _done;    // Static fields are shared between all threads
                        // in the same process.
  static void Main()
  {
    new Thread (Go).Start();
    Go();
  }

  static void Go()
  {
    if (!_done) { _done = true; Console.WriteLine ("Done"); }
  }
}

所有四个示例都展示了另一个关键概念:即线程安全(或者说,缺乏线程安全!)。输出实际上是不确定的:“Done”可能会被打印两次(尽管可能性很小)。然而,如果我们交换Go方法中语句的顺序,那么“Done”被打印两次的几率会显著增加:

static void Go()
{
  if (!_done) { Console.WriteLine ("Done"); _done = true; }
}

问题在于一个线程可以在另一个线程执行WriteLine语句之前恰好评估 if 语句,而它还没有机会将done设置为true

注意

我们的示例说明了共享可写状态可能引入多线程环境下的间歇性错误的多种方式之一。接下来,我们将看看如何通过锁定来修复我们的程序;然而,在可能的情况下,最好完全避免共享状态。我们稍后会看到,异步编程模式如何帮助解决这个问题。

锁定和线程安全

注意

锁定和线程安全是一个广泛的话题。有关完整讨论,请参阅“独占锁定”和“锁定和线程安全”。

我们可以通过在读写共享字段时获取独占锁来修复前面的示例。C# 提供了 lock 语句来实现这一目的:

class ThreadSafe 
{
  static bool _done;
  static readonly object _locker = new object();

  static void Main()
  {
    new Thread (Go).Start();
    Go();
  }

  static void Go()
  {
    lock (_locker)
    {
      if (!_done) { Console.WriteLine ("Done"); _done = true; }
    }
  }
}

当两个线程同时争夺一个锁(可以是任何引用类型对象;在本例中是 _locker),一个线程会等待,或者说阻塞,直到锁变为可用。在这种情况下,它确保只有一个线程可以同时进入其代码块,因此,“Done” 只会被打印一次。在这种多线程上下文中受保护的代码称为线程安全

警告

即使是自增变量的操作也不是线程安全的:表达式 x++ 在底层处理器上执行为独立的读-增量-写操作。因此,如果两个线程在没有锁的情况下同时执行 x++,变量可能只会增加一次,而不是两次(或者更糟,x 可能会撕裂,在某些条件下会得到旧内容和新内容的混合)。

锁定并非线程安全的万能药——很容易忘记在访问字段时加锁,而且加锁本身可能会带来问题(如死锁)。

在 ASP.NET 应用程序中访问频繁访问的数据库对象的共享内存缓存时,锁定是一个很好的例子。这种应用程序简单易用,并且没有死锁的机会。我们在“应用程序服务器中的线程安全性”中给出了一个例子。

向线程传递数据

有时,您可能希望向线程的启动方法传递参数。这样做的最简单方法是使用一个 Lambda 表达式来调用带有所需参数的方法:

Thread t = new Thread ( () => Print ("Hello from t!") );
t.Start();

void Print (string message) => Console.WriteLine (message);

使用这种方法,你可以向方法传递任意数量的参数。甚至可以将整个实现包装在一个多语句 Lambda 表达式中:

new Thread (() =>
{
  Console.WriteLine ("I'm running on another thread!");
  Console.WriteLine ("This is so easy!");
}).Start();

另一种(更不灵活)的技术是将参数传递给 ThreadStart 方法:

Thread t = new Thread (Print);
t.Start ("Hello from t!");

void Print (object messageObj)
{
  string message = (string) messageObj;   // We need to cast here
  Console.WriteLine (message);
}

这是因为 Thread 的构造函数被重载为接受两个委托之一:

public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);

Lambda 表达式和捕获的变量

正如我们所见,Lambda 表达式是向线程传递数据最方便和强大的方法。但是,在启动线程后,您必须小心不要意外修改捕获的变量。例如,请考虑以下情况:

for (int i = 0; i < 10; i++)
  new Thread (() => Console.Write (i)).Start();

输出是不确定的!这里是一个典型的结果:

0223557799

问题在于 i 变量在循环的整个生命周期中引用同一内存位置。因此,每个线程在运行时调用 Console.Write 的变量值可能会发生变化!解决方法是使用临时变量如下所示:

for (int i = 0; i < 10; i++)
{
  int temp = i;
  new Thread (() => Console.Write (temp)).Start();
}

然后,数字 0 到 9 的每个数字会被写入一次。(顺序仍然是未定义的,因为线程可以在不确定的时间启动。)

注意

这类似于我们在“捕获变量”中描述的问题。这个问题不仅仅涉及到 C# 中关于在 for 循环中捕获变量的规则,还涉及到多线程。

变量 temp 现在是每个循环迭代的本地变量。因此,每个线程捕获不同的内存位置,没有问题。我们可以用以下示例更简单地说明早期代码中的问题:

string text = "t1";
Thread t1 = new Thread ( () => Console.WriteLine (text) );

text = "t2";
Thread t2 = new Thread ( () => Console.WriteLine (text) );

t1.Start(); t2.Start();

因为两个 lambda 表达式都捕获相同的文本变量,t2 被打印两次。

异常处理

当创建线程时,现有的任何 try/catch/finally 块对线程在开始执行时不起作用。考虑以下程序:

try
{
  new Thread (Go).Start();
}
catch (Exception ex)
{
  // We'll never get here!
  Console.WriteLine ("Exception!");
}

void Go() { throw null; }   // Throws a NullReferenceException

在这个示例中,try/catch语句是无效的,并且新创建的线程将被未处理的NullReferenceException所拖累。考虑到每个线程都有独立的执行路径,这种行为是合理的。

解决方法是将异常处理程序移到 Go 方法中:

new Thread (Go).Start();

void Go()
{
  try
  {
    ...
    throw null;    // The NullReferenceException will get caught below
    ...
  }
  catch (Exception ex)
  {
    // Typically log the exception and/or signal another thread
    // that we've come unstuck
    ...
  }
}

在生产应用程序的所有线程入口方法上都需要异常处理程序——就像你在主线程(通常在执行堆栈的更高级别)上做的那样。未处理的异常会导致整个应用程序关闭,并出现一个丑陋的对话框!

注意

在编写此类异常处理块时,你很少会忽略错误:通常会记录异常的详细信息。对于客户端应用程序,你可能会显示一个对话框,允许用户自动将这些详细信息提交到你的 Web 服务器。然后,你可能会选择重新启动应用程序,因为意外的异常可能会使程序处于无效状态。

集中式异常处理

在 WPF、UWP 和 Windows Forms 应用程序中,你可以订阅“全局”异常处理事件,分别是 Application.DispatcherUnhandledExceptionApplication.ThreadException。这些事件在程序的任何部分通过消息循环调用时(这相当于在 Application 激活时运行的所有代码)的未处理异常后触发。这对于日志记录和报告错误非常有用(尽管它不会对你创建的工作线程上的未处理异常触发)。处理这些事件可以防止程序关闭,尽管你可以选择重新启动应用程序,以避免从(或导致)未处理异常后可能发生的状态损坏。

前台线程与后台线程

默认情况下,显式创建的线程是前台线程。前台线程会在任何一个线程运行时保持应用程序处于活动状态,而后台线程则不会。当所有前台线程都完成后,应用程序结束,而仍在运行的任何后台线程将会突然终止。

注意

线程的前台/后台状态与其优先级(执行时间分配)无关。

你可以使用线程的IsBackground属性来查询或更改线程的后台状态:

static void Main (string[] args)
{
  Thread worker = new Thread ( () => Console.ReadLine() );
  if (args.Length > 0) worker.IsBackground = true;
  worker.Start();
}

如果调用此程序时没有参数,则工作线程假设前台状态,并将等待在ReadLine语句上,等待用户按 Enter 键。与此同时,主线程退出,但应用程序仍在运行,因为前台线程仍然活动。另一方面,如果将参数传递给Main(),则工作线程被分配为后台状态,当主线程结束时,程序几乎立即退出(终止ReadLine)。

当以这种方式终止进程时,后台线程执行堆栈中的任何finally块都会被绕过。如果你的程序使用finally(或using)块执行清理工作,如删除临时文件,你可以通过显式等待这些后台线程在应用程序退出时结束,或者使用信号传递构造(参见“信号传递”)来避免这种情况。在任一情况下,你都应该指定一个超时时间,以便在线程拒绝结束时可以放弃一个“叛徒”线程;否则,你的应用程序将无法在用户未经任务管理器帮助的情况下关闭(或在 Unix 上使用kill命令)。

前台线程不需要这种处理,但是你必须小心,避免可能导致线程不结束的错误。导致应用程序未能正确退出的常见原因是存在活动的前台线程。

线程优先级

线程的Priority属性决定了它在操作系统中相对于其他活动线程分配的执行时间,采用以下刻度:

enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }

当多个线程同时活动时,这变得很重要。提高线程优先级时要小心,因为它可能会使其他线程饿死。如果你希望一个线程的优先级高于其他进程中的线程,还必须使用System.Diagnostics中的Process类提升进程优先级:

using Process p = Process.GetCurrentProcess();
p.PriorityClass = ProcessPriorityClass.High;

这在需要低延迟(即快速响应能力)进行最小工作的非 UI 进程中表现良好。对于计算密集型应用程序(特别是具有用户界面的应用程序),提高进程优先级可能会使其他进程饿死,从而减慢整个计算机的速度。

信号传递

有时候,你需要一个线程等待,直到接收到其他线程的通知。这被称为信号传递。最简单的信号传递构造是ManualReset​Event。在ManualResetEvent上调用WaitOne会阻塞当前线程,直到另一个线程通过调用Set来“打开”信号。在以下示例中,我们启动一个等待ManualResetEvent的线程。它将在主线程信号它之前保持阻塞两秒钟:

var signal = new ManualResetEvent (false);

new Thread (() =>
{
  Console.WriteLine ("Waiting for signal...");
  signal.WaitOne();
  signal.Dispose();
  Console.WriteLine ("Got signal!");
}).Start();

Thread.Sleep(2000);
signal.Set();        // “Open” the signal

调用Set后,信号保持打开状态;可以通过调用Reset再次关闭它。

ManualResetEvent是 CLR 提供的几种信号传递构造之一;我们在第二十一章中详细介绍它们。

丰富客户端应用程序中的线程管理

在 WPF、UWP 和 Windows Forms 应用程序中,在主线程上执行长时间运行的操作会使应用程序响应变慢,因为主线程还处理渲染、处理键盘和鼠标事件的消息循环。

一种常见的方法是为耗时操作启动“工作”线程。工作线程上的代码运行一个耗时操作,然后在完成时更新 UI。然而,所有丰富客户端应用程序都有一个线程模型,即 UI 元素和控件只能从创建它们的线程(通常是主 UI 线程)访问。违反此规则会导致不可预测的行为或引发异常。

因此,当您希望从工作线程更新 UI 时,必须将请求转发到 UI 线程(技术术语是“marshal”)。这样做的低级方法如下(稍后我们将讨论建立在此基础上的其他解决方案):

  • 在 WPF 中,调用元素的 Dispatcher 对象的 BeginInvokeInvoke 方法。

  • 在 UWP 应用中,调用 Dispatcher 对象的 RunAsyncInvoke 方法。

  • 在 Windows Forms 中,调用控件的 BeginInvokeInvoke 方法。

所有这些方法都接受一个委托,该委托引用您要运行的方法。BeginInvoke/RunAsync 通过将委托排入 UI 线程的 消息队列(处理键盘、鼠标和定时器事件的相同队列)来工作。Invoke 做同样的事情,但会阻塞,直到消息被 UI 线程读取和处理。因此,Invoke 允许您从方法中获取返回值。如果不需要返回值,建议使用 BeginInvoke/RunAsync,因为它们不会阻塞调用者,也不会引入死锁的可能性(参见 “死锁”)。

注意

您可以想象当调用 Application.Run 时,以下伪代码会执行:

while (!*thisApplication.Ended*)
{
 *wait for something to appear in message queue*
 *Got something: what kind of message is it?*
 *Keyboard/mouse message -> fire an event handler*
 *User* BeginInvoke *message -> execute delegate*
 *User **Invoke** message -> execute delegate & post result*
}

正是这种循环方式使工作线程能够将委托调度到 UI 线程执行。

例如,假设我们有一个包含名为 txtMessage 的文本框的 WPF 窗口,我们希望在执行耗时任务(通过调用 Thread.Sleep 模拟)后由工作线程更新其内容。下面是我们如何做到的:

partial class MyWindow : Window
{
  public MyWindow()
  {
    InitializeComponent();
    new Thread (Work).Start();
  }

  void Work()
  {
    Thread.Sleep (5000);           // Simulate time-consuming task
    UpdateMessage ("The answer");
  }

  void UpdateMessage (string message)
  {
    Action action = () => txtMessage.Text = message;
    Dispatcher.BeginInvoke (action);
  }
}

运行此代码会立即显示一个响应迅速的窗口。五秒钟后,它会更新文本框的内容。对于 Windows Forms,代码类似,只是我们调用(窗体的)BeginInvoke 方法:

  void UpdateMessage (string message)
  {
    Action action = () => txtMessage.Text = message;
    this.BeginInvoke (action);
  }

同步上下文

System.ComponentModel 命名空间中,有一个名为 SynchronizationContext 的类,它实现了线程调度的通用化。

移动和桌面端的丰富客户端 API(UWP、WPF 和 Windows Forms)各自定义并实例化 SynchronizationContext 的子类,您可以通过静态属性 SynchronizationContext.Current(在 UI 线程上运行时)获取它。捕获此属性后,您可以稍后从工作线程“发布”到 UI 控件:

partial class MyWindow : Window
{
  SynchronizationContext _uiSyncContext;

  public MyWindow()
  {
    InitializeComponent();
    // Capture the synchronization context for the current UI thread:
    _uiSyncContext = SynchronizationContext.Current;
    new Thread (Work).Start();
  }

  void Work()
  {
    Thread.Sleep (5000);           // Simulate time-consuming task
    UpdateMessage ("The answer");
  }

  void UpdateMessage (string message)
  {
    // Marshal the delegate to the UI thread:
    _uiSyncContext.Post (_ => txtMessage.Text = message, null);
  }
}

这对所有富客户端用户界面 API 都非常有用。

调用Post等同于在DispatcherControl上调用BeginInvoke;还有一个等同于InvokeSend方法。

线程池

每当启动线程时,都会花费几百微秒来组织诸如新的局部变量堆栈之类的内容。线程池通过具有预创建的可重用线程池减少此开销。线程池对于高效的并行编程和细粒度并发至关重要;它允许短操作运行而无需被线程启动的开销所淹没。

在使用池化线程时需要注意几点:

  • 无法设置池化线程的Name,这会使调试变得更加困难(虽然在 Visual Studio 的线程窗口中调试时可以附加描述)。

  • 池化线程始终是后台线程

  • 阻塞池化线程可能会降低性能(参见“线程池中的卫生”)。

可以自由更改池化线程的优先级——释放回池中时将恢复为正常状态。

您可以通过属性Thread.CurrentThread.IsThreadPoolThread确定当前是否在池化线程上执行。

进入线程池

显式在池化线程上运行某些内容的最简单方法是使用Task.Run(我们将在后续章节中详细介绍):

// Task is in System.Threading.Tasks
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));

因为在 .NET Framework 4.0 之前不存在任务,常见的替代方法是调用ThreadPool.QueueUserWorkItem

ThreadPool.QueueUserWorkItem (notUsed => Console.WriteLine ("Hello"));
注意

以下情况隐含使用线程池:

  • ASP.NET Core 和 Web API 应用服务器

  • System.Timers.TimerSystem.Threading.Timer

  • 我们在第二十二章中描述的并行编程构造

  • (传统的)BackgroundWorker

线程池中的卫生

线程池还有另一个功能,即确保临时的计算密集型工作过多时不会导致 CPU超订阅。超订阅是指活动线程比 CPU 核心更多的情况,操作系统必须对线程进行时间切片。超订阅会降低性能,因为时间切片需要昂贵的上下文切换,并且可能使 CPU 缓存无效化,而这对于提供现代处理器性能至关重要。

CLR 通过排队任务和限制其启动来防止线程池超订阅。它首先运行与硬件核心数相同数量的并发任务,然后通过爬坡算法调整并发级别,在特定方向上不断调整工作负载。如果吞吐量提高,则继续沿着同一方向进行(否则将反转)。这确保它始终跟踪最佳性能曲线——即使面对计算机上的竞争进程活动。

如果满足以下两个条件,CLR 的策略效果最佳:

  • 工作项通常是短时间运行的(< 250 ms,理想情况下是 < 100 ms),这样 CLR 就有充足的机会来测量和调整。

  • 大部分时间都处于阻塞状态的作业不会主导线程池。

阻塞是麻烦的,因为它会让 CLR 误以为它正在加载 CPU。CLR 足够智能,可以检测并补偿(通过向池中注入更多线程),尽管这可能使池子容易受到后续过度订阅的影响。它还会引入延迟,因为 CLR 会限制注入新线程的速率,尤其是在应用程序生命周期的早期(特别是在客户操作系统上,因为它偏向低资源消耗)。

在想要充分利用 CPU 时,线程池中保持良好的卫生特别重要(例如,通过 第二十二章 中的并行编程 API)。

任务

线程是创建并发的低级工具,因此它有一些限制,特别是以下内容:

  • 虽然很容易将数据传递给启动的线程,但没有简单的方法可以从你 Join 的线程中获取“返回值”。你需要设置某种共享字段。如果操作抛出异常,捕获和传播该异常同样是痛苦的。

  • 当线程完成任务后,你不能告诉它开始其他操作;相反,你必须使用 Join 方法(在此过程中会阻塞你自己的线程)。

这些限制不利于细粒度并发;换句话说,它们使得通过组合较小的操作来构建更大的并发操作变得困难(这对于我们在后续章节中讨论的异步编程至关重要)。这反过来又导致更多对手动同步(锁定、信号等)的依赖,以及相关问题。

直接使用线程也会影响性能,我们在 “线程池” 中讨论了这些影响。如果你需要运行数百或数千个并发的 I/O 绑定操作,基于线程的方法纯粹消耗数百或数千兆字节的内存作为线程开销。

Task 类帮助解决所有这些问题。与线程相比,Task 是更高级的抽象——它表示可能或可能不由线程支持的并发操作。任务是 可组合的(你可以通过 continuations 将它们链在一起)。它们可以使用 线程池 来减少启动延迟,并且通过 TaskCompletionSource,它们可以在等待 I/O 绑定操作时完全避免线程的回调方法。

Task 类型是在 Framework 4.0 中作为并行编程库的一部分引入的。然而,通过使用 awaiters,它们已经被增强以在更一般的并发场景中同样表现良好,并且是 C# 异步函数的后备类型。

注意

在本节中,我们忽略了专门用于并行编程的任务特性;相反,我们在第二十二章中详细介绍它们。

启动任务

启动由线程支持的Task最简单的方法是使用静态方法Task.RunTask类位于System.Threading.Tasks命名空间中)。只需传递一个Action委托:

Task.Run (() => Console.WriteLine ("Foo"));
注意

任务默认使用池化线程,这些线程是后台线程。这意味着当主线程结束时,您创建的任何任务也会结束。因此,要从控制台应用程序运行这些示例,您必须在启动任务后阻塞主线程(例如通过Wait任务或调用Console.ReadLine):

Task.Run (() => Console.WriteLine ("Foo"));
Console.ReadLine();

在书籍的 LINQPad 伴侣示例中,由于 LINQPad 进程保持后台线程活动,省略了Console.ReadLine

通过这种方式调用Task.Run类似于以下方式启动线程(但请注意我们随后讨论的线程池化影响):

new Thread (() => Console.WriteLine ("Foo")).Start();

Task.Run返回一个Task对象,我们可以用它来监视其进度,类似于Thread对象。(但请注意,我们在调用Task.Run后没有调用Start,因为此方法创建“热”任务;您可以使用Task的构造函数创建“冷”任务,尽管这在实践中很少这样做。)

您可以通过其Status属性跟踪任务的执行状态。

Wait

在任务上调用Wait会阻塞,直到它完成,相当于在线程上调用Join

Task task = Task.Run (() =>
{
  Thread.Sleep (2000);
  Console.WriteLine ("Foo");
});
Console.WriteLine (task.IsCompleted);  // False
task.Wait();  // Blocks until task is complete

Wait允许您可选地指定超时和取消令牌以提前结束等待(参见“取消”)。

长时间运行的任务

默认情况下,CLR 在池化线程上运行任务,这对于运行时间短且计算密集的工作非常理想。对于运行时间较长且阻塞操作(例如我们之前的示例),您可以禁止使用池化线程:

Task task = Task.Factory.StartNew (() => ...,
                                   TaskCreationOptions.LongRunning);
注意

在池化线程上运行一个长时间运行的任务不会引起问题;当您同时运行多个长时间运行且可能阻塞的任务时,性能可能会受到影响。在这种情况下,通常有比TaskCreationOptions.LongRunning更好的解决方案:

  • 如果任务是 I/O 绑定的,TaskCompletionSource异步函数让您可以通过回调(继续)而不是线程来实现并发。

  • 如果任务是计算密集型的,生产者/消费者队列可以让您限制这些任务的并发性,避免其他线程和进程的饥饿(参见“编写生产者/消费者队列”)。

返回值

Task有一个泛型子类称为Task<TResult>,允许任务发出返回值。您可以通过使用Func<TRe⁠sult>委托(或兼容的 lambda 表达式)而不是Action调用Task.Run来获取Task<TResult>

Task<int> task = Task.Run (() => { Console.WriteLine ("Foo"); return 3; });
// ...

你可以通过查询Result属性稍后获取结果。如果任务还没有完成,访问这个属性会阻塞当前线程,直到任务完成:

int result = task.Result;      // Blocks if not already finished
Console.WriteLine (result);    // 3

在以下示例中,我们创建一个使用 LINQ 来计算前三百万(+2)个整数中的质数数量的任务:

Task<int> primeNumberTask = Task.Run (() =>
  Enumerable.Range (2, 3000000).Count (n => 
    Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));

Console.WriteLine ("Task running...");
Console.WriteLine ("The answer is " + primeNumberTask.Result);

这会输出“任务正在运行...”,然后几秒钟后输出答案 216816。

注意

Task<TResult>可以被看作是一个“future”,因为它封装了稍后会变得可用的Result

异常

与线程不同,任务可以方便地传播异常。因此,如果你的任务中的代码抛出了一个未处理的异常(换句话说,如果你的任务失败了),那么该异常会自动重新抛出给调用Wait()或访问Task<TResult>Result属性的代码:

// Start a Task that throws a NullReferenceException:
Task task = Task.Run (() => { throw null; });
try 
{
  task.Wait();
}
catch (AggregateException aex)
{
  if (aex.InnerException is NullReferenceException)
    Console.WriteLine ("Null!");
  else
    throw;
}

(CLR 会在与并行编程场景兼容的情况下用AggregateException包装异常;我们在第二十二章中讨论这个问题。)

你可以通过TaskIsFaultedIsCanceled属性测试任务是否失败而不重新抛出异常。如果两个属性都返回 false,表示没有错误;如果IsCanceled为 true,表示该任务因OperationCanceledException而取消(参见“Cancellation”);如果IsFaulted为 true,表示抛出了其他类型的异常,而Exception属性将指示错误信息。

异常和自主任务

对于自主的“设置和忘记”任务(即那些不通过Wait()Result进行会合的任务或进行相同操作的后续任务),明确地异常处理任务代码是个好习惯,以避免静默失败,就像处理线程一样。

注意

当异常仅仅表示无法获得你不再感兴趣的结果时,忽略异常是可以接受的。例如,如果用户取消了请求下载网页,那么如果发现网页不存在,我们也不会在意。

当异常指示程序中的错误时,忽略异常是有问题的,原因有两个:

  • 这个 bug 可能会使你的程序处于无效状态。

  • 更多异常可能会因为 bug 而稍后发生,而不记录初始错误会使诊断变得困难。

你可以通过静态事件TaskScheduler.UnobservedTaskException全局订阅未观察到的异常;处理这个事件并记录错误是明智的做法。

有几个有趣的细微差别关于什么算作未观察到的异常:

  • 如果在超时后发生故障,等待超时的任务会生成一个未观察到的异常。

  • 在任务faulted后检查任务的Exception属性会使异常被“观察到”。

后续任务

继续操作告诉任务:“完成后,请继续执行其他操作。”通常,继续操作由一个回调实现,该回调在操作完成后执行一次。有两种方法可以将继续操作附加到任务上。第一种特别重要,因为它被 C#的异步函数使用,很快您将看到。我们可以通过我们之前在“返回值”中编写的素数计数任务来演示它:

Task<int> primeNumberTask = Task.Run (() =>
  Enumerable.Range (2, 3000000).Count (n => 
    Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));

var awaiter = primeNumberTask.GetAwaiter();
awaiter.OnCompleted (() => 
{
  int result = awaiter.GetResult();
  Console.WriteLine (result);       // Writes result
});

调用任务上的GetAwaiter方法会返回一个awaiter对象,其OnCompleted方法告诉先行任务primeNumberTask),当其完成(或出错)时执行委托。可以将继续操作附加到已完成的任务上,此时继续操作将立即被安排执行。

注意

awaiter是指任何公开我们刚刚看到的两个方法(OnCompletedGetResult)及名为IsCompleted的布尔属性的对象。没有界面或基类可以统一所有这些成员(尽管OnCompleted是接口INotifyCompletion的一部分)。我们在“C#中的异步函数”中解释了此模式的重要性。

如果先行任务发生故障,则在继续操作代码调用awaiter.GetResult()时会重新抛出异常。而不是调用GetResult,我们可以简单地访问先行任务的Result属性。调用GetResult的好处在于,如果先行任务发生故障,则异常会直接抛出,而不会包装在AggregateException中,从而使catch块更简单和更清晰。

对于非泛型任务,GetResult()返回一个void值。它的有用功能就是重新抛出异常。

如果存在同步上下文,OnCompleted会自动捕获它,并将继续操作发布到该上下文中。这在富客户端应用程序中非常有用,因为它会将继续操作反弹回 UI 线程。然而,在编写库时,通常不希望这样做,因为相对昂贵的 UI 线程反弹应该仅在离开库时发生一次,而不是在方法调用之间。因此,可以通过使用ConfigureAwait方法来禁用它:

var awaiter = primeNumberTask.ConfigureAwait (false).GetAwaiter();

如果不存在同步上下文,或者使用了ConfigureAwait(false),则(通常情况下)继续操作将在一个池化线程上执行。

另一种附加继续操作的方法是调用任务的ContinueWith方法:

primeNumberTask.ContinueWith (antecedent => 
{
  int result = antecedent.Result;
  Console.WriteLine (result);          // Writes 123
});

ContinueWith 本身返回一个 Task,如果你想附加更多后续操作,这非常有用。然而,如果任务失败,你必须直接处理 AggregateException,并在 UI 应用程序中编写额外的代码来调度后续操作(参见 “任务调度器”)。在非 UI 上下文中,如果希望后续操作在同一线程上执行,必须指定 TaskContinuationOptions.ExecuteSynchronously;否则它将跳到线程池。ContinueWith 在并行编程场景中特别有用;我们在 第二十二章 中详细介绍它。

TaskCompletionSource

我们已经看到 Task.Run 如何创建一个在池化(或非池化)线程上运行委托的任务。另一种创建任务的方式是使用 TaskCompletionSource

TaskCompletionSource 允许你将任何在未来完成的操作转换为一个任务。它通过提供一个“从属”任务让你手动驱动——指示操作何时完成或失败。这对于 I/O 密集型工作非常理想:你获得了任务的所有好处(能够传播返回值、异常和后续操作),而不需要在操作期间阻塞线程。

要使用 TaskCompletionSource,只需实例化该类。它公开了一个 Task 属性,返回一个任务,你可以等待并附加后续操作——就像任何其他任务一样。然而,任务完全由 Task​Com⁠pletionSource 对象控制,通过以下方法:

public class TaskCompletionSource<TResult>
{
  public void SetResult (TResult result);
  public void SetException (Exception exception);
  public void SetCanceled();

  public bool TrySetResult (TResult result);
  public bool TrySetException (Exception exception);
  public bool TrySetCanceled();
  public bool TrySetCanceled (CancellationToken cancellationToken);
  ...
}

调用这些方法之一会触发任务,将其置于完成、失败或取消状态(我们在 “取消” 部分中涵盖了后者)。你应该仅调用其中一个方法一次:如果再次调用,Set​Re⁠sultSetExceptionSetCanceled 将抛出异常,而 Try* 方法将返回 false

下面的示例在等待五秒钟后打印出 42:

var tcs = new TaskCompletionSource<int>();

new Thread (() => { Thread.Sleep (5000); tcs.SetResult (42); })
  { IsBackground = true }
  .Start();

Task<int> task = tcs.Task;         // Our "slave" task.
Console.WriteLine (task.Result);   // 42

使用 TaskCompletionSource,我们可以编写自己的 Run 方法:

Task<TResult> Run<TResult> (Func<TResult> function)
{
  var tcs = new TaskCompletionSource<TResult>();
  new Thread (() => 
  {
    try { tcs.SetResult (function()); }
    catch (Exception ex) { tcs.SetException (ex); }
  }).Start();
  return tcs.Task;
}
...
Task<int> task = Run (() => { Thread.Sleep (5000); return 42; });

调用此方法等效于使用 Task.Factory.StartNew 并传递 TaskCreationOptions.LongRunning 选项来请求非池化线程。

TaskCompletionSource 的真正威力在于创建不会阻塞线程的任务。例如,考虑一个等待五秒钟然后返回数字 42 的任务。我们可以使用 Timer 类来实现,它借助 CLR(和随之的操作系统)在 x 毫秒后触发事件(我们在 第二十一章 中重新审视定时器):

Task<int> GetAnswerToLife()
{
  var tcs = new TaskCompletionSource<int>();
  // Create a timer that fires once in 5000 ms:
  var timer = new System.Timers.Timer (5000) { AutoReset = false };
  timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (42); };
  timer.Start();
  return tcs.Task;
}

因此,我们的方法返回一个任务,五秒钟后完成,结果为 42。通过将后续操作附加到任务上,我们可以输出其结果而不阻塞任何线程:

var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult()));

我们可以通过参数化延迟时间并去除返回值来使这更有用,并将其转化为通用的Delay方法。这意味着将其返回一个Task而不是Task<int>。然而,TaskCompletionSource没有非泛型版本,这意味着我们不能直接创建非泛型Task。解决方法很简单:因为Task<TResult>派生自Task,我们创建一个TaskCompletionSource<*anything*>,然后将它给你的Task<*anything*>隐式转换为Task,就像这样:

var tcs = new TaskCompletionSource<object>();
Task task = tcs.Task;

现在我们可以编写我们的通用Delay方法:

Task Delay (int milliseconds)
{
  var tcs = new TaskCompletionSource<object>();
  var timer = new System.Timers.Timer (milliseconds) { AutoReset = false };
  timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (null); };
  timer.Start();
  return tcs.Task;
}
注意

.NET 5 引入了一个非泛型的TaskCompletionSource,所以如果你的目标是.NET 5 或更高版本,你可以用TaskCompletionSource<object>替代TaskCompletionSource

以下是如何在五秒钟后写入“42”的方法:

Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));

我们在没有线程的情况下使用TaskCompletionSource,这意味着只有当续体启动时,即五秒后才会涉及线程。我们可以通过同时启动 10,000 个这样的操作来演示这一点,而不会出错或消耗过多资源。

for (int i = 0; i < 10000; i++)
  Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
注意

定时器在池化线程上触发它们的回调,因此五秒后,线程池将收到 10,000 个请求,要求在TaskCompletionSource上调用SetResult(null)。如果请求到达的速度超过它们可以处理的速度,线程池将通过以最佳的并行性水平排队和处理它们来响应。这在线程绑定的作业运行时间短的情况下是理想的,本例中属实:线程绑定的作业仅仅是调用SetResult再加上将继续对象发布到同步上下文(在 UI 应用程序中)或者是继续对象本身(Console.WriteLine(42))的动作。

Task.Delay

我们刚刚编写的Delay方法非常有用,它作为Task类的静态方法提供:

Task.Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));

或:

Task.Delay (5000).ContinueWith (ant => Console.WriteLine (42));

Task.DelayThread.Sleep异步等价物。

异步原则

在演示TaskCompletionSource时,我们最终编写了异步方法。在本节中,我们确切定义了异步操作,并解释了这如何导致异步编程。

同步与异步操作

同步操作在返回给调用者之前完成其工作。

异步操作可以在返回给调用者之后完成其(大部分或全部)工作。

您编写和调用的大多数方法都是同步的。例如List<T>.AddConsole.WriteLineThread.Sleep。异步方法较少见并引发并发,因为工作并行进行。异步方法通常会快速(或立即)返回给调用者;因此,它们也称为非阻塞方法

到目前为止,我们看到的大多数异步方法可以描述为通用方法:

  • Thread.Start

  • Task.Run

  • 将续体附加到任务的方法

此外,我们在 “同步上下文” 中讨论的一些方法(Dispatcher.BeginInvokeControl.BeginInvokeSynchronizationContext.Post)是异步的,我们在 “TaskCompletionSource” 中编写的方法也是如此,包括 Delay

什么是异步编程?

异步编程的原则是您以异步方式编写长时间运行(或潜在长时间运行)的函数。这与传统的同步编写长时间运行函数的方法形成对比,然后从新线程或任务中调用这些函数以引入所需的并发性。

与异步方法的区别在于,并发是从长时间运行的函数内部 启动 而不是从函数 外部 启动。这有两个好处:

  • 可以实现不捆绑线程的 I/O 绑定并发性(正如我们在 “TaskCompletionSource” 中演示的),从而改善可伸缩性和效率。

  • 富客户端应用程序最终在工作线程上的代码量减少,简化了线程安全性。

这反过来导致异步编程有两个明显的用途。第一个是编写(通常是服务器端)应用程序,可以有效地处理大量并发的 I/O。这里的挑战不是线程 安全性(因为通常共享状态很少),而是线程 效率;特别是不要为每个网络请求消耗一个线程。因此,在这种情况下,只有 I/O 绑定的操作才能从异步中受益。

第二个用途是简化富客户端应用程序中的线程安全性。这在程序规模增大时特别重要,因为为了处理复杂性,我们通常将较大的方法重构为较小的方法,导致相互调用的方法链(调用图)。

使用传统的 同步 调用图,如果图中的任何操作耗时较长,我们必须在工作线程上运行整个调用图以保持响应的用户界面。因此,我们最终会得到一个跨越多个方法的单个并发操作(粗粒度并发),这需要考虑图中每个方法的线程安全性。

使用 异步 调用图,我们不需要在实际需要之前启动线程,通常在图的较低部分(或者在 I/O 绑定操作的情况下根本不需要)。所有其他方法都可以完全在 UI 线程上运行,线程安全性大大简化。这导致了 细粒度并发 ——一系列小的并发操作,其中执行在 UI 线程之间反弹。

注意

要从中受益,I/O 和计算绑定的操作都需要以异步方式编写;一个很好的经验法则是包括任何可能超过 50 毫秒的操作。

(另一方面,过度 细粒度的异步可能会损害性能,因为异步操作会产生开销——参见 “优化”。)

在本章中,我们主要关注更复杂的富客户端场景。在 第十六章 中,我们给出了两个示例,说明了 I/O 密集型场景(参见 “使用 TCP 进行并发” 和 “编写 HTTP 服务器”)。

注意

UWP 框架鼓励异步编程,以至于某些长时间运行的方法的同步版本要么不公开,要么会抛出异常。因此,您必须调用返回任务的异步方法(或可以通过 AsTask 扩展方法转换为任务的对象)。

异步编程和连续性

任务非常适合异步编程,因为它们支持连续性,这对于异步性是至关重要的(考虑我们在 TaskCompletionSource 中编写的 Delay 方法)。在编写 Delay 方法时,我们使用了 TaskCompletionSource,这是实现“底层”I/O 密集型异步方法的一种标准方式。

对于计算密集型方法,我们使用 Task.Run 来启动线程绑定的并发。通过将任务返回给调用方,我们简单地创建了一个异步方法。异步编程的区别在于,我们的目标是在调用图中较低的位置执行此操作,以便在富客户端应用程序中,高级方法可以保持在 UI 线程上,并访问控件和共享状态而无需担心线程安全问题。例如,考虑以下计算和计数素数的方法,利用所有可用的核心(我们在 第二十二章 中讨论了 ParallelEnumerable):

int GetPrimesCount (int start, int count)
{
  return
    ParallelEnumerable.Range (start, count).Count (n => 
      Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0));
}

这如何运行的细节并不重要;重要的是可能需要一段时间来运行。我们可以通过编写另一个方法来演示这一点:

void DisplayPrimeCounts()
{
  for (int i = 0; i < 10; i++)
    Console.WriteLine (GetPrimesCount (i*1000000 + 2, 1000000) +
      " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
  Console.WriteLine ("Done!");
}

这里是输出:

78498 primes between 0 and 999999
70435 primes between 1000000 and 1999999
67883 primes between 2000000 and 2999999
66330 primes between 3000000 and 3999999
65367 primes between 4000000 and 4999999
64336 primes between 5000000 and 5999999
63799 primes between 6000000 and 6999999
63129 primes between 7000000 and 7999999
62712 primes between 8000000 and 8999999
62090 primes between 9000000 and 9999999

现在我们有一个 调用图,其中 DisplayPrimeCounts 调用 GetPrimesCount。为简单起见,后者使用 Console.WriteLine,尽管实际上在富客户端应用程序中更可能是更新 UI 控件,正如我们后面演示的那样。我们可以为此调用图启动粗粒度并发,如下所示:

Task.Run (() => DisplayPrimeCounts());

使用精细粒度的异步方法,我们首先编写 GetPrimesCount 的异步版本:

Task<int> GetPrimesCountAsync (int start, int count)
{
  return Task.Run (() =>
    ParallelEnumerable.Range (start, count).Count (n => 
      Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0)));
}

为何语言支持如此重要

现在我们必须修改 DisplayPrimeCounts,使其调用 GetPrimesCount**Async**。这就是 C# 的 awaitasync 关键字发挥作用的地方,因为否则这样做比听起来更加棘手。如果我们简单地修改循环如下:

for (int i = 0; i < 10; i++)
{
  var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
  awaiter.OnCompleted (() =>
    Console.WriteLine (awaiter.GetResult() + " primes between... "));
}
Console.WriteLine ("Done");

循环将快速通过 10 次迭代(方法为非阻塞),并且所有 10 个操作将并行执行(随后为过早的 “完成”)。

注意

在这种情况下并行执行这些任务是不可取的,因为它们的内部实现已经并行化;这只会使我们等待更长时间才能看到第一个结果(并且会混乱顺序)。

但有一个更常见的原因需要串行化任务的执行,那就是任务 B 依赖于任务 A 的结果。例如,在获取网页时,DNS 查找必须在 HTTP 请求之前进行。

要使它们按顺序运行,必须从继续本身触发下一个循环迭代。这意味着消除for循环,并在继续中采用递归调用:

void DisplayPrimeCounts()
{
  DisplayPrimeCountsFrom (0);
}

void DisplayPrimeCountsFrom (int i)
{
  var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();
  awaiter.OnCompleted (() => 
  {
    Console.WriteLine (awaiter.GetResult() + " primes between...");
    if (++i < 10) DisplayPrimeCountsFrom (i);
    else Console.WriteLine ("Done");
  });
}

如果我们想使DisplayPrimesCount 本身 异步运行,并返回一个任务以在完成时发出信号,情况会变得更糟。要实现这一点,需要创建一个TaskCompletionSource

Task DisplayPrimeCountsAsync()
{
  var machine = new PrimesStateMachine();
  machine.DisplayPrimeCountsFrom (0);
  return machine.Task;
}

class PrimesStateMachine
{
  TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();
  public Task Task { get { return _tcs.Task; } }

  public void DisplayPrimeCountsFrom (int i)
  {
    var awaiter = GetPrimesCountAsync (i*1000000+2, 1000000).GetAwaiter();
    awaiter.OnCompleted (() => 
    {
      Console.WriteLine (awaiter.GetResult());
      if (++i < 10) DisplayPrimeCountsFrom (i);
      else { Console.WriteLine ("Done"); _tcs.SetResult (null); }
    });
  }
}

幸运的是,C#的异步函数已经为我们完成了所有这些工作。使用asyncawait关键字,我们只需编写如下代码:

async Task DisplayPrimeCountsAsync()
{
  for (int i = 0; i < 10; i++)
    Console.WriteLine (await GetPrimesCountAsync (i*1000000 + 2, 1000000) +
      " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));
  Console.WriteLine ("Done!");
}

因此,asyncawait对于实现异步性而又不至于过于复杂至关重要。现在让我们看看这些关键字是如何工作的。

注意

另一种看待这个问题的方式是,命令式循环结构(例如forforeach等)与继续(continuations)不太兼容,因为它们依赖于方法的当前本地状态(“这个循环还要运行多少次?”)。

虽然asyncawait关键字提供了一种解决方案,但有时可以通过将命令式循环结构替换为函数式等效物(即 LINQ 查询)的另一种方式来解决问题。这是响应式扩展(Rx)的基础,当您希望在结果上执行查询操作或组合多个序列时,可以是一个不错的选择。为了避免阻塞,Rx 通过基于推送的序列运行,这在概念上可能会有些棘手。

C#中的异步函数

asyncawait关键字使您能够编写具有与同步代码相同结构和简单性的异步代码,同时消除了异步编程的“管道工程”。

等待

await关键字简化了附加继续的过程。从基本场景开始,编译器扩展为:

var *result* = await *expression*;
*statement(s)*;

转换为类似以下功能的东西:

var awaiter = *expression*.GetAwaiter();
awaiter.OnCompleted (() => 
{
  var *result* = awaiter.GetResult();
  *statement(s)*;
});
注意

编译器还会生成代码,以在同步完成时快速终止继续(参见“优化”),并处理我们在后面章节中掌握的各种微妙之处。

为了演示,让我们重新审视之前编写的异步方法,计算并计数质数:

Task<int> GetPrimesCountAsync (int start, int count)
{
  return Task.Run (() =>
    ParallelEnumerable.Range (start, count).Count (n => 
      Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));
}

使用await关键字,我们可以如下调用它:

int result = await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);

要编译,我们需要在包含的方法上添加async修饰符:

async void DisplayPrimesCount()
{
  int result = await GetPrimesCountAsync (2, 1000000);
  Console.WriteLine (result);
}

async修饰符指示编译器将await视为关键字,而不是标识符,这可以确保在该方法内部可能出现await作为标识符的代码仍然可以编译而不会出错。async修饰符只能应用于返回void或(稍后将看到的)TaskTask<TResult>的方法(和 lambda 表达式)。

注意

async修饰符类似于unsafe修饰符,因为它对方法的签名或公共元数据没有影响;它只影响方法内部的操作。因此,在接口中使用async是没有意义的。然而,例如,在重写非async虚拟方法时引入async是合法的,只要保持签名相同。

带有async修饰符的方法被称为异步函数,因为它们本身通常是异步的。要了解原因,让我们看看执行如何通过异步函数进行。

在遇到await表达式时,执行(通常)会返回给调用者,就像在迭代器中使用yield return一样。但在返回之前,运行时会将一个续约(continuation)附加到等待的任务上,确保任务完成时,执行会跳回方法中,并继续之前的执行点。如果任务失败,它的异常会被重新抛出,否则其返回值会被赋给await表达式。我们可以通过查看我们刚刚检查的异步方法的逻辑扩展来总结我们刚刚说的一切:

void DisplayPrimesCount()
{
  var awaiter = GetPrimesCountAsync (2, 1000000).GetAwaiter();
  awaiter.OnCompleted (() =>    
  {
    int result = awaiter.GetResult();
    Console.WriteLine (result);
  });
}

await的表达式通常是一个任务(task);然而,任何具有返回一个awaiter(实现了INotifyCompletion.OnCompleted,具有适当类型的GetResult方法和bool IsCompleted属性)的GetAwaiter方法的对象都将满足编译器的要求。

注意,我们的await表达式评估为int类型;这是因为我们等待的表达式是一个Task<int>(其GetAwaiter().GetResult()方法返回int)。

等待一个非泛型任务是合法的,并生成一个void表达式:

await Task.Delay (5000);
Console.WriteLine ("Five seconds passed!");

捕获本地状态

await表达式的真正威力在于它们几乎可以出现在代码的任何地方。具体来说,在异步函数中,await表达式可以出现在任何表达式的位置,除了在lock语句或unsafe上下文内部。

在以下示例中,我们在循环中使用await

async void DisplayPrimeCounts()
{
  for (int i = 0; i < 10; i++)
    Console.WriteLine (await GetPrimesCountAsync (i*1000000+2, 1000000));
}

在首次执行GetPrimesCountAsync时,由于await表达式的存在,执行返回给调用者。当方法完成(或失败)时,执行会在之前暂停的地方恢复,本地变量和循环计数器的值保持不变。

如果没有await关键字,最简单的等价可能是我们在“为什么语言支持很重要”中编写的示例。然而,编译器更倾向于将这样的方法重构为状态机(类似于处理迭代器)。

编译器依赖于继续(通过等待器模式)来在 await 表达式后恢复执行。这意味着,如果在富客户端应用程序的 UI 线程上运行,同步上下文确保执行恢复在同一线程上。否则,执行将恢复在任务完成时的任何线程上。线程的更改不会影响执行顺序,并且在不依赖线程亲和性的情况下影响不大,可能通过线程本地存储(参见“线程本地存储”)进行依赖。这就像在城市中游览并招手拦出租车从一个地方到另一个地方。有同步上下文时,您总会得到相同的出租车;没有同步上下文时,您通常会每次都得到不同的出租车。不过,不管哪种情况,旅程都是一样的。

在 UI 中等待

我们可以通过编写一个简单的 UI,在调用计算绑定方法时保持响应性,更实际地演示异步函数。让我们从同步解决方案开始:

class TestUI : Window
{
  Button _button = new Button { Content = "Go" };
  TextBlock _results = new TextBlock();

  public TestUI()
  {
    var panel = new StackPanel();
    panel.Children.Add (_button);
    panel.Children.Add (_results);
    Content = panel;
    _button.Click += (sender, args) => Go();
  }

  void Go()
  {
    for (int i = 1; i < 5; i++)
      _results.Text += GetPrimesCount (i * 1000000, 1000000) +
        " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
        Environment.NewLine;
  }

  int GetPrimesCount (int start, int count)
  {
    return ParallelEnumerable.Range (start, count).Count (n => 
      Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0));
  }
}

按下“Go”按钮后,应用程序在执行计算绑定代码时变得无响应。在将此异步化的两个步骤中,第一步是切换到我们在之前示例中使用的 GetPrimesCount 的异步版本:

Task<int> GetPrimesCountAsync (int start, int count)
{
  return Task.Run (() =>
    ParallelEnumerable.Range (start, count).Count (n => 
      Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0)));
}

第二步是修改 Go 方法调用 GetPrimesCountAsync

async void Go()
{
  _button.IsEnabled = false;
  for (int i = 1; i < 5; i++)
    _results.Text += await GetPrimesCountAsync (i * 1000000, 1000000) +
      " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +
      Environment.NewLine;
  _button.IsEnabled = true;
}

这说明了使用异步函数编程的简单性:您编写的方式与同步编程相同,但调用异步函数而不是阻塞函数,并且使用 await 等待它们。只有 GetPrimesCountAsync 中的代码在工作线程上运行;而 Go 中的代码则“租赁”了 UI 线程的时间。我们可以说 Go 在消息循环中 伪并发 执行(即其执行与 UI 线程处理的其他事件交织在一起)。在这种伪并发中,唯一可能发生抢占的时刻是在 await 期间。这简化了线程安全性:在我们的情况下,这可能导致的唯一问题是 重入(在运行时再次点击按钮,我们通过禁用按钮来防止这种情况)。真正的并发发生在调用 Task.Run 的调用堆栈较低处,以确保此模型的受益,真正的并发代码禁止访问共享状态或 UI 控件。

举例来说,假设我们不是计算素数,而是要下载几个网页并计算它们的长度。.NET 提供了许多返回任务的异步方法之一是 System.Net 中的 WebClient 类。DownloadDataTaskAsync 方法异步下载 URI 到字节数组,返回一个 Task<byte[]>,因此通过等待它,我们得到一个 byte[]。现在让我们重新编写我们的 Go 方法:

async void Go() 
{
  _button.IsEnabled = false;
  string[] urls = "www.albahari.com www.oreilly.com www.linqpad.net".Split();
  int totalLength = 0;
  try
  {
    foreach (string url in urls)
    {
      var uri = new Uri ("http://" + url);
      byte[] data = await new WebClient().DownloadDataTaskAsync (uri);
      _results.Text += "Length of " + url + " is " + data.Length +
                       Environment.NewLine;
      totalLength += data.Length;
    }
    _results.Text += "Total length: " + totalLength;
  }
  catch (WebException ex)
  {
    _results.Text += "Error: " + ex.Message;
  }
  finally { _button.IsEnabled = true; }
}

再次,这反映了我们同步编写它的方式,包括使用catchfinally块。尽管执行在第一个await后返回到调用者,但finally块直到方法逻辑上完成(通过所有代码执行或早期的return或未处理的异常)才执行。

考虑到底层正在发生的事情可能会有所帮助。首先,我们需要重新访问在 UI 线程上运行消息循环的伪代码:

*Set synchronization context for this thread to WPF sync context*
while (!*thisApplication.Ended*)
{
 *wait for something to appear in message queue*
 *Got something: what kind of message is it?*
 *Keyboard/mouse message -> fire an event handler*
 *User **BeginInvoke/Invoke** message -> execute delegate*
}

我们附加到 UI 元素的事件处理程序通过此消息循环执行。当我们的Go方法运行时,执行将继续到await表达式,然后返回到消息循环(使 UI 能够响应进一步的事件)。然而,await的编译器扩展确保在返回之前设置一个继续,以便在任务完成时执行恢复执行到离开的地方。并且因为我们在 UI 线程上等待,所以继续通过同步上下文发布,通过消息循环执行它,使我们整个Go方法在 UI 线程上伪并发执行。真正的(I/O 绑定)并发发生在DownloadDataTaskAsync的实现中。

与粗粒度并发比较

在 C# 5 之前,异步编程很困难,不仅因为没有语言支持,而且因为 .NET Framework 通过笨拙的模式(称为 EAP 和 APM,参见“过时模式”)暴露了异步功能,而不是返回任务的方法。

流行的解决方法是粗粒度并发(事实上,甚至还有一种称为BackgroundWorker的类型来帮助处理)。回到我们最初的同步示例GetPrimesCount,我们可以通过修改按钮的事件处理程序来演示粗粒度异步,如下所示:

  ...
  _button.Click += (sender, args) =>
  {
    _button.IsEnabled = false;
    Task.Run (() => Go());
  };

(我们选择使用Task.Run而不是BackgroundWorker,因为后者对我们特定的示例没有简化作用。)无论哪种情况,最终结果是我们整个同步调用图(Go加上GetPrimesCount)都在工作线程上运行。并且因为Go更新 UI 元素,我们现在必须在代码中散布Dispatcher.BeginInvoke

void Go()
{
  for (int i = 1; i < 5; i++)
  {
    int result = GetPrimesCount (i * 1000000, 1000000);
    Dispatcher.BeginInvoke (new Action (() =>
      _results.Text += result + " primes between " + (i*1000000) +
      " and " + ((i+1)*1000000-1) + Environment.NewLine));
  }
  Dispatcher.BeginInvoke (new Action (() => _button.IsEnabled = true));
}

与异步版本不同,循环本身在工作线程上运行。这看起来可能是无害的,然而,即使在这种简单情况下,我们的多线程使用也引入了竞争条件。(你能发现吗?如果不能,请尝试运行程序:几乎肯定会变得显而易见。)

实现取消和进度报告会增加线程安全错误的可能性,方法中的任何额外代码也会如此。例如,假设循环的上限不是硬编码的,而是来自方法调用:

  for (int i = 1; i < GetUpperBound(); i++)

现在假设GetUpperBound()从延迟加载的配置文件中读取值,在第一次调用时从磁盘加载。所有这些代码现在都在您的工作线程上运行,这段代码很可能不是线程安全的。这就是在调用图的高处启动工作线程的危险。

编写异步函数

对于任何异步函数,您可以将void返回类型替换为Task,使方法本身有用异步(并且可以await)。不需要进一步的更改:

async Task PrintAnswerToLife()   // We can return Task instead of void
{
  await Task.Delay (5000);
  int answer = 21 * 2;
  Console.WriteLine (answer);  
}

请注意,在方法体中我们并未显式返回任务。编译器会制造任务,并在方法完成时(或未处理的异常时)发出信号。这使得创建异步调用链变得容易:

async Task Go()
{
  await PrintAnswerToLife();
  Console.WriteLine ("Done");
}

因为我们已将Go声明为Task返回类型,所以Go本身是可等待的。

编译器会将返回任务的异步函数展开成使用TaskCompletionSource创建任务的代码,然后信号或故障。

除了细微差别,我们可以将PrintAnswerToLife扩展为以下功能等效形式:

Task PrintAnswerToLife()
{
  var tcs = new TaskCompletionSource<object>();
  var awaiter = Task.Delay (5000).GetAwaiter();
  awaiter.OnCompleted (() =>
  {
    try
    {
      awaiter.GetResult();    // Re-throw any exceptions
      int answer = 21 * 2;
      Console.WriteLine (answer);
      tcs.SetResult (null);
    }
    catch (Exception ex) { tcs.SetException (ex); }
  });
  return tcs.Task;
}

因此,每当返回任务的异步方法完成时,执行都会跳回到任何等待它的地方(通过延续)。

注:

在富客户端场景中,执行在此处回到 UI 线程(如果尚未在 UI 线程上)。否则,它会继续在连续体返回的任何线程上执行。这意味着在向上冒泡异步调用图时,除了第一次“弹跳”(如果是 UI 线程启动),没有延迟成本。

返回Task<TResult>

如果方法体返回TResult,则可以返回Task<TResult>

async Task<int> GetAnswerToLife()
{
  await Task.Delay (5000);
  int answer = 21 * 2;
  return answer;    // Method has return type Task<int> we return int
}

在内部,这会导致TaskCompletionSource用值而不是null被信号化。我们可以通过从Go调用它的方式演示GetAnswerToLife(而Go本身则从PrintAnswerToLife调用):

async Task Go()
{
  await PrintAnswerToLife();
  Console.WriteLine ("Done");
}

async Task PrintAnswerToLife()
{
  int answer = await GetAnswerToLife();
  Console.WriteLine (answer);
}

async Task<int> GetAnswerToLife()
{
  await Task.Delay (5000);
  int answer = 21 * 2;
  return answer;
}

实际上,我们将原始的PrintAnswerToLife重构为两种方法——与编程同步一样容易。与同步编程的相似性是有意的;这是我们调用图的同步等效,调用Go()在阻塞五秒后会得到相同的结果:

void Go()
{
  PrintAnswerToLife();
  Console.WriteLine ("Done");
}

void PrintAnswerToLife()
{
  int answer = GetAnswerToLife();
  Console.WriteLine (answer);
}

int GetAnswerToLife()
{
  Thread.Sleep (5000);
  int answer = 21 * 2;
  return answer;
}
注:

这也说明了如何设计带有异步函数的基本原理:

  1. 将您的方法同步编写。

  2. 异步方法调用替换同步方法调用,并对其进行await

  3. 除了“顶级”方法(通常是 UI 控件的事件处理程序),将您的异步方法的返回类型升级为TaskTask<TResult>以使它们可以被等待。

编译器为异步函数制造任务的能力意味着,在大多数情况下,您只需在启动 I/O 绑定并发的底层方法(相对罕见的情况)中显式实例化TaskCompletionSource。(对于启动计算绑定并发的方法,您可以使用Task.Run创建任务。)

异步调用图执行

要确切了解这是如何执行的,重新排列我们的代码会有所帮助:

async Task Go()
{
  var task = PrintAnswerToLife();
  await task; Console.WriteLine ("Done");
}

async Task PrintAnswerToLife()
{
  var task = GetAnswerToLife();
  int answer = await task; Console.WriteLine (answer);
}

async Task<int> GetAnswerToLife()
{
  var task = Task.Delay (5000);
  await task; int answer = 21 * 2; return answer;
}

Go调用PrintAnswerToLife,它调用GetAnswerToLife,后者调用Delay然后等待。await导致执行返回到PrintAnswerToLife,它本身在等待,返回到Go,它也在等待并返回到调用方。所有这些都是在调用Go的线程上同步发生的;这是执行的简短同步阶段。

五秒钟后,Delay上的延续触发,执行返回到池化线程上的Get​Ans⁠werToLife。(如果我们在 UI 线程上启动,执行现在会回到该线程。)然后GetAnswerToLife中的剩余语句运行,之后该方法的Task<int>完成并以 42 的结果执行PrintAnswerToLife中的延续,执行该方法中的其余语句。此过程持续,直到Go的任务标记为完成。

执行流程与我们之前展示的同步调用图匹配,因为我们遵循的模式是,在调用每个异步方法后立即await它。这创建了一个顺序流程,在调用图内部没有并行或重叠执行。每个await表达式在执行中创建了一个“间隙”,在此之后程序恢复到离开的位置。

并行性

调用异步方法而不等待它允许后续代码并行执行。您可能已经注意到在先前的示例中,我们有一个按钮,其事件处理程序调用了Go,如下所示:

_button.Click += (sender, args) => Go();

尽管Go是一个异步方法,但我们并没有等待它,这确实有助于维护响应式 UI 所需的并发性。

我们可以使用相同的原理来并行运行两个异步操作:

var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1; await task2;

(在之后等待这两个操作后,我们“结束”了此时的并行性。稍后,我们将描述WhenAll任务组合器如何处理这种模式。)

以这种方式创建的并发无论操作是否在 UI 线程上启动都会发生,尽管它们的发生方式有所不同。在这两种情况下,我们都会在启动它的底层操作(如Task.Delay或委托给Task.Run的代码)中得到相同的“真正”并发。如果调用堆栈中的方法在没有同步上下文的情况下启动操作,那么这些方法将仅在await语句处于伪并发状态下(并简化线程安全);这使我们能够在GetAnswerToLife中定义一个共享字段_x并增加它,而无需锁定:

async Task<int> GetAnswerToLife()
{
  _x++;
  await Task.Delay (5000);
  return 21 * 2;
}

(但我们无法假设在await之前和之后_x具有相同的值。)

异步 Lambda 表达式

就像普通的命名方法可以是异步的一样:

async Task NamedMethod()
{
  await Task.Delay (1000);
  Console.WriteLine ("Foo");
}

所以,如果前面加上async关键字,无名方法(Lambda 表达式和匿名方法)也可以:

Func<Task> unnamed = async () =>
{
  await Task.Delay (1000);
  Console.WriteLine ("Foo");
};

我们可以以相同的方式调用并等待这些内容:

await NamedMethod();
await unnamed();

当附加事件处理程序时,我们可以使用异步 lambda 表达式:

myButton.Click += async (sender, args) =>
{
  await Task.Delay (1000);
  myButton.Content = "Done";
};

这比下面具有相同效果的更为简洁:

myButton.Click += ButtonHandler;
...
async void ButtonHandler (object sender, EventArgs args)
{
  await Task.Delay (1000);
  myButton.Content = "Done";
};

异步 lambda 表达式也可以返回 Task<TResult>

Func<Task<int>> unnamed = async () =>
{
  await Task.Delay (1000);
  return 123;
};
int answer = await unnamed();

异步流

使用 yield return,您可以编写迭代器;使用 await,您可以编写异步函数。异步流(来自 C# 8)结合了这些概念,让您编写一个同时等待和异步产生元素的迭代器。此支持建立在以下一对接口之上,它们是我们在 “枚举和迭代器” 中描述的枚举接口的异步对应版本:

public interface IAsyncEnumerable<out T>
{
  IAsyncEnumerator<T> GetAsyncEnumerator (...);
}

public interface IAsyncEnumerator<out T>: IAsyncDisposable
{
  T Current { get; }
  ValueTask<bool> MoveNextAsync();
}

ValueTask<T> 是一个包装了 Task<T> 并在任务完成时行为类似于 Task<T> 的结构体(在枚举序列时经常发生)。参见 ValueTask<T> 讨论其区别。IAsyncDisposableIDisposable 的异步版本;它提供了执行清理操作的机会,如果您选择手动实现这些接口:

public interface IAsyncDisposable
{
  ValueTask DisposeAsync();
}
注意

从序列中获取每个元素的操作 (MoveNextAsync) 是一个异步操作,因此当元素逐个到达时,异步流非常适合(例如处理来自视频流的数据)。相比之下,以下类型在整体上延迟时更适合,但元素到达时会全部到达:

Task<IEnumerable<T>>

要生成异步流,您需要编写结合了迭代器和异步方法原理的方法。换句话说,您的方法应包括 yield returnawait,并且应返回 IAsyncEnumerable<T>

async IAsyncEnumerable<int> RangeAsync (
  int start, int count, int delay)
{
  for (int i = start; i < start + count; i++)
  {
    await Task.Delay (delay);
    yield return i;
  }
}

要消耗异步流,使用 await foreach 语句:

await foreach (var number in RangeAsync (0, 10, 500))
  Console.WriteLine (number);

请注意,数据稳定地每 500 毫秒到达一次(或在现实中,随着数据的可用性)。与使用 Task<IEnumerable<T>> 的类似结构相比,后者直到最后一个数据可用时才返回数据:

static async Task<IEnumerable<int>> RangeTaskAsync (int start, int count,
                                                    int delay)
{
  List<int> data = new List<int>();
  for (int i = start; i < start + count; i++)
  {
    await Task.Delay (delay);
    data.Add (i);
  }

  return data;
}

这是如何使用 foreach 语句消耗它的方法:

foreach (var data in await RangeTaskAsync(0, 10, 500))
  Console.WriteLine (data);

查询 IAsyncEnumerable

System.Linq.Async NuGet 包定义了在 IAsyncEnumerable<T> 上操作的 LINQ 查询操作符,允许您像使用 IEnumerable<T> 一样编写查询。

例如,我们可以编写一个 LINQ 查询,针对前面章节中定义的 RangeAsync 方法,如下所示:

IAsyncEnumerable<int> query =
  from i in RangeAsync (0, 10, 500)
  where i % 2 == 0   // Even numbers only.
  select i * 10;     // Multiply by 10.

await foreach (var number in query)
  Console.WriteLine (number);

这将输出 0、20、40 等。

注意

如果您熟悉 Rx,您还可以通过调用 ToObservable 扩展方法来获益,该方法转换 IAsyncEnumerable<T>IObservable<T>,从而使用其更强大的查询操作符。还有一个 ToAsyncEnumerable 扩展方法,可以反向转换。

在 ASP.Net Core 中的 IAsyncEnumerable

ASP.Net Core 控制器动作现在可以返回 IAsyncEnumerable<T>。这样的方法必须标记为 async。例如:

[HttpGet]
public async IAsyncEnumerable<string> Get()
{
    using var dbContext = new BookContext();
    await foreach (var title in dbContext.Books
                                         .Select(b => b.Title)
                                         .AsAsyncEnumerable())
       yield return title;
}

WinRT 中的异步方法

如果您正在开发 UWP 应用程序,则需要使用操作系统中定义的 WinRT 类型。WinRT 中 Task 的等效物是 IAsyncActionTask<TResult> 的等效物是 IAsyncOperation<TResult>。而对于报告进度的操作,等效物是 IAsyncActionWithProgress<TProgress>IAsyncOperationWithProgress<TResult, TProgress>。它们都定义在 Windows.Foundation 命名空间中。

您可以通过 AsTask 扩展方法从任一转换为 TaskTask<TResult>

Task<StorageFile> fileTask = KnownFolders.DocumentsLibrary.CreateFileAsync
                             ("test.txt").AsTask();

或者您可以直接等待它们:

StorageFile file = await KnownFolders.DocumentsLibrary.CreateFileAsync
                         ("test.txt");
注意

由于 COM 类型系统的限制,IAsyncActionWithProgress<TProgress>IAsyncOperationWithProgress<TResult, TProgress> 并不基于预期的 IAsyncAction。相反,两者都继承自称为 IAsyncInfo 的共同基类型。

AsTask 方法也重载为接受取消标记(参见 “Cancellation”)。当链接到 WithProgress 变体时,它还可以接受 IProgress<T> 对象(参见 “Progress Reporting”)。

异步和同步上下文

我们已经看到同步上下文的存在在提交延续方面是重要的。还有一些更微妙的方式,它们涉及空返回异步函数时的同步上下文。这些并不是 C# 编译器扩展的直接结果,而是编译器在扩展异步函数时使用的 System.CompilerServices 命名空间中的 Async*MethodBuilder 类型的功能。

异常发布

在富客户端应用程序中,依赖于中心异常处理事件(在 WPF 中为 Application.DispatcherUnhandledException)来处理 UI 线程上抛出的未处理异常是常见做法。在 ASP.NET Core 应用程序中,Startup.csConfigureServices 方法中的自定义 ExceptionFilterAttribute 也完成类似的工作。在内部,它们通过在它们自己的 try/catch 块中调用 UI 事件(或在 ASP.NET Core 中,页面处理方法的流水线)来工作。

顶级异步函数使这变得复杂。考虑以下按钮点击事件处理程序:

async void ButtonClick (object sender, RoutedEventArgs args)
{
  await Task.Delay(1000);
  throw new Exception ("Will this be ignored?");
}

当点击按钮并运行事件处理程序时,在 await 语句后执行将正常返回到消息循环,一秒后抛出的异常将无法被消息循环中的 catch 块捕获。

为了缓解这个问题,AsyncVoidMethodBuilder 在空返回异步函数中捕获未处理的异常,并在存在同步上下文时将它们发布到同步上下文中,以确保全局异常处理事件仍然触发。

注意

编译器仅对返回void的异步函数应用此逻辑。因此,如果我们将ButtonClick改为返回Task而不是void,未处理的异常将导致结果Task的故障,而这个任务将无处可去(导致未观察到的异常)。

一个有趣的细微差别是,无论在await之前还是之后抛出异常都没有区别。因此,在以下示例中,异常将被发布到同步上下文(如果存在),而不是调用者:

async void Foo() { throw null; await Task.Delay(1000); }

(如果没有同步上下文存在,则异常将在线程池上传播,从而终止应用程序。)

异常未直接抛回给调用者的原因是为了确保可预测性和一致性。在以下示例中,InvalidOperationException总是会导致结果Task的故障——不论*someCondition*如何:

async Task Foo()
{
  if (*someCondition*) await Task.Delay (100);
  throw new InvalidOperationException();
}

迭代器的工作方式类似:

IEnumerable<int> Foo() { throw null; yield return 123; }

在此示例中,异常不会直接抛回给调用者:直到序列被枚举时,异常才会被抛出。

OperationStarted 和 OperationCompleted

如果存在同步上下文,返回void的异步函数还会在进入函数时调用其OperationStarted方法,并在函数完成时调用其OperationCompleted方法。

覆盖这些方法对于为单元测试编写自定义同步上下文非常有用。这在Microsoft 的并行编程博客中有所讨论。

优化

同步完成

异步函数可以在等待之前返回。考虑以下方法,该方法缓存下载网页:

static Dictionary<string,string> _cache = new Dictionary<string,string>();

async Task<string> GetWebPageAsync (string uri)
{
  string html;
  if (_cache.TryGetValue (uri, out html)) return html;
  return _cache [uri] = 
    await new WebClient().DownloadStringTaskAsync (uri);
}

如果缓存中已存在 URI,执行将立即返回给调用者,而不会发生等待,并且方法会返回一个已标记的任务。这被称为同步完成

当您等待一个同步完成的任务时,执行不会返回给调用者并通过继续反弹;相反,它会立即继续到下一个语句。编译器通过检查等待器上的IsCompleted属性来实现此优化;换句话说,无论何时您等待

Console.WriteLine (await GetWebPageAsync ("http://oreilly.com"));

编译器会发出代码以在同步完成时短路继续:

var awaiter = GetWebPageAsync().GetAwaiter();
if (awaiter.IsCompleted)
  Console.WriteLine (awaiter.GetResult());
else
  awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult());
注意

等待一个返回同步完成的异步函数仍会产生(非常)小的开销——在 2019 年的 PC 上可能是 20 纳秒。

相比之下,切换到线程池会引入上下文切换的成本——可能是一到两微秒——而切换到 UI 消息循环,至少是其 10 倍(如果 UI 线程忙碌则更长)。

甚至可以编写永远不会await的异步方法,尽管编译器会生成警告:

async Task<string> Foo() { return "abc"; }

当重写虚拟/抽象方法时,如果您的实现恰好不需要异步性,这些方法可以非常有用。(例如MemoryStreamReadAsync/WriteAsync方法;见第十五章。)另一种实现相同结果的方法是使用Task.FromResult,它返回一个已经信号化的任务:

Task<string> Foo() { return Task.FromResult ("abc"); }

如果从 UI 线程调用,我们的GetWebPageAsync方法在隐式上是线程安全的,您可以连续多次调用它(从而启动多个并发下载),而无需锁定以保护缓存。然而,如果这些调用系列是对同一 URI 的,则最终会启动多个冗余下载,所有这些下载最终都会更新同一个缓存条目(最后一个赢得胜利)。虽然不是错误的,但如果后续对同一 URI 的调用能够(异步地)等待正在进行的请求结果,则效率会更高。

有一种简单的方法可以实现这一点——无需使用锁定或信号化结构。我们不创建字符串缓存,而是创建“未来”(Task<string>)的缓存:

static Dictionary<string,Task<string>> _cache = 
   new Dictionary<string,Task<string>>();

Task<string> GetWebPageAsync (string uri)
{
  if (_cache.TryGetValue (uri, out var downloadTask)) return downloadTask;
  return _cache [uri] = new WebClient().DownloadStringTaskAsync (uri);
}

(请注意,我们不将方法标记为async,因为我们直接返回从调用WebClient方法获得的任务。)

如果我们重复使用相同的 URI 多次调用GetWebPageAsync,现在我们确保得到相同的Task<string>对象。(这还有额外的好处,可以最小化垃圾收集的负载。)而且如果任务已完成,等待它是廉价的,这要归功于我们刚讨论过的编译器优化。

我们可以进一步扩展我们的示例,使其在不需要同步上下文保护的情况下成为线程安全,只需在整个方法体周围进行锁定即可:

lock (_cache)
  if (_cache.TryGetValue (uri, out var downloadTask))
    return downloadTask;
  else
    return _cache [uri] = new WebClient().DownloadStringTaskAsync (uri);
}

这是因为我们不会在下载页面的整个持续时间内进行锁定(这会影响并发性);我们只会在检查缓存、必要时启动新任务并用该任务更新缓存的短暂持续时间内进行锁定。

ValueTask<T>

注意

ValueTask<T>旨在进行微优化场景,您可能从未需要编写返回此类型的方法。然而,仍需注意我们在下一节中概述的预防措施,因为某些 .NET 方法返回ValueTask<T>,而IAsyncEnumerable<T>也使用它。

我们刚刚描述了编译器如何优化对同步完成任务的await表达式——通过短路延续并立即继续到下一条语句。如果同步完成是由于缓存,我们看到缓存任务本身可以提供一种优雅且高效的解决方案。

然而,在所有同步完成场景中缓存任务并不实际。有时需要实例化一个新任务,这会造成(微小的)潜在效率问题。这是因为 TaskTask<T> 是引用类型,因此实例化需要基于堆的内存分配和随后的回收。一种极端的优化形式是编写无分配的代码;换句话说,不实例化任何引用类型,不增加垃圾收集的负担。为支持此模式,引入了 ValueTaskValueTask<T> 结构体,编译器允许在 TaskTask<T> 的位置使用它们:

async ValueTask<int> Foo() { ... }

如果操作同步完成,则等待 ValueTask<T> 是无分配的。

int answer = await Foo();   // (Potentially) allocation-free

如果操作未同步完成,ValueTask<T> 在幕后创建一个普通的 Task<T>(它会将 await 转发给它),不会获得任何优势。

您可以通过调用 AsTask 方法将 ValueTask<T> 转换为普通的 Task<T>

还有一个非泛型版本——ValueTask——与 Task 类似。

使用 ValueTask 时的预防措施

ValueTask<T> 相对不寻常,它纯粹因为性能原因被定义为结构体。这意味着它负载了不适当的值类型语义,可能会导致意外的行为。为避免不正确的行为,必须避免以下情况:

  • 多次等待相同的 ValueTask<T>

  • 在操作未完成时调用 .GetAwaiter().GetResult()

如果需要执行这些操作,请调用 .AsTask(),并且改为操作返回的 Task

注意

避免这些陷阱的最简单方法是直接等待方法调用,例如:

await Foo();   // Safe

当将(值)任务分配给变量时,会打开错误行为的大门:

ValueTask<int> valueTask = Foo();  // Caution!
// Our use of valueTask can now lead to errors.

可通过立即转换为普通任务来缓解:

Task<int> task = Foo().AsTask();   // Safe
// task is safe to work with.

避免过多的跳转

对于在循环中多次调用的方法,您可以通过调用 ConfigureAwait 来避免重复跳转到 UI 消息循环的成本。这会强制任务不将后续任务跳转到同步上下文,将开销削减到接近上下文切换的成本(或者如果您等待的方法同步完成,则远低于此成本):

async void A() { ... await B(); ... }

async Task B()
{
  for (int i = 0; i < 1000; i++)
    await C().ConfigureAwait (false);
}

async Task C() { ... }

这意味着对于 BC 方法,我们取消了 UI 应用程序中简单的线程安全模型,其中代码在 UI 线程上运行,并且只能在 await 语句期间被抢占。然而,A 方法不受影响,如果它在 UI 线程上启动,则将保持在该线程上。

当编写库时,这种优化尤为重要:您不需要简化的线程安全性好处,因为您的代码通常不与调用方共享状态,也不访问 UI 控件。(在我们的示例中,如果它知道操作可能是短暂运行的,则使方法 C 同步完成也是有意义的。)

异步模式

取消

通常很重要的是,在启动后能够取消并发操作,也许是响应用户请求的一部分。实现这一点的一个简单方法是使用取消标志,我们可以通过编写如下类来封装它:

class CancellationToken
{
  public bool IsCancellationRequested { get; private set; }
  public void Cancel() { IsCancellationRequested = true; }
  public void ThrowIfCancellationRequested()
  {
    if (IsCancellationRequested)
      throw new OperationCanceledException();
  }
}

然后我们可以编写一个可取消的异步方法如下:

async Task Foo (CancellationToken cancellationToken)
{
  for (int i = 0; i < 10; i++)
  {
    Console.WriteLine (i);
    await Task.Delay (1000);
    cancellationToken.ThrowIfCancellationRequested();
  }
}

当调用者想要取消时,它调用传递给Foo的取消令牌上的Cancel方法。这将将IsCancellationRequested设置为 true,导致Foo在短时间内出现OperationCanceledException(这是System命名空间中为此目的设计的预定义异常)。

除了线程安全性(我们应该在读取/写入IsCancellationRequested周围进行锁定)之外,这种模式非常有效,CLR 提供了一个名为CancellationToken的类型,与我们刚刚展示的非常相似。但是,它缺少一个Cancel方法;这个方法实际上是在另一个名为CancellationTokenSource的类型上公开的。这种分离提供了一些安全性:只有访问CancellationToken对象的方法可以检查但不能发起取消。

要获取取消令牌,我们首先实例化一个CancellationTokenSource

var cancelSource = new CancellationTokenSource();

这暴露了一个Token属性,它返回一个CancellationToken。因此,我们可以像下面这样调用我们的Foo方法:

var cancelSource = new CancellationTokenSource();
Task foo = Foo (cancelSource.Token);
...
... *(sometime later)*
cancelSource.Cancel();

CLR 中的大多数异步方法都支持取消令牌,包括Delay。如果我们修改Foo,使其将其令牌传递给Delay方法,任务将在请求后立即结束(而不是最多一秒后)。

async Task Foo (CancellationToken cancellationToken)
{
  for (int i = 0; i < 10; i++)
  {
    Console.WriteLine (i);
    await Task.Delay (1000, cancellationToken);
  }
}

请注意,我们不再需要调用ThrowIfCancellationRequested,因为Task.Delay已经为我们做了这件事。取消令牌在调用堆栈中很好地传播(就像取消请求通过引发异常向调用堆栈级联一样)。

注意

UWP 依赖于 WinRT 类型,其异步方法遵循一种较低级的取消协议,不是接受CancellationToken,而是通过IAsyncInfo类型公开Cancel方法。AsTask扩展方法重载以接受取消令牌,以此来弥合差距。

同步方法也可以支持取消(例如TaskWait方法)。在这种情况下,取消指令将需要异步地传递(例如来自另一个任务)。例如:

var cancelSource = new CancellationTokenSource();
Task.Delay (5000).ContinueWith (ant => cancelSource.Cancel());
...

实际上,您可以在构造CancellationTokenSource时指定一个时间间隔,以在一段时间后启动取消(就像我们演示的那样)。这对于实现超时非常有用,无论是同步还是异步的情况:

var cancelSource = new CancellationTokenSource (5000);
try { await Foo (cancelSource.Token); }
catch (OperationCanceledException ex) { Console.WriteLine ("Cancelled"); }

CancellationToken结构提供了一个Register方法,允许您注册一个回调委托,在取消时将触发该委托;它返回一个可以被处置以取消注册的对象。

编译器异步函数生成的任务,在未处理的OperationCanceledException时自动进入“已取消”状态(IsCanceled返回 true,IsFaulted返回 false)。对于使用Task.Run创建的任务,如果将(同一)CancellationToken传递给构造函数,情况也是如此。在异步场景中,故障任务和取消任务的区别并不重要,因为在等待时两者都会抛出OperationCanceledException;但在高级并行编程场景中(特别是条件连续性),这一点很重要。我们在“取消任务”中继续讨论这个话题。

进度报告

有时,您希望异步操作在运行时报告进度。一个简单的解决方案是将一个Action委托传递给异步方法,该方法在进度更改时触发:

Task Foo (Action<int> onProgressPercentChanged)
{
  return Task.Run (() =>
  {
    for (int i = 0; i < 1000; i++)
    {
      if (i % 10 == 0) onProgressPercentChanged (i / 10);
      // Do something compute-bound...
    }
  });
}

下面是我们如何调用它的方式:

Action<int> progress = i => Console.WriteLine (i + " %");
await Foo (progress);

虽然这在控制台应用程序中效果很好,在富客户端场景中并不理想,因为它从工作线程报告进度,可能会导致消费者的潜在线程安全问题。(事实上,我们已经允许并发的副作用“泄漏”到外部世界,这在从 UI 线程调用时是不幸的。)

IProgress<T>Progress<T>

CLR 提供了一对类型来解决这个问题:一个名为IProgress<T>的接口和一个实现此接口的类Progress<T>。它们的目的实际上是“包装”一个委托,以便 UI 应用程序可以通过同步上下文安全地报告进度。

接口只定义了一个方法:

public interface IProgress<in T>
{
  void Report (T value);
}

使用IProgress<T>很容易;我们的方法几乎不会改变:

Task Foo (IProgress<int> onProgressPercentChanged)
{
  return Task.Run (() =>
  {
    for (int i = 0; i < 1000; i++)
    {
      if (i % 10 == 0) onProgressPercentChanged.Report (i / 10);
      // Do something compute-bound...
    }
  });
}

Progress<T>类有一个构造函数,接受一个类型为Action<T>的委托,它进行包装:

var progress = new Progress<int> (i => Console.WriteLine (i + " %"));
await Foo (progress);

Progress<T>还有一个ProgressChanged事件,您可以订阅它,而不是[或者另外]将动作委托传递给构造函数。)在实例化Progress<int>时,如果存在同步上下文,该类会捕获它。然后,当Foo调用Report时,委托通过该上下文被调用。

异步方法可以通过用自定义类型替换int来实现更复杂的进度报告,该类型公开一系列属性。

注意

如果您熟悉 Rx,您会注意到IProgress<T>与异步函数返回的任务一起提供了类似于IObserver<T>的功能集。不同之处在于,任务可以除了(并且与IProgress<T>发出的值不同类型)提供一个“最终”返回值。

IProgress<T>发出的值通常是“一次性”值(例如,完成百分比或到目前为止下载的字节数),而IObserver<T>OnNext推送的值通常包括结果本身,这也是调用它的主要原因。

WinRT 中的异步方法还提供进度报告,尽管通过 COM 的(相对)原始类型系统使协议变得复杂。异步 WinRT 方法,而不是接受IProgress<T>对象的方法,会返回以下接口之一,以替代IAsyncActionIAsyncOperation​<TRe⁠sult>

IAsyncActionWithProgress<TProgress>
IAsyncOperationWithProgress<TResult, TProgress>

有趣的是,两者都基于IAsyncInfo(而不是IAsyncActionIAsyncOperation<TResult>)。

好消息是AsTask扩展方法也被重载以接受IProgress<T>,用于前述接口,因此作为.NET 消费者,您可以忽略 COM 接口并执行此操作:

var progress = new Progress<int> (i => Console.WriteLine (i + " %"));
CancellationToken cancelToken = ...
var task = someWinRTobject.FooAsync().AsTask (cancelToken, progress);

任务异步模式

.NET 提供了数百个返回任务的异步方法,您可以进行await(主要与 I/O 相关)。这些方法大多(至少部分)遵循一种称为任务异步模式(TAP)的模式,这是我们到目前为止描述的内容的合理形式化。TAP 方法执行以下操作:

  • 返回“热”(正在运行的)TaskTask<TResult>

  • 具有“Async”后缀(除了特殊情况如任务组合器)

  • 如果支持取消和/或进度报告,重载以接受取消标记和/或IProgress<T>

  • 对调用者快速返回(只有一个小的同步阶段

  • 如果是 I/O 绑定,不会占用线程

正如我们所见,使用 C#的异步函数编写 TAP 方法非常简单。

任务组合器

异步函数有一个一致的协议的一个好处是(其中它们一致地返回任务),可以使用和编写任务组合器 —— 函数有用地组合任务,而不考虑这些特定任务所做的事情。

CLR 包括两个任务组合器:Task.WhenAnyTask.WhenAll。在描述它们时,我们假设以下方法已定义:

async Task<int> Delay1() { await Task.Delay (1000); return 1; }
async Task<int> Delay2() { await Task.Delay (2000); return 2; }
async Task<int> Delay3() { await Task.Delay (3000); return 3; }

WhenAny

Task.WhenAny 返回一个任务,当一组任务中的任何一个完成时,它也完成。以下示例在一秒钟内完成:

Task<int> winningTask = await Task.WhenAny (Delay1(), Delay2(), Delay3());
Console.WriteLine ("Done");
Console.WriteLine (winningTask.Result);   // 1

因为Task.WhenAny本身返回一个任务,我们等待它,它返回首先完成的任务。我们的示例完全非阻塞,包括最后一行访问Result属性时(因为winningTask已经完成)。尽管如此,最好还是等待winningTask

Console.WriteLine (await winningTask);   // 1

因为任何异常都会被重新抛出,而不会用AggregateException包装。实际上,我们可以一次性执行两个await

int answer = await await Task.WhenAny (Delay1(), Delay2(), Delay3());

如果一个未获胜的任务随后发生故障,除非随后等待该任务(或查询其Exception属性),否则异常将未被观察到。

WhenAny 对于对不支持超时或取消的操作应用超时或取消非常有用:

Task<string> task = SomeAsyncFunc();
Task winner = await (Task.WhenAny (task, Task.Delay(5000)));
if (winner != task) throw new TimeoutException();
string result = await task;   // Unwrap result/re-throw

请注意,因为在这种情况下我们使用不同类型的任务调用WhenAny,因此赢家报告为一个普通的Task(而不是Task<string>)。

WhenAll

Task.WhenAll返回一个任务,当你传递给它的所有任务都完成时完成。以下代码在三秒后完成(并演示了分支/合并模式):

await Task.WhenAll (Delay1(), Delay2(), Delay3());

我们可以通过依次等待task1task2task3来获得类似的结果,而不是使用WhenAll

Task task1 = Delay1(), task2 = Delay2(), task3 = Delay3();
await task1; await task2; await task3;

与其说这比等待一个更有效率(因为需要三个等待而不是一个),不如说如果task1出现故障,我们将永远无法等待task2/task3,它们的任何异常将未被观察。

相比之下,Task.WhenAll直到所有任务完成才完成,即使出现故障也是如此。如果出现多个故障,它们的异常将合并到任务的AggregateException中(这时AggregateException实际上变得有用——如果你对所有异常感兴趣的话)。然而,等待组合任务时,只会抛出第一个异常,因此要查看所有异常,你需要这样做:

Task task1 = Task.Run (() => { throw null; } );
Task task2 = Task.Run (() => { throw null; } );
Task all = Task.WhenAll (task1, task2);
try { await all; }
catch
{
  Console.WriteLine (all.Exception.InnerExceptions.Count);   // 2 
}   

使用Task<TResult>类型的任务调用WhenAll会返回一个Task<TResult[]>,在等待时给出所有任务的组合结果。这在等待时会简化为一个TResult[]

Task<int> task1 = Task.Run (() => 1);
Task<int> task2 = Task.Run (() => 2);
int[] results = await Task.WhenAll (task1, task2);   // { 1, 2 }

举个实际的例子,以下代码并行下载 URI 并计算它们的总长度:

async Task<int> GetTotalSize (string[] uris)
{
  IEnumerable<Task<byte[]>> downloadTasks = uris.Select (uri => 
    new WebClient().DownloadDataTaskAsync (uri));

  byte[][] contents = await Task.WhenAll (downloadTasks);
  return contents.Sum (c => c.Length);
}

这里有一点点效率问题,即我们不必要地保留下载的字节数组,直到每个任务完成。如果在下载后立即将字节数组折叠为它们的长度,将会更有效率。这就是异步 lambda 的用武之地,因为我们需要将await表达式传递给 LINQ 的Select查询运算符:

async Task<int> GetTotalSize (string[] uris)
{
  IEnumerable<Task<int>> downloadTasks = uris.Select (async uri =>
    (await new WebClient().DownloadDataTaskAsync (uri)).Length);

  int[] contentLengths = await Task.WhenAll (downloadTasks);
  return contentLengths.Sum();
}

自定义组合器

编写自己的任务组合器非常有用。最简单的“组合器”接受一个单一的任务,例如以下示例,它允许你在超时时等待任何任务:

async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,
                                                 TimeSpan timeout)
{
  Task winner = await Task.WhenAny (task, Task.Delay (timeout))
                          .ConfigureAwait (false);
  if (winner != task) throw new TimeoutException();
  return await task.ConfigureAwait (false);   // Unwrap result/re-throw
}

因为这是一个非常“库方法”,不涉及外部共享状态,所以在等待时我们使用ConfigureAwait(false)来避免潜在地跳转到 UI 同步上下文。当任务按时完成时,我们可以通过取消Task.Delay来进一步提高效率(避免定时器挂在那里产生的小开销):

async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,
                                                 TimeSpan timeout)
{
  var cancelSource = new CancellationTokenSource();
  var delay = Task.Delay (timeout, cancelSource.Token);
  Task winner = await Task.WhenAny (task, delay).ConfigureAwait (false);
  if (winner == task)
    cancelSource.Cancel();
  else
    throw new TimeoutException();
  return await task.ConfigureAwait (false);   // Unwrap result/re-throw
}

以下代码允许你通过CancellationToken“放弃”一个任务:

static Task<TResult> WithCancellation<TResult> (this Task<TResult> task,
                                          CancellationToken cancelToken)
{
  var tcs = new TaskCompletionSource<TResult>();
  var reg = cancelToken.Register (() => tcs.TrySetCanceled ());
  task.ContinueWith (ant => 
  {
    reg.Dispose();
    if (ant.IsCanceled)
      tcs.TrySetCanceled();
    else if (ant.IsFaulted)
      tcs.TrySetException (ant.Exception.InnerExceptions);
    else
      tcs.TrySetResult (ant.Result);
  });
  return tcs.Task;
}

任务组合器可能很难编写,有时需要使用信号构造,我们在第二十一章中介绍。这实际上是件好事,因为它将与并发相关的复杂性从业务逻辑中分离出来,放入可重用的方法中,可以单独进行测试。

下一个组合器类似于WhenAll,但是如果任何任务出现故障,结果任务将立即失败:

async Task<TResult[]> WhenAllOrError<TResult> 
  (params Task<TResult>[] tasks)
{
  var killJoy = new TaskCompletionSource<TResult[]>();
  foreach (var task in tasks)
    task.ContinueWith (ant =>
    {
      if (ant.IsCanceled) 
        killJoy.TrySetCanceled();
      else if (ant.IsFaulted)
        killJoy.TrySetException (ant.Exception.InnerExceptions);
    });
  return await await Task.WhenAny (killJoy.Task, Task.WhenAll (tasks))
                         .ConfigureAwait (false);
}

我们首先创建一个TaskCompletionSource,它的唯一工作是在任务故障时结束。因此,我们从不调用它的SetResult方法,只调用它的TrySetCanceledTrySetException方法。在这种情况下,ContinueWithGetAwaiter().OnCompleted更方便,因为我们不访问任务的结果,也不希望在此时跳转到 UI 线程。

异步锁定

在“异步信号量和锁”中,我们描述了如何使用SemaphoreSlim来异步锁定或限制并发。

废弃的模式

.NET 使用其他模式来处理异步,这些模式在任务和异步函数出现之前。现在,随着基于任务的异步成为主导模式,这些模式几乎不再需要。

异步编程模型

最古老的模式称为异步编程模型(APM),它使用一对以“Begin”和“End”开头的方法,并且一个名为IAsyncResult的接口。为了说明,让我们看一下System.IO中的Stream类及其Read方法。首先是同步版本:

public int Read (byte[] buffer, int offset, int size);

您可能已经可以预测基于任务的异步版本是什么样的:

public Task<int> ReadAsync (byte[] buffer, int offset, int size);

现在让我们来看一下 APM 版本:

public IAsyncResult BeginRead (byte[] buffer, int offset, int size,
                               AsyncCallback callback, object state);
public int EndRead (IAsyncResult asyncResult);

调用Begin*方法启动操作,返回一个IAsyncResult对象,它充当异步操作的标记。当操作完成(或故障)时,将触发AsyncCallback委托:

public delegate void AsyncCallback (IAsyncResult ar);

谁处理这个委托,然后调用End*方法,该方法提供操作的返回值,并在操作故障时重新抛出异常。

APM 不仅使用起来很笨拙,而且在正确实现时也令人意外地困难。处理 APM 方法的最简单方法是调用Task.Factory.FromAsync适配器方法,将 APM 方法对转换为一个Task。在内部,它使用TaskCompletionSource来提供一个在 APM 操作完成或故障时被信号的任务。

FromAsync方法需要以下参数:

  • 指定Begin*XXX*方法的委托

  • 指定End*XXX*方法的委托

  • 附加参数将传递给这些方法

FromAsync被重载以接受与.NET 中几乎所有异步方法签名匹配的委托类型和参数。例如,假设streamStreambufferbyte[],我们可以这样做:

Task<int> readChunk = Task<int>.Factory.FromAsync (
  stream.BeginRead, stream.EndRead, buffer, 0, 1000, null);

基于事件的异步模式

基于事件的异步模式(EAP)于 2005 年引入,旨在为 APM 提供一个更简单的替代方案,特别是在 UI 场景中。然而,它仅在少数类型中实现,最显著的是System.Net中的WebClient。EAP 仅仅是一个模式;没有提供任何类型来帮助。基本上,该模式是这样的:一个类提供一组成员,这些成员在内部管理并发性,类似于以下内容:

// These members are from the WebClient class:

public byte[] DownloadData (Uri address);    // Synchronous version
public void DownloadDataAsync (Uri address);
public void DownloadDataAsync (Uri address, object userToken);
public event DownloadDataCompletedEventHandler DownloadDataCompleted;

public void CancelAsync (object userState);  // Cancels an operation
public bool IsBusy { get; }                  // Indicates if still running

*Async 方法异步启动操作。当操作完成时,会自动触发 ***Completed 事件(如果存在捕获的同步上下文则会自动发布)。该事件返回一个包含以下内容的事件参数对象:

  • 一个指示操作是否被取消的标志(由消费者调用 CancelAsync 设置)

  • 一个表示抛出的异常的 Error 对象(如果有的话)

  • 在调用 Async 方法时提供的 userToken 对象

EAP 类型还可以公开进度报告事件,每当进度发生变化时触发(也通过同步上下文发布):

public event DownloadProgressChangedEventHandler DownloadProgressChanged;

实现 EAP 需要大量的样板代码,使得该模式的可组合性较差。

BackgroundWorker

System.ComponentModel 中的 BackgroundWorker 是 EAP 的通用实现。它允许富客户端应用程序启动一个工作线程,并报告完成和基于百分比的进度,无需显式捕获同步上下文。以下是一个例子:

var worker = new BackgroundWorker { WorkerSupportsCancellation = true };
worker.DoWork += (sender, args) =>
{                                      // This runs on a worker thread
  if (args.Cancel) return;
  Thread.Sleep(1000); 
  args.Result = 123;
};
worker.RunWorkerCompleted += (sender, args) =>    
{                                                  // Runs on UI thread
  // We can safely update UI controls here...
  if (args.Cancelled)
    Console.WriteLine ("Cancelled");
  else if (args.Error != null)
    Console.WriteLine ("Error: " + args.Error.Message);
  else
    Console.WriteLine ("Result is: " + args.Result);
};
worker.RunWorkerAsync();   // Captures sync context and starts operation

RunWorkerAsync 启动操作,会在一个池化的工作线程上触发 DoWork 事件。它还会捕获同步上下文,当操作完成(或出错)时,会通过该同步上下文调用 RunWorkerCompleted 事件(类似于一个延续)。

BackgroundWorker 创建粗粒度并发,即 DoWork 事件完全在工作线程上运行。如果需要在该事件处理程序中更新 UI 控件(而不仅仅是发布百分比完成消息),必须使用 Dispatcher.BeginInvoke 或类似方法。

我们在 http://albahari.com/threading 更详细地描述了 BackgroundWorker

¹ CLR 在幕后为垃圾回收和终结创建其他线程。