阻塞(Blocking)
阻塞
- 如果线程的执行由于某种原因导致暂停,那么就认为该线程被阻塞了。
- 例如在 Sleep 或者 Join 等待其他线程结束。
- 被阻塞的线程会立即将其处理器的时间片生成给其它线程,从此就不再消耗处理器时间,直到满足其阻塞条件为止。
解除阻塞(Unblocking)
- 当遇到下列四种情况的时候,就会解除阻塞:
- 阻塞条件被满足
- 操作超时(如果设置超时的话)
- 通过 Thread.Interrupt() 进行打断
- 通过 Thread.Abort() 进行中止
上下文切换(Context Switching)
当线程阻塞或解除阻塞时,操作系统将执行上下文切换。这会产生少量开销,通常为 1 或者 2 微秒。
I/O-bound vs Compute-bound(或CPU-Bound)
- 一个花费大部分时间等待某事发生的操作称为 I/O-bound
- I/O 绑定操作通常涉及输入或输出,但这不是硬性要求:Thread.Sleep() 也被视为 I/O-bound
- 相反,一个花费大部分时间执行 CPU 密集型工作的操作称为 Compute-bound。
阻塞 vs 忙等待(自旋 Spinning)
- I/O-bound 操作的工作方式有两种:
- 在当前线程上同步地等待,Console.ReadLine(), Thread.Sleep(), Thread.Join()...
- 异步地操作,在稍后操作完成时触发一个回调动作。
- 同步等待的 I/O-bound 操作将大部分时间花在阻塞线程上。
- 它们也可以周期性地在一个循环里进行“打转(自旋)”
while (DateTime.Now < nextStartTime)
Thread.Sleep(100); //这个算半阻塞半自旋
while (DateTime.Now < nextStartTime); //这个是纯自旋
阻塞和忙等待的细微差别:
- 首先,如果您希望条件很快得到满足(几微秒内),则短暂自旋可能会很有效,因为它避免了上下文切换的开销和延迟。
- .NET 提供了特殊的方法和类,来提供帮助 SpinLock 和 SpinWait
- 其次,阻塞也不是零成本。这是因为每个线程在生存期间会占用大约1MB的内存,并会给 CLR 和操作系统带来持续的管理开销。
- 因此,在需要处理成百上千个并发操作的大量 I/O-bound 程序的上下文中,阻塞可能会很麻烦
- 所以,此类程序需要使用基于回调的方法,在等待时完全撤销其线程。
前台和后台线程(Foreground vs Background Threads)
- 可以通过 IsBackground 属性判断线程是否后台线程
- 进程以这种形式终止的时候,后台线程执行栈中的 finally 块就不会执行了。(少数几个 finally 不执行的情况)
- 如果想让它执行,可以在退出程序时使用 Join 来等待后台线程(自建线程),或者使用 signal construct(线程池)
- 应用程序无法正常退出的一个常见原因是有前台线程。
信号(Signaling)
- 有时,你需要让某线程一直处于等待的状态,直至接收到其他线程发来的通知。这就叫做 signaling(发送信号)。
- 最简单的信号结构就是 ManualResetEvent。
- 调用它上面的 WaitOne 方法会阻塞当前的线程,直到另一个线程通过调用 Set 方法来开启信号。
- 调用完 Set 之后,信号会处于“打开”的状态。可以通过调用 Reset 方法将其再次关闭。
示例
class Program
{
static void Main(string[] args)
{
var signal = new ManualResetEvent(false);
new Thread(() =>
{
Console.WriteLine("Waiting for signal...");
signal.WaitOne();
signal.Dispose();
Console.WriteLine("Got signal!");
}).Start();
Thread.Sleep(3000);
signal.Set(); //打开了信号
}
}
富客户端应用程序的线程
在 WPF,UWP,WinForm 等程序中,通常都有一个渲染UI、监听鼠标键盘等事件的 UI线程
针对耗时的操作,通常都是启用一个 worker 线程执行,执行完毕再更新到 UI
-
富客户端应用的线程模型通常是:
- UI 元素和控件只能从创建它们的线程来进行访问(通常是主 UI 线程)
- 当想从 worker 线程更新 UI 的时候,你必须把请求交给 UI 线程
-
比较底层的实现是:
- WPF,在元素的 Dispatcher 对象上调用 BeginInvoke 或 Invoke
- WinForm,调用控件的 BeginInvoke 或 Invoke
- UWP,调用 Dispatcher 对象上的 RunAsync 或 Invoke
所有这些方法都接受一个委托。
BeginInvoke 或 RunAsync 通过将委托排队到 UI 线程的消息队列来执行工作。
-
而 Invoke 执行相同的操作,但随后会进行阻塞,直到 UI 线程读取并处理消息。
- 因此,Invoke 允许您从方法中获取返回值。
- 如果不需要返回值,BeginInvoke/RunAsync 更可取,因为它们不会阻塞调用方,也不会引入死锁的可能性
SynchronizationContext 同步上下文
- 在 System.ComponentModel 下有一个抽象类:SynchronizationContext,它使得 Thread Marshaling 得到泛化。
- Marshaling:可以理解为序列化。Serialize 是 Marshaling 的一种
- 针对移动、桌面等富客户端应用的 API,它们都定义和实例化了 SynchronizationContext 的子类
- 可以通过静态属性 SynchronizationContext.Current 来获得(在UI线程)
- 捕获该属性让你可以在稍后的时候从 worker 线程向 UI 线程 发送数据
- 调用 Post 就相当于调用 Dispatch 或 Control 上面的 BeginInvoke 方法
- 还有一个 Send 方法,等价于 Invoke 方法
ThreadPool 线程池
- 当开始一个线程的时候,将花费几百微秒来组织类似以下的内容:
- 一个新的局部变量栈(stack)
- 线程池就可以节省这种开销:
- 通过预先创建一个可以循环使用线程的池来减少这一开销。
- 线程池对于高效的并行编程和细粒度并发是必不可少的
- 它允许在不被线程启动的开销淹没的情况下运行短期操作
使用线程池线程需要注意的几点
- 不可以设置池内线程的 Name
- 池线程都是后台线程
- 阻塞池线程可使性能降级
- 你可以自由地更改池线程的优先级。当它释放回池的时候,优先级将还原为正常状态
- 可以通过 Thread.CurrentThread.IsThreadPoolThread 属性来判断是否执行在池线程上
进入线程池
- 最简单的、显式的在池线程运行代码的方式就是使用 Task.Run
谁使用了线程池
- WCF、Remoting、ASP.NET、ASMX Web Services 应用服务器
- System.Timers.Timer、System.Threading.Timer
- 并行编程结构
- BackgroundWorker 类(现在很多余)
- 异步委托(现在很多余)
线程池中的整洁
- 线程池提供了另一个功能,即确保临时超出 Compute-Bound 的工作不会导致 CPU 超额订阅
- 超额订阅:活跃的线程超过 CPU 的核数,操作系统就需要对线程进行时间切片
- 超额订阅对性能影响很大,时间切片需要昂贵的 上下文切换,并且可能使 CPU 缓存失效,而 CPU 缓存对于现代处理器的性能至关重要
CLR 的策略
- CLR 通过对任务排队并对其启动进行节流限制来避免线程池中的超额订阅。
- 它首先运行尽可能多的并发任务(只要还有 CPU 核),然后通过爬山算法调整并发级别,并在特定方向上不断调整工作负载。
- 如果吞吐量提高,它将继续朝同一方向(否则将反转)。
- 这确保它始终追随最佳性能曲线,即使面对计算机上竞争的进程活动时也是如此
- 如果下面两点能够满足,那么 CLR 的策略将发挥出最佳效果:
- 工作项大多是短时间运行的(<250ms,或者理想情况下<100ms),因此 CLR 有很多机会进行测量和调整。
- 大部分时间都被阻塞的工作项不会主宰线程池
如果想充分利用 CPU,那么保持线程池的“整洁”是 非常重要 的。
异步编程
Thread 的问题
- 线程(Thread)是用来创建并发(Concurrency)的一种低级别的工具,它有一些限制,尤其是:
- 虽然开始线程的时候可以方便地传入数据,但是当 Join 的时候,很难从线程获得返回值。
- 可能需要设置一些共享字段。
- 如果操作抛出异常,捕获和传播该异常都很麻烦。
- 无法告诉线程在结束时开始做另外的工作,你必须进行 Join 操作(在进程中阻塞当前的线程)
- 虽然开始线程的时候可以方便地传入数据,但是当 Join 的时候,很难从线程获得返回值。
- 很难使用较小的并发(concurrent)来组建大型的并发。
- 导致了对手动同步的更大依赖以及随之而来的问题。
Task 类
- Task 类可以很好的解决上述问题
- Task 是一个相对高级的抽象:它代表了一个并发操作
- 该操作可能由 Thread 支持,或不由 Thread 支持
- Task 是可组合的(可使用 Continuation 把它们串成链)
- Tasks 可以使用线程池来减少启动延迟
- 使用 TaskCompletionSource,Tasks 可以利用回调的方式,在等待 I/O绑定操作时完全避免线程。
开始一个 Task:Task.Run()
- Task 类在 System.Threading.Tasks 命名空间下。
- 开始一个 Task 最简单的办法就是使用 Task.Run(4.0 里是 Task.Factory.StartNew)
- 传入一个 Action 委托即可
- Task 默认使用线程池,也就是后台线程:
- 当主线程结束时,你创建的所有 tasks 都会结束。
- Task.Run 返回一个 Task 对象,可以使用它来监视其过程
- 在 Task.Run 之后,我们没有调用 Start,因为该方法创建的是 “热”任务(Hot Task)
- 可以通过 Task 的构造函数创建“冷”任务,但是很少这样做
- 可以通过 Task 的 Status 属性来跟踪 task 的执行状态。
Wait 等待
- 调用 task 的 Wait 方法会进行阻塞直到操作完成
- 相当于调用 thread 上的 Join 方法
- Wait 也可以让你指定一个超时时间和一个取消令牌来提前结束等待。
Long-running tasks
- 默认情况下,CLR 在线程池中运行 Task,这非常适合短时间运行的 Compute-Bound 类工作。
- 针对长时间运行的任务或者阻塞操作,你可以不采用线程池
- 如果同时运行多个 long-running tasks(尤其是其中有处于阻塞状态的),那么性能将会受到很大影响,这时有比 TaskCreationOptions.LongRunning 更好的办法:
- 如果任务是 I/O-bound,TaskCompletionSource 和异步函数可以让你用回调(Continuations)代替线程来实现并发。
- 如果任务是 Compute-bound,生产者/消费者队列允许你对任务的并发性进行限流,避免把其他线程和进程饿死。
Task 的返回值
- Task 有一个泛型子类叫做 Task<Result>,它允许发出一个返回值。
- 使用 Func<TResult> 委托或兼容的 Lambda 表达式来调用 Task.Run() 就可以得到 Task<TResult>。
- 随后,可以通过 Result 属性来获得返回的结果。
- 如果这个 task 还没有完成操作,访问 Result 属性会阻塞该线程直到该 task 完成操作。
- Task<TResult> 可以看作是一种所谓的“未来/许诺”(future/promise),在它里面包裹着一个 Result,在稍后的时候就会变得可用。
Task 的异常
- 与 Thread 不一样,Task 可以很方便地传播异常
- 如果你的 task 里面抛出了一个未处理的异常(故障),那么该异常就会重新被抛出给:
- 调用了 wait() 的地方
- 访问了 Task<TResult> 的 Result 属性的地方。
- CLR 将异常包裹在 AggregateException 里,以便在并行编程场景中发挥很好的作用。
- 无需重新抛出异常,通过 Task 的 IsFaulted 和 IsCanceled 属性,也可以检测出 Task 是否发生了故障:
- 如果两个属性都返回 false,那么没有错误发生。
- 如果 IsCanceled 为 true,那就说明一个 OperationCanceledException 为该 Task 抛出了。
异常与“自治”的 Task
- 自治的,“设置完就不管了”的 Task。就是指不通过调用 Wait() 方法、Result 属性或 continuation 进行会合的任务。
- 针对自治的 Task,需要像 Thread 一样,显式的处理异常,避免发生“悄无声息的故障”。
- 自治 Task 上未处理的异常称为未观察到的异常。
未观察到的异常
- 可以通过全局的 TaskScheduler.UnobservedTaskException 来订阅未观察到的异常。
- 关于什么是“未观察到的异常”,有一些细微的差别:
- 使用超时进行等待的 Task,如果在超时后发生故障,那么它将会产生一个“未观察到的异常”。
- 在 Task 发生故障后,如果访问 Task 的 Exception 属性,那么该异常就被认为是“已观察到的”。
Continuation
- 一个 Continuation 会对 Task 说:“当你结束的时候,继续再做点其他的事”
- Continuation 通常是通过回调的方式实现的
- 当操作一结束,就开始执行
static void Main(string[] args)
{
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);
});
Console.ReadLine();
}
- 在 task 上调用 GetAwaiter 会返回一个 awaiter 对象
- 它的 OnCompleted 方法会告诉之前的 task:“当你结束/发生故障的时候要执行委托”。
- 可以将 Continuation 附加到已经结束的 task 上面,此时 Continuation 将会被安排立即执行。
awaiter
- 任何可以暴露下列两个方法和一个属性的对象就是 awaiter:
- OnCompleted
- GetResult
- 一个 叫做 IsCompleted 的 bool 属性
- 没有接口或者父类来统一这些成员。
- 其中 OnCompleted 是 INotifyCompletion 的一部分
如果发生故障
- 如果之前的任务发生故障,那么当 Continuation 代码调用 awaiter.GetResult() 时候,异常会被重新抛出。
- 无需调用 GetResult(),我们可以直接访问 task 的 Result 属性。
- 但是调用 GetResult 的好处是,如果 task 发生故障,那么异常会被直接地抛出,不是包裹在 AggregateException 里面,这样的话 catch 块就简洁很多了。
非泛型 task
- 针对非泛型的 task,GetResult() 方法返回值是 void,它就是用来重新抛出异常的。
同步上下文
- 如果同步上下文出现了,那么 OnCompleted 会自动捕获它,并将 Continuation 提交到这个上下文中。这一点在富客户端应用中非常有用,因为它会把 Continuation 放回到 UI 线程中。
- 如果是编写一个库,则不希望出现上述行为,因为开销较大的 UI 线程切换应该在程序运行离开库的时候只发生一次,而不是出现在方法调用之间。所以,我们可以使用 ConfigureAwait 方法来避免这种行为。
static void Main(string[] args)
{
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.ConfigureAwait(false).GetAwaiter();
awaiter.OnCompleted(() =>
{
int result = awaiter.GetResult();
Console.WriteLine(result);
});
Console.ReadLine();
}
- 如果没有同步上下文出现,或者你使用的是 ConfigureAwait(false),那么 Continuation 会运行在先前 task 的同一个线程上,从而避免不必要的开销。
ContinueWith
- 另外一种附加 Continuation 的方式就是调用 task 的 ContinueWith 方法
- ContinueWith 本身返回一个 Task,可以用来附加更多的 Continuation。
- 但是,必须直接处理 AggregateException:
- 如果 task 发生故障,需要写额外的代码来把 Continuation 封装(marshall)到 UI 应用上。
- 在非 UI 上下文中,若想让 Continuation 和 task 执行在同一个线程上,必须指定 TaskContinuationOptions.ExecuteSynchronously,否则它将弹回到线程池。
- ContinueWith 对于并行编程来说非常有用。
TaskCompletionSource
- Task.Run 创建 Task
- 另一种方式就是用 TaskCompletionSource 来创建 Task
- TaskCompletionSource 让你在稍后开始和结束的任意操作中创建 Task
- 它会为你提供一个可手动执行的“从属”Task
- 指示操作何时结束或发生故障
- 它会为你提供一个可手动执行的“从属”Task
- 它对 I/O-bound 类工作比较理想
- 可以获得所有 Task 的好处(传播值、异常、Continuation等)
- 不需要在操作时阻塞线程
使用 TaskCompletionSource
- 初始化一个实例即可
- 它有一个 Task 属性可返回一个 Task
- 该 Task 完全由 TaskCompletionSource 控制
- 调用任意一个方法都会给 Task 发信号:
- 完成、故障、取消
- 这些方法只能调用一次,如果再次调用:
- SetXxx 会抛出异常
- TryXxx 会返回 false
static void Main(string[] args)
{
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);
}
下面是利用 TaskCompletionSource,自己实现 Task.Run
Task<TResult> Run<TResult>(Func<TResult> function)
{
var tcs = new TaskCompletionSource<TResult>();
new Thread(() =>
{
try
{
tcs.SetResult(function());
}
catch (System.Exception ex)
{
tcs.SetException(ex);
}
}).Start();
return tcs.Task;
}
static void Main(string[] args)
{
Task<int> task = Run(() =>
{
Thread.Sleep(5000);
return 42;
});
}
TaskCompletionSource 的真正魔力
- 它创建 Task,但并不占用线程
static void Main(string[] args)
{
var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted(() =>
{
Console.WriteLine(awaiter.GetResult());
});
}
static Task<int> GetAnswerToLife()
{
var tcs = new TaskCompletionSource<int>();
var timer = new System.Timers.Timer(5000) { AutoReset = false };
timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult(42); };
timer.Start();
return tcs.Task;
}
-P17
同步 vs 异步
- 同步操作会在返回调用者之前完成它的工作
- 异步操作会在返回调用者之后去做它的(大部分)工作
- 异步的方法更为少见,会启用并发,因为它的工作会与调用者并行执行
- 异步方法通常会很快(立即)就会返回到调用者,所以叫非阻塞方法
- 目前见到的大部分的异步方法都是通用目的的:
- Thread.Start
- Task.Run
- 可以将 continuation 附加到 Task 的方法
什么是异步编程
- 异步编程的原则是将长时间运行的函数写成异步的。
- 传统的做法是将长时间运行的函数写成同步的,然后从新的线程或 Task 进行调用,从而按需引入并发。
- 上述异步方式的不同之处在于,它是从长时间运行函数的内部启动并发。这有两点好处:
- I/O-bound 并发可不使用线程来实现。可提高可扩展性和执行效率;
- 富客户端在 worker 线程会使用更少的代码,简化了线程安全性。
异步编程的两种用途
- 编写高效处理大量并发 I/O 的应用程序(典型:服务器端应用)
- 挑战并不是线程安全(因为共享状态通常是最小化的),而是执行效率
- 特别的,每个网络请求并不会消耗一个线程
- 挑战并不是线程安全(因为共享状态通常是最小化的),而是执行效率
- 富客户端应用程序 调用图(call graph)
- 在富客户端应用里简化线程安全。
- 如果调用图中任何一个操作是长时间运行的,那么整个 call graph 必须运行在 worker 线程上,以保证 UI 响应。
- 得到一个横跨多个方法的单一并发操作(粗粒度)
- 需要为 call graph 中的每个方法考虑线程安全。
- 异步的 call graph,直到需要才开启一个线程,通常较浅(I/O-bound 操作完全不需要)
- 其它的方法可以在 UI 线程执行,线程安全简化。
- 并发的粒度适中:
- 一连串小的并发操作,操作之间会弹回到 UI 线程
- 如果调用图中任何一个操作是长时间运行的,那么整个 call graph 必须运行在 worker 线程上,以保证 UI 响应。
经验之谈
- 为了获得上述好处,下列操作建议异步编写:
- I/O-bound 和 Compute-bound 操作
- 执行超过 50 毫秒的操作
- 另一方面过细的粒度会损害性能,因为异步操作也有开销。
-P18
异步编程和 Continuation
- Task 非常适合异步编程,因为它们支持 Continuation(对异步非常重要)
- 第 16 讲里面 TaskCompletionSource 的例子。
- TaskCompletionSource 是实现底层 I/O-bound 异步方法的一种标准方式
- 对于 Compute-bound 方法,Task.Run 会初始化绑定线程的并发。
- 把 task 返回调用者,创建异步方法;
- 异步编程的区别:目标是在调用图较低的位置来这样做。
- 富客户端应用中,高级方法可以保留在UI线程和访问控制以及共享状态上,不会出现线程安全问题。
例子1:粗粒度的异步
static void Main(string[] args)
{
Task.Run(() => DisplayPrimeCounts());
Console.ReadKey();
}
static 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!");
}
static 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));
}
例子2:改善后的异步
static void Main(string[] args)
{
DisplayPrimeCounts();
Console.ReadKey();
}
static void DisplayPrimeCounts()
{
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!");
}
static 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)));
}
看执行结果,可以发现虽然实现了异步,但是顺序是乱的。
语言对异步的支持非常重要
- 需要对 task 的执行序列化。
- 例如 Task B 依赖于 Task A 的执行结果。
- 为此,必须在 continuation 内部触发下一次循环
例子3:顺序化执行
static void Main(string[] args)
{
DisplayPrimeCounts();
Console.ReadKey();
}
static void DisplayPrimeCounts()
{
DisplayPrimeCountsFrom(0);
}
static 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");
});
}
static 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)));
}
(例子4太麻烦就不写了)
async 与 await
对于不想复杂地实现异步非常重要。
例子5
static async Task Main(string[] args)
{
await DisplayPrimeCountsAsync();
}
static 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!");
}
static 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)));
}
- 命令式循环结构不要和 continuation 混合在一起,因为它们依赖于当前本地状态。
- 另一种实现,函数式写法(Linq 查询),它也是 响应式编程(Rx)的基础。
-P19
await
- async 和 await 关键字可以让你写出和同步代码一样简洁且结构相同的异步代码
awaiting
- await 关键字简化了附加 continuation 的过程。
var result = await expression;
statement(s);
就相当于:
var awaiter = expression.GetAwaiter();
awaiter.OnCompleted(() =>
{
var result = awaiter.GetResult();
statement(s);
});
async 修饰符
- async 修饰符会让编译器把 await 当作关键字而不是标识符(C# 5 以前可能会使用 await 作为标识符)
- async 修饰符只能应用于方法(包括 lambda 表达式)
- 该方法可以返回 void、Task、Task<TResult>
- async 修饰符对方法的签名或 public 元数据没有影响(和 unsafe 一样),它只会影响方法内部。
- 在接口内使用 async 是没有意义的
- 使用 async 来重载非 async 的方法却是合法的
- 使用了 async 修饰符的方法就是“异步函数”。
异步方法如何执行
- 遇到 await 表达式,执行(正常情况下)会返回调用者
- 就像 iterator 里面的 yield return。
- 在返回前,运行时会附加一个 continuation 到 await 的 task
- 为保证 task 结束时,执行会跳回原方法,从停止的地方继续执行。
- 如果发生故障,那么异常会被重新抛出
- 如果一切正常,那么它的返回值就会赋给 await 表达式
可以 await 什么?
- 你 await 的表达式通常是一个 task
- 也可以满足下列条件的任意对象:
- 有 GetAwaiter 方法,它返回一个 awaiter(实现了 INotifyCompletion.OnCompleted 接口)
- 返回适当类型的 GetResult 方法
- 一个 bool 类型的 IsCompleted 属性
捕获本地状态
- await 表达式的最牛之处就是它几乎可以出现在任何地方。
- 特别的,在异步方法内,await 表达式可以替换任何表达式。
- 除了 lock 表达式和 unsafe 上下文
await 之后在哪个线程上执行
- 在 await 表达式之后,编译器依赖于 continuation(通过 awaiter 模式)来继续执行
- 如果在富客户端应用的 UI 线程上,同步上下文会保证后续是在原线程上执行;
- 否则,就会在 task 结束的线程上继续执行。
UI 上的 await
- 本例中,只有 GetPrimesCountAsync 中的代码在 worker 线程上运行
- Go 中的代码会“租用” UI 线程上的时间
- 可以说:Go是在消息循环中“伪并发”地执行
- 也就是说:它和 UI 线程处理的其他事件是穿插执行的
- 因为这种伪并发,唯一能发生“抢占”的时刻就是在 await 期间。
- 这其实简化了线程安全,防止重新进入即可
- 这种并发发生在调用栈较浅的地方(Task.Run 调用的代码里)
- 为了从该模型获益,真正的并发代码要避免访问共享状态或 UI 控件。
与粗粒度的并发相比
- 例如使用 BackgroundWorker(例子,Task.Run)
- 整个同步调用图都在 worker 线程上
- 必须在代码中到处使用 Dispatcher.BeginInvoke
- 循环本身在 worker 线程上
- 引入了 race condition
- 若实现取消和过程报告,会使得线程安全问题更容易发生,在方法中新添加任何的代码也是同样的效果。
编写异步函数
- 对于任何异步函数,你可以使用 Task 替代 void 作为返回类型,让该方法成为更有效的异步(可以进行 await)。
- 并不需要在方法体中显式的返回 Task。编译器会生成一个 Task(当方法完成或发生异常时),这使得创建异步的调用链非常方便
- 编译器会对返回 Task 的异步函数进行扩展,使其成为当发送信号或发送故障时使用 TaskCompletionSource 来创建 Task 的代码。
- 因此,当返回 Task 的异步方法结束的时候,执行就会跳回到对它进行 await 的地方。
编写异步函数 富客户端场景下
- 富客户端场景下,执行在此刻会跳回到 UI 线程(如果目前不在 UI 线程的话)。
- 否则,就在 continuation 返回的任意线程上继续执行。
- 这意味着,在异步调用图中向上冒泡的时候,不会发生延迟成本,除非是 UI 线程启动的第一次“反弹”。
返回 Task<TResult>
- 如果方法体返回 TResult,那么异步方法就可以返回 Task<TResult>
- 其原理就是给 TaskCompletion 发送的信号带有值,而不是 null。
- 与同步编程很相似,是故意这样设计的。
C# 中如何设计异步函数
- 以同步的方式编写方法
- 使用异步调用来代替同步调用,并且进行 await
- 除了顶层方法外(UI 控件的 event handler),把你方法的返回类型升级为 Task 或者 Task<TResult>,这样就可以进行 await 了。
编译器能对异步函数生成 Task 意味着什么?
- 大多数情况下,你只需要在初始化 IO-bound 并发的底层方法里显式地初始化 TaskCompletionSource,这种情况很少见。
- 针对初始化 compute-bound 的并发方法,你可以使用 Task.Run 来创建 Task。
异步调用图执行
- 整个执行与之前同步例子中调用图顺序一样,因为我们对每个异步函数的调用都进行了 await。
并行(Parallelism)
- 不使用 await 来调用异步函数会导致并行执行的发生。
- 例如:_button.Click += (sender, args) => Go();
- 确实也能满足保持 UI 响应的并发要求。
- 同样,可以并行跑两个操作:
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1; await task2;
异步 lambda 表达式
添加 async 关键字后,一样的。
调用方式也一样。
附加 event handler 的时候也可以使用 Lambda 表达式,也可以返回 Task<TResult>。