第14章 并发与异步
14.2 线程
进 程提供了程序执行的独立环境, 进 程持有 线 程,且至少持有一个 线 程。这些 线 程共享 进 程提供的执行环境。
14.2.1 创建线程
创建线程的步骤为:
- 实例化
Thread
对象,通过构造函数传入 ThreadStart
委托。 - 调用
Thread.Start()
方法。
static void Main(){Thread t = new Thread (WriteY); // Kick off a new threadt.Start(); // running WriteY()// Simultaneously, do something on the main thread.for (int i = 0; i < 1000; i++) Console.Write ("x");
}static void WriteY(){for (int i = 0; i < 1000; i++) Console.Write ("y");
}
单核计算机,操作系统会为每一个线程划分时间片模拟并发执行;
在 Windows 系统中,该时间片的默认值为
20ms。随着系统的更新,时间片时长已更改。多核计算机,两个线程可以并行执行。
会和机器上其他执行的进程进行竞争。
线程是抢占式的,它的执行和其他线程的代码是交错执行的。
IsAlive
属性
Thread
启动后, IsAlive
属性会返回 true
,直至线程停止。线程停止后 将无法 再启动。
Name
属性
每个 Thread
都有一个 Name
属性用于调试,在 Visual Stuido 的 Thread 窗口、Debug Location 工具栏上,会显示线程的 Name
。
在 C#8.0 及早期版本, Name
属性只能设置一次,更改将抛出 InvalidOperationException
异常。
Thread.CurrentThread
Thread.CurrentThread
是静态属性,可以通过该属性获取当前所在线程。
14.2.2 Join
(汇合)与 Sleep
(休眠)
static void Main(){Thread t = new Thread (Go);t.Start();t.Join();Console.WriteLine ("Thread t has ended!");
}static void Go() {for (int i = 0; i < 1000; i++)Console.Write ("y");
}
Thread.Join
Thread.Join
方法用于 等待线程结束 ,会阻塞当前线程。
调用 Join
时可以指定超时时间,并返回 bool
值表示结果。 true 为正常结束, false 为超时。
Thread.Sleep
和 Thread.Yield
Sleep
方法将暂停指定时间,会阻塞当前线程。
Thread.Sleep(0)
之前我们提到时间片,Thread.Sleep
方法将放弃时间片剩余时间,此时系统将重新进行调度:
-
Thread.Sleep(0)
放弃时间片剩余时间, 仍会 参与下次调度的竞选。
-
Thread.Sleep(1)
放弃时间片剩余时间,且让线程 沉睡 , 放弃 下次竞选。
-
Thread.Yield()
放弃时间片剩余时间, 仍会 参与下次调度的竞选,但在 同级 线程中,最后参与竞选。
14.2.3 阻塞
当线程由于特定原因暂停执行,它就是阻塞的。阻塞会立即交出时间片,不再消耗处理器时间,直至阻塞结束。
可以使用 ThreadState
属性查看线程阻塞状态,它包含如下成员:
public enum ThreadState{Initialized,Ready,Running,Standby,Terminated,Wait,Transition,Unknown
}
ThreadState
适用于诊断调试,因获取 ThreadState
时线程仍在执行,因此 ThreadState
不能反映线程当下的实际情况。
线程被阻塞、解除阻塞时,操作系统会进行一次上下文切换(context switch)。这会导致细小的开销,一般在 1~2ms 左右。
14.2.3.1 I/O 密集和计算密集
-
IO 密集
该操作的绝大部分时间都在 等待事件的发生 ,被称为 I/O 密集型。如网页下载、
Console.ReadLine
、Thread.Sleep
。I/O 密集操作一般会涉及输入或输出,但这并非硬性要求。
-
计算密集型
大部分时间用于执行 大量的 CPU 操作 ,被称为计算密集型。
14.2.3.2 阻塞与自旋
自旋,指线程在 循环 中 空转 ,直到 条件成立 :
// 自旋
while (DateTime.Now < nextStartTime)Thread.Sleep(100);
// 持续自旋,会消耗更多的 CPU 资源
while (DateTime.Now < nextStartTime) ;
C7.0 核心技术指南 第7版.pdf - p600 - C7.0 核心技术指南 第 7 版-P600-20240304175658
14.2.4 本地状态与共享状态
CLR 为每一个线程分配了独立的 内存栈 ,从而保证了 局部变量 的隔离。
14.2.5 锁与线程安全
在不确定的多线程上下文中,通过锁(Lock)保证一次只有一个线程能够进入保护的代码,称为线程安全的代码。
C7.0 核心技术指南 第7版.pdf - p603 - C7.0 核心技术指南 第 7 版-P603-20240304180555
14.2.6 向线程传递数据
向线程传递数据有两种方式:
- 使用 Lambda 表达式
static void Main()
{Thread t = new Thread (() => Print ("Hello from t!") );t.Start();
}static void Print (string message) { Console.WriteLine (message); }
- 使用
ParameterizedThreadStart
委托
Thread t = new Thread(Print);
t.Start("Hello from t!");static void Print(object message) { Console.WriteLine(message); }
Lambda 表达式和变量捕获
见8.4.2 捕获变量
见捕获迭代变量
14.2.7 异常处理
集中式异常处理
WPF、UWP 和 Windows Forms 应用程序都支持订阅全局的异常处理事件。分别为:
-
Application.DispatcherUnhandledException
适用于 WPF
-
Application.ThreadException
适用于 Windows Forms
这些事件将会在程序的** 消息循环 调用中发生未处理的异常时触发。这种方式非常适合于记录日志并报告应用程序的缺陷(但需要注意,它不会被 非 UI 线程中发生的未处理异常 触发**)。处理这些事件可以防止应用程序直接关闭,但是为避免应用程序在出现未处理异常后继续执行造成潜在的状态损坏,因此通常需要重新启动应用程序。
-
AppDomain.CurrentDomain.UnhandledException
该事件会在 任何线程 出现未处理异常时触发。从 CLR 2.0 开始,该事件处理器执行完毕后会强行关闭程序,可以在配置文件中添加如下代码防止应用程序关闭:
<configuration><runtime><legacyUnhandledExceptionPolicy enabled="1"/></runtime>
</configuration>
-
TaskScheduler.UnobservedTaskException
该事件用于处理 Task
中未观察到的异常,提供一个最后的机会响应未处理的异常,防止应用程序因此造成的异常而崩溃。
当使用
Task
或Task<T>
执行异步操作时,如果异步操作失败并抛出异常,而应用程序代码没有通过调用 await、Task.Wait、Task.Result 或 Task.Exception 来显式检查这些异常,那么这些异常就被认为是“未观察到”的。
Info
关于未观察到的异常,另见异常和自治的任务、23.4.4.2 延续任务和异常
Info
更详细的介绍,见集中式异常处理
14.2.8 前台线程与后台线程
可以使用线程的 IsBackground
属性来查询或修改线程的前后台状态:
Thread worker = new Thread(()=> Console.ReadLine());
worker.IsBackground = true;
worker.Start();
注意:因前台线程退出导致的后台线程关闭,后台线程的 finally 块、 using 块的清理逻辑将无法正常执行。此时应 显式等待后台线程汇合(join) ,或使用 信号等待句柄 避免此问题。此外应设置超时时间,避免程序无法正常关闭。
C7.0 核心技术指南 第7版.pdf - p607 - C7.0 核心技术指南 第 7 版-P607-20240304221725
14.2.9 线程的优先级
线程的 Priority
属性决定了线程的优先级,其枚举类型包含的成员如下:
public enum ThreadPriority
{Lowest,BelowNormal,Normal,AboveNormal,Highest
}
另外,我们还可以通过 Process
类提升进程的优先级:
using(Process p = Process.GetCurrentProcess())p.PriorityClass = ProcessPriorityClass.High;
14.2.10 信号量
我们可以通过信号量完成线程间互相等待。最简单的信号量是 ManualResetEvent
。可以使用 ManualResetEvent.WaitOne
阻塞当前线程,直至其他线程调用了 ManualResetEvent.Set
打开信号。通过 ManualResetEvent.Reset
可以将信号再次关闭:
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
14.2.11 富客户端应用程序的线程
在 WPF、UWP 和 WindowsForms 应用程序中,如果想在工作线程上更新 UI,必须将请求发送给 UI 线程,这种技术称为 封送(marshal) 。实现这个操作的底层方式有:
- 在 WPF 中,调用元素上的
Dispatcher
对象的BeginInvoke
或Invoke
方法。 - 在 UWP 中,调用
Dispatcher
对象的RunAsync
或Invoke
方法。 - 在 WindowsForms 中:调用控件的
BeginInvoke
或Invoke
方法。
所有这些方法都接收一个委托来引用实际执行的方法,并将委托加入到 UI 线程的 消息队列 上(这个消息队列也处理键盘、鼠标和定时器事件)。
-
BeginInvoke
/RunAsync
不会 阻塞当前线程, 也不会 造成死锁,如果不需要返回值,可以使用它们。
-
Invoke
会 阻塞当前线程,直至 UI 线程读取或者处理了这些消息。使用
Invoke
可以从方法中直接得到返回值。
Info
关于死锁,见22.2.7 死锁
C7.0 核心技术指南 第7版.pdf - p609 - C7.0 核心技术指南 第 7 版-P609-20240304225621
C7.0 核心技术指南 第7版.pdf - p610 - C7.0 核心技术指南 第 7 版-P610-20240304225807
14.2.12 同步上下文
System.ComponentModel.SynchronizationContext
抽象类实现了一般性的线程封送功能。
UWP、WPF、Windows Forms 的富客户端 API 都定义并实例化了 SynchronizationContext
的子类。当运行在 UI 线程上时,可通过静态属性 SynchronizationContext.Current
获得。通过捕获这个属性就可以从工作线程将数据“提交”到 UI 控件上,它们和 *Invoke
有如下关系:
-
SynchronizationContext.Post
相当于BeginInvoke
-
SynchronizationContext.Send
相当于Invoke
partial class MyWindow : Window
{TextBox txtMessage;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 taskUpdateMessage ("The answer");}void UpdateMessage (string message){// Marshal the delegate to the UI thread:_uiSyncContext.Post (_ => txtMessage.Text = message, null);}void InitializeComponent(){SizeToContent = SizeToContent.WidthAndHeight;WindowStartupLocation = WindowStartupLocation.CenterScreen;Content = txtMessage = new TextBox { Width=250, Margin=new Thickness (10), Text="Ready" };}
}
14.2.13 线程池
线程池可以降低线程创建时的开销(几百毫秒)。使用线程池需要注意以下几点:
- 线程池的线程,其
Name
属性无法设置,但调试时可以使用 Visual Studio 的 Threads 窗口附加描述信息。 - 线程池中的线程都是 后 台线程。
- 阻塞 线程池中的线程将影响性能。
我们可以任意设置线程池中线程的优先级,当我们将线程归还线程池时其优先级会 恢复为普通级别 。
Thread.CurrentThread.IsThreadPoolThread
属性可用于确认当前运行的线程是否是一个线程池线程。
14.2.13.1 进入线程池
在线程池上运行代码有两种方式:
-
使用
Task.Run
-
使用
ThreadPool.QueueUserWorkItem
适用于 .NET Framework 4.0 之前的框架。
// Task is in System.Threading.Tasks
Task.Run (() => Console.WriteLine ("Hello from the thread pool"));// The old-school way:
ThreadPool.QueueUserWorkItem (notUsed => Console.WriteLine ("Hello, old-school"));
C7.0 核心技术指南 第7版.pdf - p612 - C7.0 核心技术指南 第 7 版-P612-20240305130413
14.2.13.2 线程池的整洁性
CLR 通过将任务进行排队,并控制任务启动数量来避免线程池超负荷,保证临时性的计算密集型任务不会导致 CPU 超负荷(oversubscription)。
如果满足以下两个条件,则 CLR 的策略将得到最好的效果:
- 大多数工作项目的运行时间非常短暂(小于 250 毫秒或者理想情况下小于 100 毫秒)。这样 CLR 就会有大量的机会进行测量和调整。
- 线程池中不会出现大量以阻塞为主的作业。
在.NET 环境中,线程池用于管理线程的创建和执行,以提高性能和减少资源消耗。当线程被阻塞时,它们无法执行其他工作。但从外部看,可能看起来像是它们正在忙碌。因此,公共语言运行库(CLR)可能会错误地认为需要更多的线程来处理工作负载,从而创建更多的线程。这种过度补偿可能导致资源使用不当,最终影响到应用程序的性能和响应能力。
因此,建议尽可能使用非阻塞或异步编程模式,特别是在涉及到 IO 操作时,使用.NET 的异步 API 可以有效避免这种阻塞,从而提高应用程序的效率和响应性。
14.3 任务(Task
)
14.3.1 启动任务
常用的启动任务的方式有两种:
-
Task.Run
适用于 .NET Framework 4.5(含)之后的版本。
-
Task.Factory.StartNew
这两个方法都会返回 Task
实例,我们可以使用 Task.Status
属性追踪 Task
的状态。
Task.Run (() => Console.WriteLine ("Foo"));
Notice
Task
默认使用线程池中的线程,它们都是 后台 线程。这意味着当主线程结束时,所有的任务也会随之停止。因此,要在控制台应用程序中运行这些例子,必须在启动任务之后 阻塞 主线程(例如在任务对象上调用Wait()
,或者调用Console.ReadLine()
方法):static void Main() {Task.Run(() => Console.WriteLine("Foo"));Console.ReadLine(); }
14.3.1.1 Wait
方法
Task.Wait
方法可以阻塞当前方法,直至任务完成,和 Thread.Join
相似:
Task task = Task.Run (() =>
{Console.WriteLine ("Task started");Thread.Sleep (2000);Console.WriteLine ("Foo");
});
Console.WriteLine (task.IsCompleted); // False
task.Wait(); // Blocks until task is complete
14.3.1.2 长任务
如果要执行长时间阻塞的操作,可以传入 TaskCreationOptions.LongRunning
避免使用线程池线程:
Task task = Task.Factory.StartNew (() =>
{Console.WriteLine ("Task started");Thread.Sleep (2000);Console.WriteLine ("Foo");
}, TaskCreationOptions.LongRunning);task.Wait(); // Blocks until task is complete
C7.0 核心技术指南 第7版.pdf - p615 - C7.0 核心技术指南 第 7 版-P615-20240305172537
14.3.2 返回值
Task<TResult>
为 Task
的泛型子类,允许任务返回一个返回值。可以调用 Task.Run<TResult>(Func<TResult> function)
方法获得。
通过查询 Task.Result
属性可以获得返回值,如果任务还未执行完毕,将 阻塞 当前线程。
Task<int> task = Task.Run (() => { Console.WriteLine ("Foo"); return 3; });int result = task.Result; // Blocks if not already finished
Console.WriteLine (result); // 3
14.3.3 异常
Task
中产生的异常,在调用 Task.Wait
方法或 Task.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!");elsethrow;
}
通过 Task.IsFaulted
和 Task.IsCanceled
属性我们可以判断 Task
是否抛出了异常。
-
Task.IsCanceled
:对应OperationCanceledException
异常 -
Task.IsFaulted
:对应其他异常。
异常和自治的任务
自治任务(即一发即忘)最好在任务代码中显式声明 异常处理代码 ,防止出现难以察觉的错误。
使用静态事件 TaskScheduler.UnobservedTaskException
可以在全局范围订阅未观测的异常。处理该事件,并将错误记录在日志中,是一个有效的处理异常的方式。
C7.0 核心技术指南 第7版.pdf - p617 - C7.0 核心技术指南 第 7 版-P617-20240305175647
14.3.4 延续
有两种方式可以在 Task
结束后执行后续操作:
- 回调方法
Awaiter.OnCompleted
-
ContinueWith
方法
回调方法
为 Task
添加回调函数的方式如下:
- 使用
Task.GetAwaiter
方法获取 Awaiter
实例; - 通过
Awaiter.OnCompleted
注册回调函数。
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
});
使用 OnCompleted
需要注意:
-
如果提供了同步上下文(例如有 UI 线程的 UWP、WPF、Windows Form),
OnCompleted
会 自动捕获,并将延续提交到该上下文中 ; -
如果不希望出现上述行为,需要使用
ConfigureAwait
方法避免此行为:var awaiter = primeNumberTask.ConfigureAwait(false).GetAwaiter();
ContinueWith
方法
ContinueWith
可以延续执行任务,使用方式如下:
Task<int> primeNumberTask = Task.Run (() =>Enumerable.Range (2, 3000000).Count (n => Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));primeNumberTask.ContinueWith (antecedent =>
{int result = antecedent.Result;Console.WriteLine (result);
});
ContinueWith
方法会返回一个 Task
对象。使用时需要注意:
- 先导任务可能 发生异常 ,要注意处理;
- 如果需要将延续封送至 UI 线程,需要编写额外代码(见 #ref#23.4.5);
- 如果希望和先导任务执行在同一线程上,需要指定
TaskContinuationOptions.ExecuteSynchronously
。
Awaiter
C7.0 核心技术指南 第7版.pdf - p618 - C7.0 核心技术指南 第 7 版-P618-20240306124411
Task
的 Awaiter
可以通过 Task.GetAwaiter
方法获得。我们可以使用 Awaiter.GetResult
方法获取执行结果。
Task.Result
和Awaiter.GetResult
的区别:当发生异常时,
Task.Result
将抛出 包装后的异常( AggregateException
) ,Awaiter.GetResult
将抛出 原始异常 。非泛型
Task
的Awaiter
也支持该方法,只是GetResult
返回值为 void
。
14.3.5 TaskCompletionSource
类
TaskCompletionSource
是一个辅助类,其内含一个 Task
属性,以及如下方法:
public class TaskCompletionSource
{public void SetResult(...)public void SetException(...)public void SetCanceled(...)public bool TrySetResult()public bool TrySetException(...)public bool TrySetCanceled()
}
这些方法可以设置 TaskCompletionSource.Task
属性的结果值、异常、取消状态。上述非 Try 方法 不可 多次调用,否则会 抛出异常 。Try 方法即时调用多次, 仅有第一次的 会生效。
TaskCompletionSource
的使用
辅助 Thread 线程
TaskCompletionSource
可以为传统的 Thread 线程 创建 Task
,以支持返回值等操作:
var tcs = new TaskCompletionSource<int>();new Thread (() => { Thread.Sleep (5000); tcs.SetResult (42); }).Start();Task<int> task = tcs.Task; // Our "slave" task.
Console.WriteLine (task.Result); // 42
还可以用 TaskCompletionSource
创建自己的 Task.Run
方法,如下方法相当于:Task.Factory.StartNew
并传递 TaskCreationOptions.LongRunning
参数:
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;
}
与计时器协作
TaskCompletionSource
可以与 计时器( Timer
) 协作,创建一个“定时任务”,且不涉及绑定线程:
var awaiter = GetAnswerToLife().GetAwaiter();
awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult()));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;
}
将其适当改造,可以变成一个通用的 Delay
方法:
Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));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));
C7.0 核心技术指南 第7版.pdf - p621 - C7.0 核心技术指南 第 7 版-P621-20240306224955
14.3.6 Task.Delay
方法
14.4 异步原则
14.4.1 同步操作与异步操作
14.4.2 什么是异步编程
14.4.3 异步编程与延续
14.4.4 语言支持的重要性
14.5 C# 的异步函数
14.5.1 等待 await
await
关键字可以简便地附加 延续 ,如下代码等价:
var result = await expression;
statement(s);
var awaiter = expression.GetAwaiter();
awaiter.OnCompleted(() => {var result = awaiter.GetResult();statement(s);
});
await
可以等待任何:
-
实现了
GetAwaiter()
方法、 -
返回值是可等待对象Awaiter(通常是
TaskAwaiter
)的类型(详见可等待)。Task
实现了该方法,因此可等待
Tips
可等待对象(awaitable object)指
- 实现了
INotifyCompletion
接口、- 具有返回类型恰当的
GetResult()
方法、- 具有 bool 类型的
IsCompleted
属性的类型实例。
async
修饰符
async
修饰符用于指示编译器将 await
作为一个关键字而非标识符来避免二义性(C#5 之前的代码可能将 await
作为标识符)。
C7.0 核心技术指南 第7版.pdf - p628 - C7.0 核心技术指南 第 7 版-P628-20240307124850
14.5.1.1 同步上下文
除 lock
、unsafe
、Main
方法外,await
表达式可以出现在任意位置。
使用 await
,如果代码运行在富客户端应用程序的 UI 线程上,同步上下文会将执行恢复到同一个线程上。否则,执行过程会恢复到任务所在的那个线程上。线程的更换不会影响执行顺序。
使用 await 产生的上下文同步,就像是坐在出租车上游览:
- 在同步上下文中,代码总是使用同一辆出租车(线程);
- 在没有同步上下文的情况下,每一次都会使用不同的出租车。
但无论是哪一种情况,旅程都是相同的。
详见同步上下文
14.5.2 编写异步函数
异步方法(使用 async
标注),编译器会展开,将任务返回,并使用 TaskCompletionSource
创建一个新的任务。如下两段代码是等价的:
async Task PrintAnswerToLife()
{await Task.Delay (5000);int answer = 21 * 2;Console.WriteLine (answer);
}
Task PrintAnswerToLife() {var tcs = new TaskCompletionSource<object>();var awaiter = Task.Delay(5000).GetAwaiter();awaiter.OnCompleted(() => {try {// 此处等待任务执行完毕,如果有异常,重新抛出awaiter.GetResult();int answer = 21 * 2;Console.WriteLine(answer);tcs.SetResult(null);}catch (Exception ex) {tcs.SetException(ex);}});return tcs.Task;
}
C7.0 核心技术指南 第7版.pdf - p633 - C7.0 核心技术指南 第 7 版-P633-20240308131229
14.5.3 异步 Lambda 表达式
普通方法可以通过 async
关键字、 Task
返回类型成为异步方法:
async Task NamedMethod()
{await Task.Delay (1000);Console.WriteLine ("Foo");
}
匿名方法、Lambda 表达式通过 async
关键字、 Func<Task(T)>
委托也可以异步执行:
Func<Task> unnamed = async () =>
{await Task.Delay (1000);Console.WriteLine ("Foo");
};
Func<Task<int>> unnamed = async () =>
{await Task.Delay (1000);return 123;
};
异步 Lambda 表达式可以附加到事件处理器上,更为简洁:
myButton.Click += async (sender, args) =>
{await Task.Delay (1000);myButton.Content = "Done";
};
14.5.4 WinRT 中的异步方法
14.5.5 异步与同步上下文
14.5.5.1 异常提交
在富客户端程序的异步方法中,未 catch 的异常有两种情况:
返回值为 void
的异步方法
async void
方法背后是由 AsyncVoidMethodBuilder
实现,此类方法抛出的异常将由 AsyncVoidMethodBuilder
捕获,并交由 同步上下文 ,保证触发全局异常处理事件(见集中式异常处理)。
async void
的异常状况较为复杂,以如下代码为例:// 该方法无法捕获 ButtonClick 抛出的异常 private async void button1_Click(object sender, EventArgs e) {try {ButtonClick(sender, e);}catch (Exception ex) {MessageBox.Show(ex.Message);} }private async void ButtonClick(object sender, EventArgs e) {await Task.Delay(1000);throw new Exception("Will this be ignored?"); }// 该方法可以捕获内部的异常 private async void button2_Click(object sender, EventArgs e) {try {await Task.Delay(1000);throw new Exception("Will this be ignored?");}catch (Exception ex) {MessageBox.Show(ex.Message);} }
button1_Click
和ButtonClick
都是异步的,他们被分别包装成了两个状态机(用了两个AsyncVoidMethodBuilder
),ButtonClick
没用 try-catch,因此被AsyncVoidMethodBuilder.SetException
获取,并随后转交给同步上下文,导致button1_Click
的 try-catch 未捕获该异常;
button2_Click
方法内部有 try-catch,异常未被AsyncVoidMethodBuilder.SetException
获取,因此可以在button2_Click
中捕获、显示异常信息。
返回值为 Task
的异步方法
async Task
方法背后是由 AsyncTaskMethodBuilder
实现,与 AsyncVoidMethodBuilder
不同,异常会 封装至 Task
实例中 ,并不交由同步上下文处理。如果我们不去等待,或不在全局异常中进行处理,永远不会得到该异常。
Tips
实际上还是会触发
TaskScheduler.UnobservedTaskException
事件的,见23.4.4.2 延续任务和异常
14.5.5.2 OperationStarted
和 OperationCompleted
如果存在同步上下文,async void
方法会在开始阶段调用 同步上下文( SynchronizationContext
类) 的 OperationStarted
方法,在结束阶段调用 OperationCompleted
方法。我们可以通过 自定义同步上下文 ,监控 async void
的开始和结束:
var myContext = new MySynchronizationContext();
SynchronizationContext.SetSynchronizationContext(myContext);
Func();
Console.ReadLine();async void Func() {Console.WriteLine("开始");await Task.Delay(1000);Console.WriteLine("结束");
}public class MySynchronizationContext : SynchronizationContext {public override void OperationStarted() {Console.WriteLine("触发开始");base.OperationStarted();}public override void OperationCompleted() {Console.WriteLine("触发结束");base.OperationCompleted();}// 根据需要,重写其他方法
}
14.5.6 优化
14.5.6.1 同步完成
await 时,如果 Task 已结束,await 行为和调用同步方法无异。如下两段代码等价:
var content = await GetWebPageAsync("http://oreilly.com");
string content;
var awaiter = GetWebPageAsync("http://oreilly.com").GetAwaiter();
if (awaiter.IsCompleted)content = awaiter.GetResult();
elseawaiter.OnCompleted(() => content = awaiter.GetResult());
C7.0 核心技术指南 第7版.pdf - p640 - C7.0 核心技术指南 第 7 版-P640-20240309103415
不含 await
的 async
方法
编写不含 await 的 async 方法是合法的,不过编译器会产生警告:
async Task<string> Foo() {return "abc";
}
为避免警告,我们可以使用 Task.FromResult
方法:
async Task<string> Foo() {return await Task.FromResult("abc");
}
14.5.6.2 避免大量回弹
切换同步上下文开销较大,我们可以通过 ConfigureAwait
方法,避免 Task 的后续任务切换至同步上下文执行(即通过消息队列切换至 UI 线程执行)。
该优化尤其适用于编写程序库(不需要操作 UI 控件)。
14.6 异步模式
14.6.1 取消操作
异步的取消操作通过 CancellationTokenSource
和 CancellationToken
共同实现。
CancellationTokenSource
包含如下成员:
-
CancellationTokenSource.Cancel
方法 -
CancellationTokenSource.Token
属性 -
CancellationTokenSource.CancelAfter
方法
CancellationToken
包含如下成员
-
CancellationToken.IsCancellationRequested
属性 -
CancellationToken.ThrowIfCancellationRequested
方法 -
CancellationToken.Register
方法
14.6.2 进度报告
一些异步操作需要在运行时报告其执行进度。一种简单方案是向异步方法中传入一个 Action
委托,在进度发生变化时触发方法:
async void Main() {Action<int> progress = i => Console.WriteLine (i + " %");await Foo (progress);
}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...}});
}
这种方式在富客户端场景下存在风险:在工作线程中报告进度,会给消费线程(UI 线程)带来潜在的线程安全问题(在非 UI 线程访问控件抛出异常)。
IProgress<T>
和 Progress<T>
IProgress<T>
接口和 Progress<T>
类用于报告进度,如果有同步上下文,它会 自动捕获,进行切换 ,调用者无需顾虑 UI 线程的线程安全。使用方式如下:
async void Main() {var progress = new Progress<int>( i => Console.WriteLine (i + " %"));await Foo (progress);
}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>
还有一个 ProgressChanged
事件。我们可以订阅该事件监控进度:
async void Main() {var progress = new Progress<int>();progress.ProgressChanged += Progress_ProgressChanged;await Foo(progress);
}
private void Progress_ProgressChanged(object sender, int e) {Console.WriteLine (e + " %");
}
14.6.3 基于任务的异步模式
14.6.4 任务组合器
14.6.4.1 WhenAny
和 14.6.4.2 WhenAll
-
WhenAny
用于等待任意一个 Task 执行结束,并返回第一个执行完成的 Task。 -
WhenAll
用于等待所有 Task 执行结束,即使中间出现了 错误 。该方法返回一个 Task 可以用于等待。
WhenAny
中,若有 Task 发生错误,需要 对每个 Task 进行判断 ,获取错误;
WhenAll
中,若有 Task 发生错误,await WhenAll
会 抛出第一个异常 ,获取全部异常需通过 Task.Exception.InnerExceptions
属性:
async void Main() {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[]>
,await 时可以获取全部结果:
Task<int> task1 = Task.Run (() => 1);
Task<int> task2 = Task.Run (() => 2);
int[] results = await Task.WhenAll (task1, task2); // { 1, 2 }
results.Dump();
14.6.4.3 自定义组合器
自定义超时器
见 Task.Delay 和 Task.WhenAny 实现超时机制
async void Main()
{string result = await SomeAsyncFunc().WithTimeout (TimeSpan.FromSeconds (2));result.Dump();
}async Task<string> SomeAsyncFunc()
{await Task.Delay (10000);return "foo";
}public static class Extensions
{public async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task, TimeSpan timeout){Task winner = await (Task.WhenAny (task, Task.Delay (timeout)));if (winner != task) throw new TimeoutException();return await task; // Unwrap result/re-throw}
}
自定义取消功能
有些异步方法不支持 CancellationToken
,我们可以通过 TaskCompletionSource
+ ContinueWith
+ CancellationToken.Register
支持取消功能:
async void Main()
{var cts = new CancellationTokenSource (3000); // Cancel after 3 secondsstring result = await SomeAsyncFunc().WithCancellation (cts.Token);result.Dump();
}async Task<string> SomeAsyncFunc()
{// 虽然已超时,任务仍会在后台执行完毕。await Task.Delay (10000);return "foo";
}public static class Extensions
{public static Task<TResult> WithCancellation<TResult> (this Task<TResult> task, CancellationToken cancelToken){var tcs = new TaskCompletionSource<TResult>();// 该回调函数将触发“TaskCanceledException”异常,借此取消外部 awaitvar reg = cancelToken.Register (() => tcs.TrySetCanceled ());// 此处使用 ContineuWith,手动设置 TaskCompletionSource 状态,保证外部能正常 await tcs.Tasktask.ContinueWith (ant => {reg.Dispose();if (ant.IsCanceled)tcs.TrySetCanceled();else if (ant.IsFaulted)tcs.TrySetException (ant.Exception.InnerException);elsetcs.TrySetResult (ant.Result);});return tcs.Task;}
}
自定义 WhenAll
+ 任意异常终止等待
WhenAll
不支持任意 Task 发生异常终止执行,我们可以使用 TaskCompletionSource
+ ContinueWith
+ WhenAny
+ WhenAll
实现任意异常终止执行:
async void Main()
{Task<int> task1 = Task.Run(() => { throw null; return 42; });Task<int> task2 = Task.Delay(5000).ContinueWith(ant => 53);int[] results = await WhenAllOrError(task1, task2);
}async Task<TResult[]> WhenAllOrError<TResult>(params Task<TResult>[] tasks)
{var killJoy = new TaskCompletionSource<TResult[]>();// 通过 ContinueWith 和前序任务的 Task,手动控制 TaskCompletionSource 状态foreach (var task in tasks)_ = task.ContinueWith(ant =>{if (ant.IsCanceled)killJoy.TrySetCanceled();else if (ant.IsFaulted)killJoy.TrySetException(ant.Exception.InnerException!);});// 此处用 WhenAny + WhenAll 等待,如果 tasks 中出现了异常,WhenAll 将继续执行,TaskCompletionSource.Task 状态发生改变,WhenAny 将捕获到发生的异常。return await await Task.WhenAny(killJoy.Task, Task.WhenAll(tasks));
}