第14章 并发与异步

第14章 并发与异步

14.2 线程

程提供了程序执行的独立环境, 程持有 线 程,且至少持有一个 线 程。这些 线 程共享 程提供的执行环境。

14.2.1 创建线程

创建线程的步骤为:

  1. 实例化 Thread ​ 对象,通过构造函数传入 ThreadStart ​ 委托。
  2. 调用 Thread.Start() ​ 方法。
static void Main(){
	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");
}

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 向线程传递数据

向线程传递数据有两种方式:

  1. 使用 Lambda 表达式
static void Main()
{
	Thread t = new Thread (() => Print ("Hello from t!") );
	t.Start();
}

static void Print (string message) { Console.WriteLine (message); }
  1. 使用 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 task
		UpdateMessage ("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 线程池

线程池可以降低线程创建时的开销(几百毫秒)。使用线程池需要注意以下几点:

  1. 线程池的线程,其 Name ​ 属性无法设置,但调试时可以使用 Visual Studio 的 Threads 窗口附加描述信息。
  2. 线程池中的线程都是 台线程。
  3. 阻塞 线程池中的线程将影响性能。

我们可以任意设置线程池中线程的优先级,当我们将线程归还线程池时其优先级会 恢复为普通级别

Thread.CurrentThread.IsThreadPoolThread ​ 属性可用于确认当前运行的线程是否是一个线程池线程。

14.2.13.1 进入线程池

在线程池上运行代码有两种方式:

  1. 使用 Task.Run

  2. 使用 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 的策略将得到最好的效果:

  1. 大多数工作项目的运行时间非常短暂(小于 250 毫秒或者理想情况下小于 100 毫秒)。这样 CLR 就会有大量的机会进行测量和调整。
  2. 线程池中不会出现大量以阻塞为主的作业。

在.NET 环境中,线程池用于管理线程的创建和执行,以提高性能和减少资源消耗。当线程被阻塞时,它们无法执行其他工作。但从外部看,可能看起来像是它们正在忙碌。因此,公共语言运行库(CLR)可能会错误地认为需要更多的线程来处理工作负载,从而创建更多的线程。这种过度补偿可能导致资源使用不当,最终影响到应用程序的性能和响应能力。

因此,建议尽可能使用非阻塞或异步编程模式,特别是在涉及到 IO 操作时,使用.NET 的异步 API 可以有效避免这种阻塞,从而提高应用程序的效率和响应性。

14.3 任务(Task​)

14.3.1 启动任务

常用的启动任务的方式有两种:

  1. Task.Run

    适用于 .NET Framework 4.5(含)之后的版本。

  2. 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!");
	else
		throw;
}

通过 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​ 结束后执行后续操作:

  1. 回调方法 Awaiter.OnCompleted
  2. ContinueWith​ 方法
回调方法

Task​​ 添加回调函数的方式如下:

  1. 使用 Task.GetAwaiter ​ 方法获取 Awaiter ​ 实例;
  2. 通过 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​ 需要注意:

  1. 如果提供了同步上下文(例如有 UI 线程的 UWP、WPF、Windows Form),OnCompleted​ 会 自动捕获,并将延续提交到该上下文中

  2. 如果不希望出现上述行为,需要使用 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​ 对象。使用时需要注意:

  1. 先导任务可能 发生异常 ,要注意处理;
  2. 如果需要将延续封送至 UI 线程,需要编写额外代码(见 ​23.4.5 任务调度器);
  3. 如果希望和先导任务执行在同一线程上,需要指定 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​ 将抛出 包装后的异常(AggregateExceptionAwaiter.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​ 可以等待任何:

  1. 实现了 GetAwaiter() ​ 方法、

  2. 返回值是可等待对象Awaiter(通常是 TaskAwaiter ​ )的类型(详见可等待)。

    Task​ 实现了该方法,因此可等待

Tips

可等待对象(awaitable object)指

  1. 实现了 INotifyCompletion​ 接口、
  2. 具有返回类型恰当的 GetResult()​ 方法、
  3. 具有 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 产生的上下文同步,就像是坐在出租车上游览:

  1. 在同步上下文中,代码总是使用同一辆出租车(线程);
  2. 在没有同步上下文的情况下,每一次都会使用不同的出租车。

但无论是哪一种情况,旅程都是相同的。

详见同步上下文

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();
else
    awaiter.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​ 包含如下成员:

  1. CancellationTokenSource.Cancel​ 方法
  2. CancellationTokenSource.Token​ 属性
  3. CancellationTokenSource.CancelAfter​ 方法

CancellationToken​ 包含如下成员

  1. CancellationToken.IsCancellationRequested​ 属性
  2. CancellationToken.ThrowIfCancellationRequested​ 方法
  3. 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 seconds
	string 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”异常,借此取消外部 await
		var reg = cancelToken.Register (() => tcs.TrySetCanceled ());
        // 此处使用 ContineuWith,手动设置 TaskCompletionSource 状态,保证外部能正常 await tcs.Task
		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;
	}
}
自定义 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));
}

posted @   hihaojie  阅读(8)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· 2 本地部署DeepSeek模型构建本地知识库+联网搜索详细步骤
点击右上角即可分享
微信分享提示