《c#10 in a nutshell》--- 读书随记(9)
Chaptor 13. Diagnostics
内容来自书籍《C# 10 in a Nutshell》
Author:Joseph Albahari
需要该电子书的小伙伴,可以留下邮箱,有空看到就会发送的
调试,暂时跳过
Chapter 14. Concurrency and Asynchrony
Introduction
以下是最常见的并发场景:
- 编写响应性用户界面
在 WindowsPresentationFoundation (WPF)、移动应用程序和 Windows 窗体应用程序中,必须与运行用户界面的代码同时运行耗时的任务,以保持响应性。 - 允许同时处理请求
在服务器上,客户端请求可以并发到达,因此必须并行处理以保持可伸缩性。如果您使用 ASP.NET Core 或 Web API,运行时将自动为您完成此操作。但是,您仍然需要了解共享状态(例如,使用静态变量进行缓存的效果)。 - Parallel programming
如果将工作负载分配给多核/多处理器计算机,那么执行密集计算的代码可以更快地执行 - Speculative execution
在多核计算机上,有时可以通过预测可能需要完成的事情并提前完成来提高性能。LINQPad 使用这种技术来加速新查询的创建。一种变化是并行运行许多不同的算法,这些算法都解决同一个任务。无论哪个先完成,都是“赢家”ーー当你不能提前知道哪个算法执行得最快时,这种方法是有效的。
程序同时执行代码的一般机制称为多线程。CLR 和操作系统都支持多线程,而且多线程是并发中的一个基本概念。因此,理解线程的基础知识,尤其是线程对共享状态的影响是非常重要的。
Threading
线程是可以独立于其他线程执行的执行路径。
每个线程在操作系统进程中运行,操作系统进程提供一个独立的环境,程序在该环境中运行。对于单线程程序,只有一个线程在进程的独立环境中运行,因此该线程对其具有独占访问权。对于多线程程序,多个线程在单个进程中运行,共享相同的执行环境(特别是内存)。这在一定程度上解释了为什么多线程是有用的: 例如,一个线程可以在后台获取数据,而另一个线程在数据到达时显示数据。此数据称为共享状态。
Creating a Thread
Thread t = new Thread (WriteY);
for (int i = 0; i < 1000; i++) Console.Write ("x");
void WriteY()
{
for (int i = 0; i < 1000; i++) Console.Write ("y");
}
主线程创建一个新的线程 t,在这个线程 t 上运行一个重复打印字符 y 的方法。同时,主线程重复打印字符 x,如图14-1所示。在单核计算机上,操作系统必须为每个线程分配“时间片”(Windows 中通常为20ms)来模拟并发,从而导致重复的 x 和 y 块。在多核或多处理器计算机上,两个线程可以真正并行执行(受到计算机上其他活动进程的竞争) ,尽管由于控制台处理并发请求的机制中的微妙之处,在这个例子中仍然可以得到重复的 x 和 y 块。
一个线程在它的执行与另一个线程上的代码的执行交错的时候被抢占。这个词经常突然出现在解释为什么出了问题的时候!
Thread.CurrentThread 这个静态方法可以获取当前正在执行的线程
Join and Sleep
可以通过 Join 方法等待其他线程执行完毕
Thread.Sleep 可以让当前线程暂停一段时间
当线程在Sleep或者Join方法等待的时候,是处于blocked状态的
Blocking
当一个线程的执行由于某种原因而暂停时,比如在休眠或者等待另一个线程通过 Join 结束时,该线程被认为是被阻塞的。一个被阻塞的线程立即放弃它的处理器时间片,从那时起,直到它的阻塞条件得到满足,它才消耗处理器时间。您可以通过线程的 ThreadState 属性测试被阻塞的线程:
bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
当一个线程阻塞或解除阻塞时,操作系统执行上下文切换。这会产生一个小的开销,通常是1或2微秒。
I/O-bound versus compute-bound
花费大部分时间等待某些事情发生的操作称为 I/O-bound ,一个例子是下载网页或调用 Console.ReadLine。(I/O-bound 操作通常涉及输入或输出,但这不是一个硬性要求: Thread.Sleep也被认为是 I/O-bound )相反,花费大量时间执行 CPU 密集型工作的操作称为compute-bound。
Blocking versus spinning
I/O-bound 操作有两种工作方式: 要么在当前线程上同步等待操作完成,要么异步操作,在将来操作完成时触发回调
I/O-bound 操作阻塞线程并消耗了很多时间在等待上,它们还可以周期性的自旋
关于旋转和阻挡有一些细微的差别。首先,当您期望一个条件很快得到满足(可能在几微秒内)时,非常短暂的旋转可能是有效的,因为它避免了上下文切换的开销和延迟。.NET 提供了特殊的方法和类来帮助
其次,阻塞是有成本。这是因为每个线程只要存在,就会占用大约1MB 的内存,并导致 CLR 和 OS 的持续管理开销。由于这个原因,在需要处理数百或数千个并发操作的严重 I/O-bound 程序的上下文中,阻塞可能会很麻烦。相反,这些程序需要使用基于回调的方法,在等待时完全取消它们的线程。这(在某种程度上)是我们稍后讨论的异步模式的目的。
Local Versus Shared State
CLR 为每个线程分配自己的内存堆栈,这样局部变量保持独立
如果线程对同一对象或变量有共同的引用,则它们共享数据
bool _done = false;
new Thread (Go).Start();
Go();
void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
Lambda 表达式捕获的局部变量也可以共享
bool done = false;
ThreadStart action = () =>
{
if (!done) { done = true; Console.WriteLine ("Done"); }
};
new Thread (action).Start();
action();
但更常见的是,字段用于在线程之间共享数据
class ThreadTest
{
bool _done;
public void Go()
{
if (!_done) { _done = true; Console.WriteLine ("Done"); }
}
}
var tt = new ThreadTest();
new Thread (tt.Go).Start();
tt.Go();
静态字段提供了在线程之间共享数据的另一种方式
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"); }
}
}
所有这四个例子都说明了另一个关键概念: 线程安全(或者说,缺乏线程安全!).输出实际上是不确定的: “完成”可以打印两次(尽管不太可能)。但是,如果我们在 Go 方法中交换语句的顺序,那么“完成”被打印两次的几率就会大大增加
static void Go()
{
if (!_done) { Console.WriteLine ("Done"); _done = true; }
}
问题在于一个线程可以在另一个线程执行 WriteLine 语句的同时计算 if 语句ーー在它有机会将 done 设置为 true 之前。
Locking and Thread Safety
我们使用排他锁来保证读写共享字段时的线程安全
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; }
}
}
}
当两个线程同时竞争一个锁(可以位于任何引用类型的对象上; 在本例中为 _ lock)时,一个线程等待或阻塞,直到锁变得可用。在这种情况下,它确保一次只有一个线程可以输入它的代码块,并且“完成”将只打印一次。以这种方式(在多线程上下文中防止不确定性)保护的代码称为线程安全。
锁定不是线程安全的灵丹妙药ーー很容易忘记在访问字段时进行锁定,而锁定本身也会产生问题,比如死锁
Foreground Versus Background Threads
默认情况下,显式创建的线程是前台线程。前台线程只要其中任何一个线程运行,应用程序就会保持活动状态,而后台线程则不会。所有前台线程完成后,应用程序结束,任何仍在运行的后台线程突然终止。
static void Main (string[] args)
{
Thread worker = new Thread ( () => Console.ReadLine() );
if (args.Length > 0) worker.IsBackground = true;
worker.Start();
}
Thread Priority
线程的“优先级”属性确定相对于操作系统中的其他活动线程分配的执行时间,按以下比例
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
当多个线程同时处于活动状态时,这就变得相关了。在提升线程的优先级时,您需要小心,因为它可能会使其他线程处于饥饿状态。如果希望线程的优先级高于其他进程中的线程,还必须使用 System.Diagnostics 中的 Process 类提高进程的优先级
using Process p = Process.GetCurrentProcess();
p.PriorityClass = ProcessPriorityClass.High;
这对于那些工作量很小并且在工作中需要低延迟(非常快速响应的能力)的非 UI 流程来说可以很好地工作。对于需要大量计算机的应用程序(特别是那些具有用户界面的应用程序) ,提高进程优先级可能会使其他进程处于匮乏状态,从而降低整个计算机的运行速度。
Signaling
有时,您需要一个线程来等待,直到从其他线程接收到通知。这就是所谓的信号。最简单的信令结构是 ManualResetEvent。在 ManualResetEvent 上调用 WaitOne 会阻塞当前线程,直到另一个线程通过调用 Set“打开”信号
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
The Thread Pool
无论何时启动一个线程,都要花费几百微秒的时间来组织一个新的本地变量堆栈。线程池通过拥有一个预先创建的可回收线程池来减少这种开销。线程池对于有效的并行编程和细粒度并发是必不可少的; 它允许短期操作运行,而不会因线程启动的开销而不堪重负。
在使用池线程时需要注意以下几点:
- 不能设置池线程的名称,这使得调试更加困难
- 池线程始终是后台线程
- 阻塞池线程会降低性能
您可以随意更改池中线程的优先级ーー当释放回池中时,它将恢复到正常状态
可以通过属性 Thread.CurrentThread.IsThreadPoolThread 确定当前是否正在池线程上执行
Entering the thread pool
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));
Hygiene in the thread pool
线程池服务于另一个函数,这是为了确保临时过量的计算绑定工作不会导致 CPU 超订。超额订阅是活动线程多于 CPU 内核的条件,操作系统必须对线程进行时间切片。超额订阅会影响性能,因为时间切片需要昂贵的上下文切换,并可能使 CPU 缓存失效,而这些缓存对于向现代处理器提供性能至关重要。
CLR 通过对任务排队并限制其启动来防止线程池中的超订。它首先运行与硬件核心一样多的并发任务,然后通过爬坡算法调整并发级别,不断按特定方向调整工作负载。如果吞吐量得到改善,它将继续沿着相同的方向前进(否则将发生相反的情况)。这确保了它始终跟踪最佳性能曲线ーー即使在面对计算机上的竞争性流程活动时也是如此。
如果满足以下两个条件,CLR 的策略效果最好:
- 工作项大多是短时间运行的(< 250 ms,理想情况下 < 100 ms) ,因此 CLR 有大量的机会来度量和调整。
- 花费大部分时间被封锁的工作并不适合这个pool。
Tasks
线程是用于创建并发性的低级工具,因此它有一些限制,特别是以下限制
- 尽管将数据传递给启动的线程很容易,但是从被 Join 的线程中获取“返回值”却不是一件容易的事情。你需要建立某种共享领域。如果操作抛出一个异常,捕获和传播该异常同样是痛苦的
- 当一个线程完成后,你不能让它启动其他的东西,相反,你必须 Join 它(在这个过程中阻塞你自己的线程)
这些限制阻碍了细粒度的并发性; 换句话说,它们使得通过组合较小的并发操作来组合较大的并发操作变得非常困难(这对于我们将在下面几节中讨论的异步编程是必不可少的)。这反过来又导致对手动同步(锁定、信令等)以及随之而来的问题的更大依赖。线程的直接使用还具有我们在“线程池”中讨论过的性能影响。如果您需要运行数百或数千个并发 I/O-bound 操作,那么基于线程的方法仅在线程开销方面就会消耗数百或数千兆内存。
Task 类可以帮助解决所有这些问题。与线程相比,Task 是更高级别的抽象ーー它表示一个可能由线程支持、也可能不由线程支持的并发操作。任务是组合的(您可以通过使用延续将它们链接在一起)。他们可以使用线程池来减少启动延迟,并且使用 TaskCompletionSource,他们可以采用一种回调方法,在等待 I/O-bound 操作时完全避免使用线程。
Starting a Task
Run需要一个 Action 委托
Task.Run (() => Console.WriteLine ("Foo"));
这个方法返回一个 Task 对象,我们用它来监控它的进度,它更像是一个Thread对象。可以通过 Status 属性来追踪这个任务的执行状态
Wait
调用 Wait 方法会阻塞当前线程直到任务完成,和Join方法一样
Long-running tasks
默认情况下,CLR 在池线程上运行任务,这对于短期运行的 compute-bound 工作非常理想。对于运行时间较长和阻塞的操作(如前面的示例) ,可以按照以下方式防止使用池线程
Task task = Task.Factory.StartNew (() => Console.WriteLine ("Foo"),
TaskCreationOptions.LongRunning);
Returning values
Task 有一个 范型子类 Task< TResult >,允许task有返回值。可以调用Task.Run方法,但是传递Func< TResult >委托,可以得到这个返回值
Task<int> task = Task.Run (() => { Console.WriteLine ("Foo"); return 3; });
稍后可以通过查询 Result 属性获取结果。如果任务尚未完成,则访问此属性将阻塞当前线程,直到任务完成
Exceptions
与线程不同,任务可以方便地传播异常。因此,如果任务中的代码抛出一个未处理的异常(换句话说,如果您的任务出现故障) ,该异常将自动重新引发给调用 Wait ()的任何人ーー或访问 Task < TResult > 的 Result 属性的任何人:
Task task = Task.Run (() => { throw null; });
try
{
task.Wait();
}
catch (AggregateException aex)
{
if (aex.InnerException is NullReferenceException)
Console.WriteLine ("Null!");
else
throw;
}
可以通过 Task 的 IsFault 和 IsCancled 属性测试有错误的任务,而无需重新引发异常。如果两个属性都返回 false,则没有发生错误; 如果 IsCancled 为 true,则为该任务引发OperationCanceledException; 如果 IsFault 为 true,则引发另一种类型的异常,并且 Exception 属性将指示错误。
Continuations
延续对任务说,“当你完成后,继续做其他事情。”延续通常由一个回调实现,该回调在操作完成后执行一次。有两种方法可以将延续附加到任务
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.GetResult() 的时候,错误会重新抛出。也可以直接访问前置任务的Result属性。调用awaiter.GetResult()的好处是,当前置任务失败,错误会直接抛出,而不需要AggregateException来包装,这样我们的catch块更加简单和简洁
还有一种添加回调的方法是:
primeNumberTask.ContinueWith (antecedent =>
{
int result = antecedent.Result;
Console.WriteLine (result);
// Writes 123
});
ContinueWith 本身返回一个 Task,如果您想要附加进一步的 ContinueWith,这非常有用。
但是我们需要处理AggregateException如果方法报错。在不是UI的应用中,还必须指定TaskContinuationOptions.ExecuteSynchronously
,如果希望用同一个线程来执行后续操作,另外这会绑定线程池,这个方法在并行编程中很有用
TaskCompletionSource
这是第二种方法创建Task
TaskCompletionSource 允许您使用将来需要完成的任何操作创建任务。它的工作方式是给您一个手动驱动的“从”任务ーー通过指示操作何时结束或出现故障。这是 I/O-bound 工作的理想选择: 您可以获得任务的所有好处(具有传播返回值、异常和延续的能力) ,而不会在操作期间阻塞线程。
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);
...
}
调用以上的方法,会给task发送信号,让这个任务进入相应的状态。这些方法最多可以调用一次,如果重复调用会报错,不过用Tryxxx方法就只是返回false
var tcs = new TaskCompletionSource<int>();
new Thread (
() => { Thread.Sleep (5000); tcs.SetResult (42); })
{ IsBackground = true }
.Start();
Task<int> task = tcs.Task;
Console.WriteLine (task.Result);
还有一种用法是:
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 的真正威力在于创建不会占用线程的任务。例如,假设一个任务等待5秒钟,然后返回数字42。通过使用 Timer 类,我们可以在没有线程的情况下编写这个代码,在 CLR (以及操作系统)的帮助下,Timer 类在 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;
}
因此,我们的方法返回一个5秒钟后完成的任务,结果是42。通过向任务附加延续,我们可以在不阻塞任何线程的情况下写入其结果
var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult()));
现在我们可以编写通用的延迟方法:
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;
}
我们在没有线程的情况下使用 TaskCompletionSource 意味着只有在延续开始时(五秒钟后)线程才被占用。我们可以通过一次启动10,000个这样的操作来证明这一点,而不会出现错误或过多的资源消耗
for (int i = 0; i < 10000; i++)
Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
计时器在池线程上启动回调,因此5秒钟后,线程池将接收10,000个请求,以便在 TaskCompletionSource 上调用 SetResult (null)。如果请求到达的速度比处理的速度快,线程池将通过排队来响应,然后在 CPU 的最佳并行级别处理它们。如果线程绑定作业是短时间运行的,那么这是理想的,在这种情况下是正确的: 线程绑定作业只是对 SetResult 的调用,加上将延续发布到同步上下文(在 UI 应用程序中)的操作,或者是延续本身(Console.WriteLine(42)).
这个延迟方法已经在Task上有实现
Task.Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
Principles of Asynchrony
在演示 TaskCompletionSource 时,我们最终编写了异步方法。在本节中,我们将确切地定义什么是异步操作,并解释这如何导致异步编程
Synchronous Versus Asynchronous Operations
(在这个上下文中,所说的同步和异步,我认为叫做阻塞与非阻塞好像会更好一点)
同步操作在返回到调用方之前完成其工作
异步操作可以在返回给调用者之后完成(大部分或全部)工作
我们编写和调用的大多数方法都是同步的。比如List< T >.Add、Console.WriteLine或者Thread.Sleep
异步方法不太常见,它会启动并发性,因为工作是与调用方并行进行的
异步方法通常很快(或立即)返回给调用方; 因此,它们也称为非阻塞方法。
到目前为止,我们看到的大多数异步方法都可以描述为通用方法
- Thread.Start
- Task.Run
- continuations
What Is Asynchronous Programming?
异步编程的原理是异步编写长时间运行(或可能长时间运行)的函数。这与同步编写长时间运行的函数,然后从新线程或任务调用这些函数以引入所需的并发性的传统方法形成了对比。
与异步方法的不同之处在于,并发是在长期运行的函数内部而不是从函数外部启动的。这有两个好处:
- I/O-bound 并发可以在不占用线程、提高可伸缩性和效率的情况下实现。
- Rich-client 应用程序最终在辅助线程上的代码更少,从而简化了线程安全性。
这反过来又导致了异步编程的两种不同用途。第一个挑战是编写(通常是服务器端)能够有效处理大量并发 I/O 的应用程序。这里的挑战不是线程安全(因为通常只有最小的共享状态) ,而是线程效率; 特别是,不会为每个网络请求消耗一个线程。因此,在此上下文中,只有 I/O-bound 操作受益于异步。
Asynchronous Programming and Continuations
Tasks 非常适合异步编程,因为它们支持异步所必需的延续。在编写延迟时,我们使用了 TaskCompletionSource,这是实现“底层” I/O-bound 异步方法的标准方法。
对于 compute-bound 方法,我们使用 Task.Run 启动线程绑定并发。只需将task返回给调用者,我们就可以创建一个异步方法。异步编程的不同之处在于,我们的目标是在 call graph 中做到这一点,以便在 rich-client 应用程序中,更高级别的方法可以保留在 UI 线程和访问控制以及共享状态上,而不会出现线程安全问题。为了举例说明,考虑下面的方法,使用所有可用的核计算和计数素数
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!");
}
output
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,但实际上它更可能是在一个 rich-client 应用程序中更新 UI 控件,正如我们后面展示的那样。我们可以像下面这样为这个 call graph 启动粗粒度并发
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)));
}
然后外面的调用就是
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 的结果。例如,在获取网页时,必须在 HTTP 请求之前进行 DNS 查找。
为了让它们按顺序运行,我们必须从延续本身触发下一个循环迭代。这意味着消除 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 # 的异步函数为我们完成了所有这些工作。通过 async 和 await 关键字,我们只需要编写这个
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!");
}
另一种看待问题的方法是命令式循环构造(for、 foreach 等)不能很好地与延续相混合,因为它们依赖于方法的当前本地状态(“这个循环还要运行多少次?”).
虽然异步和等待关键字提供了一种解决方案,但有时也可以用另一种方法来解决这个问题,即用函数等价的结构(换句话说,LINQ 查询)替换命令式循环结构。这是反应扩展(Rx)的基础,当您希望对结果执行查询运算符ーー或组合多个序列时,这是一个很好的选择。要付出的代价是,为了防止阻塞,Rx 在基于推的序列上运行,这在概念上可能很棘手。
Asynchronous Functions in C#
async 和 await关键字允许您编写与同步代码具有相同结构和简单性的异步代码,同时消除异步编程的“管道”。
Awaiting
await 关键字简化了延续的附加(attaching of continuations)
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 关键字,我们可以按如下方式调用它
async void DisplayPrimesCount()
{
int result = await GetPrimesCountAsync (2, 1000000);
Console.WriteLine (result);
}
async 修饰符指示编译器将 await 视为一个关键字,而不是该方法中出现歧义时的标识符。异步修饰符只能应用于返回 void 或 Task 或 Task < TResult > 的方法(和 lambda 表达式)。
async 修饰符类似于不安全修饰符,因为它对方法的签名或公共元数据没有影响; 它只影响方法内部发生的事情。因此,在接口中使用async是没有意义的。但是,例如,在重写非async virtual 方法时引入async是合法的,只要签名保持不变。
在遇到await表达式时,执行(通常)返回给调用者ーー就像迭代器中的yield return一样。但是在返回之前,运行时将一个延续附加到await的任务,以确保当任务完成时,执行跳回到方法中并继续执行它停止的地方。如果任务发生故障,则重新引发其异常,否则将其返回值分配给 await 表达式。我们可以通过研究刚才检查的异步方法的逻辑扩展来总结我们刚才所说的一切:
void DisplayPrimesCount()
{
var awaiter = GetPrimesCountAsync (2, 1000000).GetAwaiter();
awaiter.OnCompleted (() =>
{
int result = awaiter.GetResult();
Console.WriteLine (result);
});
}
await 代表一个task,注意到,await表达式计算的结果是 int ,这是因为我们 await 的表达式结果是 Task< int > ,也就是 GetAwaiter().GetResult() 方法返回的是 int。
Capturing local state
await 表达式的真正威力在于,它几乎可以出现在代码的任何地方。具体来说,除了锁表达式或不安全上下文之外,await 表达式可以代替任何表达式(在异步函数中)出现。
async void DisplayPrimeCounts()
{
for (int i = 0; i < 10; i++)
Console.WriteLine (await GetPrimesCountAsync (i*1000000+2, 1000000));
}
在第一次执行 GetPrimesCountAsync 时,执行通过 await 表达式返回给调用者。当方法完成(或出现错误)时,执行将在中断的地方继续,并保留局部变量和循环计数器的值。
然而,编译器采用更一般的策略将这些方法重构为状态机(就像它对迭代器所做的那样)
编译器依赖于延续(通过 awaiter 模式)在 await 表达式之后恢复执行。这意味着如果在富客户端应用程序的 UI 线程上运行,同步上下文(synchronization context)将确保在同一线程上继续执行。否则,执行将在任何完成任务的线程上继续。线程的更改不会影响执行顺序,除非您在某种程度上依赖于线程关联,或许可以通过使用线程本地存储(请参阅“线程本地存储”) ,否则这种更改不会产生什么影响。这就像游览一个城市,叫出租车从一个目的地到另一个目的地。使用同步上下文时,您将始终获得相同的出租车; 如果没有同步上下文,则通常每次获得不同的出租车。不过,无论哪种情况,旅程都是一样的。
Asynchronous call graph execution
为了确切地了解它是如何执行的,将代码重新排列如下是有帮助的:
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 调用 PrintResponerToLife,后者调用 GetAnswerToLife,后者调用Delay,然后await。这个 await 导致执行返回到 PrintAnswerToLife,它再await,返回到 Go,它也await并返回给调用者。所有这些都同步发生在调用 Go 的线程上; 这是执行的短暂同步阶段。
5秒钟后,会触发“延迟”上的延续,执行将在一个池线程上返回到 GetAnswerToLife。(如果我们从一个 UI 线程开始,执行现在会反弹到该线程。)然后运行 GetAnswerToLife 中的其余语句,之后该方法的 Task < int > 结果为42,并执行 PrintAnswerToLife 中的延续,后者执行该方法中的其余语句。这个过程一直持续到 Go 的任务完成为止。
执行流与前面展示的同步调用图相匹配,因为我们遵循的模式是在调用每个异步方法后立即对其进行 await。这将在调用图中创建一个没有并行性或重叠执行的顺序流。每个 await 表达式在执行过程中创建一个“间隔”,然后程序从中断的地方继续运行。
Parallelism
在不等待异步方法的情况下调用异步方法,可以让后面的代码并行执行。您可能已经注意到,其事件处理程序名为 Go,如下所示:
_button.Click += (sender, args) => Go();
尽管 Go 是一种异步方法,但我们并没有对它进行 await,这确实促进了维护响应 UI 所需的并发性。
我们可以使用同样的原理并行运行两个异步操作:
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1;
await task2;
无论操作是否在 UI 线程上启动,以这种方式创建的并发都会发生,尽管发生方式不同。在这两种情况下,我们都会在启动它的底层操作(如 Task)中获得相同的“真正”并发。延迟或代码植入到任务。快跑)。只有当操作在没有同步上下文的情况下启动时,调用堆栈中的上述方法才会受到真正的并发性的影响,否则它们将受到我们前面提到的伪并发性(和简化的线程安全性)的影响,在这种情况下,我们唯一可以被抢占的地方是一个 await 语句。例如,这使我们可以定义一个共享字段 _x,并在 GetAnswerToLife 中增加它,而不需要锁定:
async Task<int> GetAnswerToLife()
{
_x++;
await Task.Delay (5000);
return 21 * 2;
}
(按照前面的原理解释,这里的并发为共享字段+1,肯定不会出问题的,因为根据前面的说法,await的前半段是同步的,或者说是只有一条线程在执行而已,所以根本不会有线程安全性问题,是有两条或多条线程同时访问同一个变量的时候才会出现问题)
Asynchronous Lambda Expressions
Func<Task> unnamed = async () =>
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
};
Func<Task<int>> unnamed = async () =>
{
await Task.Delay (1000);
return 123;
};
int answer = await unnamed();
Asynchronous Streams
有了屈服返回,你可以写一个迭代器,有了 await,你可以写一个异步函数。异步流(来自 C # 8)结合了这些概念,让您编写一个await的迭代器,异步生成元素。这种支持基于以下两个接口,它们与我们在“枚举和迭代器”中描述的枚举接口是异步对应的:
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 > ,同时在任务同步完成时(在枚举序列时经常会发生这种情况)支持更高效的执行。IAsyncDisposable 是 IDisposable 的异步版本; 如果您选择手动实现接口,它提供了执行清理的机会:
从序列中提取每个元素(MoveNextAsync)是一个异步操作,因此当元素以零碎的方式到达时(例如在处理来自视频流的数据时) ,异步流是合适的。相比之下,下面的类型更适合于整个序列延迟的情况,但是元素到达时会一起到达:
Task<IEnumerable<T>>
要生成异步流,需要编写一个结合迭代器原理和异步方法的方法。换句话说,你的方法应该同时包含结果返回和 await 返回,并且它应该返回 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 < IEnumable < 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 (var data in await RangeTaskAsync(0, 10, 500))
Console.WriteLine (data);
Querying IAsyncEnumerable< T >
System.Linq.Async
包定义了LINQ异步查询操作,使用的是IAsyncEnumerable< int >
IAsyncEnumerable<int> query =
from i in RangeAsync (0, 10, 500)
where i % 2 == 0
select i * 10;
IAsyncEnumerable< T > in ASP.Net Core
控制器现在可以返回 IAsyncEnumerable< T >
[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;
}
Optimizations
Completing synchronously
一个异步方法可以在 await 之前返回
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,则执行将在没有发生等待的情况下返回给调用方,并且该方法将返回已经发出信号的任务。这被称为同步完成。
当你 await 一个同步完成的任务时,执行不会返回给调用者并通过延续返回,相反,它会立即进入下一个语句。编译器通过检查 awaiter 上的 IsCompleted 属性来实现这种优化,换句话说,每当你 await 时
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());
await 同步返回的异步函数仍然会带来(非常)小的开销,可能是2019年的个人电脑上的20纳秒。相比之下,跳转到线程池会带来上下文切换的开销(可能是一到两微秒) ,跳转到 UI 消息循环的开销至少是这个开销的10倍(如果 UI 线程很忙,则会更长)。
可以在一个标记了异步的方法里面,不调用 await,虽然编译器会警告。这种方法在重写virtual/abstract方法的时候很有用,如果你的实现不需要异步。另外一种方式是使用 Task.FromResult 返回结果
Task<string> Foo() { return Task.FromResult ("abc"); }
Asynchronous Patterns
Cancellation
能够在并发操作启动后取消该操作通常很重要,可能是为了响应用户请求。实现这一点的一个简单方法是使用一个取消标志,我们可以通过编写如下类来封装该标志:
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。这会将 IsCancelationRequest 设置为 true,这会导致 Foo 在短时间内发生 OperationCanceledException 错误
抛开线程安全性不谈(我们应该在读/写 IsCancelationRequsted 时进行锁定) ,这种模式是有效的,CLR 提供了一种名为 CancelationToken 的类型,它与我们刚才展示的非常相似。但是,它缺少 Cancel 方法; 该方法在另一个名为 CcellationTokenSource 的类型上公开。这种分离提供了一些安全性: 只能访问 CcellationToken 对象的方法可以检查取消,但不能启动取消
var cancelSource = new CancellationTokenSource();
var cancelSource = new CancellationTokenSource();
Task foo = Foo (cancelSource.Token);
... (sometime later)
cancelSource.Cancel();
CLR 中的大多数异步方法都支持取消令牌,包括 Delay 。如果我们修改 Foo,让它将其令牌传递给延迟方法,那么任务将在请求时立即结束(而不是一秒钟之后) :
async Task Foo (CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine (i);
await Task.Delay (1000, cancellationToken);
}
}
注意,我们不再需要调用 ThrowIfCancelationRequsted,因为Task.Delay正在为我们做这件事。取消令牌可以很好地沿着调用堆栈向下传播(就像取消请求通过异常向上级联调用堆栈一样)
同步方法也可以支持取消。在这种情况下,要取消的指令需要异步传递(例如,来自另一个任务)。
var cancelSource = new CancellationTokenSource();
Task.Delay (5000).ContinueWith (ant => cancelSource.Cancel());
实际上,您可以在构造 CcellationTokenSource 时指定一个时间间隔,以便在设定的时间段之后启动取消操作(正如我们演示的那样)。它对于实现超时非常有用,无论是同步的还是异步的:
var cancelSource = new CancellationTokenSource (5000);
try { await Foo (cancelSource.Token); }
catch (OperationCanceledException ex)
{ Console.WriteLine ("Cancelled"); }
CcellationToken 结构提供了一个 Register 方法,允许您注册一个回调委托,该委托将在取消时触发; 它返回一个对象,该对象可用于撤消注册。
由编译器的异步函数生成的任务在未处理的 OperationCanceledException 时自动进入“ Cancled”状态(IsCancled 返回 true,IsFault 返回 false)。对于使用 Task.Run 创建的任务,也是如此,您需要将(相同的) CancelationToken 传递给构造函数。在异步场景中,错误任务和取消任务之间的区别并不重要,因为在等待时两者都抛出 OperationCanceledException; 在高级并行编程场景(特别是条件延续)中,这一点很重要。
Progress Reporting
有时,您会希望异步操作在运行时报告进度。一个简单的解决方案是将 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...
}
});
}
IProgress< T > and Progress< T >
CLR 提供了一对类型来解决这个问题: 一个名为 IProgress < T > 的接口和一个实现这个名为 Progress < T > 的接口的类。实际上,它们的用途是“包装”一个委托,以便 UI 应用程序可以通过同步上下文安全地报告进度
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...
}
});
}
var progress = new Progress<int> (i => Console.WriteLine (i + " %"));
await Foo (progress);
The Task-Based Asynchronous Pattern
.NET 有上百个返回task的异步任务,提供给我们 await,这些方法大多数是关于 I/O 的。而这些方法大多数是遵循一个叫做 Task-Based Asynchronous Pattern(TAP) 的模式,这是迄今为止我们所描述的内容的合理形式化
- 返回一个“热(运行中)”任务 Task or Task< TResult >
- “Async”后缀
- 有接收cancellation token和或者IProgress< T >的重载方法
- 快速返回调用方(只有很小的同步初始化阶段)
- 如果是与 I/O-bound 有关,不会绑定线程
Task Combinators
对异步函数有一个一致的协议(它们一致地返回任务)的一个很好的结果是,可以使用和编写任务组合器ーー有效地组合任务的函数,而不必考虑这些特定任务做什么。
CLR有两种 task combinators:Task.WhenAny和Task.WhenAll
WhenAny
Task.WhenAny 返回当一组任务中的任何一个完成时完成的任务
Task<int> winningTask = await Task.WhenAny(Delay1(), Delay2(), Delay3());
Console.WriteLine ("Done");
Console.WriteLine (winningTask.Result); // 1
因为 t 本身返回一个任务,所以我们对它进行 await,它返回第一个完成的任务。我们的示例是完全非阻塞的ーー包括访问 Result 属性时的最后一行(因为 winningTask 将已经完成)。尽管如此,最好还是把 await 放在winningTask上
Console.WriteLine (await winningTask);
还可以连续调用int answer = await await Task.WhenAny (Delay1(), Delay2(), Delay3());
WhenAll
当传递给它的所有任务都完成时,返回一个完成的任务
await Task.WhenAll (Delay1(), Delay2(), Delay3());
这种效果等同于
Task task1 = Delay1(), task2 = Delay2(), task3 = Delay3();
await task1; await task2; await task3;
不同之处(除了需要3个等待而不是1个等待的效率较低之外)在于,如果 task1出错,我们将永远无法进入 task2/task3的 await,而且它们的任何异常都不会被观察到
相比之下,Task. When 在所有任务都完成之后才会完成ーー即使出现故障也是如此。如果存在多个错误,则将它们的异常合并到 AggregateException 。使用 Task < TResult > 类型的任务调用 WhenAll 将返回 Task < TResult [] > ,给出所有任务的组合结果
Task<int> task1 = Task.Run (() => 1);
Task<int> task2 = Task.Run (() => 2);
int[] results = await Task.WhenAll (task1, task2); // { 1, 2 }
Custom combinators
最简单的组合器是接收单个task
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
}
我们可以通过取消 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放弃task
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.InnerException);
else
tcs.TrySetResult (ant.Result);
});
return tcs.Task;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?