.Net 多线程与异步编程
文章目录
本篇主要记录总结多线程
.Net中的多线程概念和异步处理
一、前世今生
单个应用程序可能需要执行多个类似的任务
例如: 一个Web服务器可能同时接收很多并发的网页, 图像,声音等客户端请求, 如果是传统的单个进程执行一次只能处理一个请求, 终端等待时间过长
以前是让服务器以单个进程运行接收请求, 当服务器接收到请求创建另一个进程处理, 当时进程的创建很耗费时间和资源, 如果每个请求的新进程都执行同样的任务不需要消耗这么多进程创建的开销
多线程流行之后, 一个进程包含多个线程的操作更加有效, 如果Web服务器是多线程的, 服务器可以创建一个线程用以监听请求, 当请求进来之后创建新的线程处理, 并恢复监听
二、线程
1. 概念
线程是CPU的一个基本单元, 包含线程ID, 程序计数器, 寄存器组和堆栈
2. 多线程的优点
- 响应式: 如果一个交互系统采用多线程, 那么即使部分阻塞或者执行冗长逻辑, 依然可以继续执行, 提高终端的响应速度
- 资源共享: 进程只能通过共享内存和消息传递之类的方式共享资源, 线程默认共享所属进程的内存和资源
- 经济: 进程创建所耗费的内存和资源非常昂贵, 由于线程共享同一进程的内存和资源, 更加经济
- 可伸缩性: 对于多处理器体系结构, 多线程可在多处理核上并行运行, 而对于单线程进程无论可用CPU资源有多少只能运行在单核上
三、异步
1. 阻塞操作和事件循环
线程可以进入休眠, 等待操作系统唤醒继续执行, 切换线程有一定的性能成本
很多时候程序需要调用一些阻塞操作, 比如IO, 网络连接等, 需要让线程先等待, 操作完成之后重新把线程放入等待运行的队列等待唤醒执行
但是这样一旦阻塞操作很多, 意味着需要开启很多个线程, 开启线程的消耗会造成性能问题
为了解决这种问题, 最初是使用事件循环机制, 例如跨平台的select接口. 程序需要使用一个或多个线程用于获取事件, 然后替换 执行阻塞操作 为 非阻塞操作, 注册事件用于在处理完成后接收通知.
这样发出阻塞操作的线程可以减少为1个, 这一个线程可以同时创建多个阻塞操作. 然后在循环中不断处理事件, 针对不同事件, 及类似事件回调的数据, 执行对应的回调完成操作
基于事件循环编写程序有一定难度, 且代码结构类似, 因此在事件循环基础上封装了一套框架, 称为异步操作, 异步操作提供了基于回调的机制, 会先执行非阻塞操作, 注册事件并关联回调, 接收到事件后自动调用之前关联的回调. 著名的异步操作框架: C语言libevent,C++的ASIO,JAVA的Netty。部分操作系统也提供了原生接口,Windows的IOCP,Linux的AIO。
2. .Net中的异步编程模式(Asynchronous Programming Patterns)
1. .Net异步编程模式提供了3中编程模型
-
基于任务的异步模式 (TAP) ,该模式使用单一方法表示异步操作的开始和完成。 TAP 是在 .NET Framework 4 中引入的。 这是在 .NET 中进行异步编程的推荐方法。 C# 中的 async 和 await 关键词以及 Visual Basic 中的 Async 和 Await 运算符为 TAP 添加了语言支持。
-
基于事件的异步模式 (EAP),是提供异步行为的基于事件的旧模型。 这种模式需要后缀为 Async 的方法,以及一个或多个事件、事件处理程序委托类型和 EventArg 派生类型。 EAP 是在 .NET Framework 2.0 中引入的。 建议新开发中不再使用这种模式。
-
异步编程模型 (APM) 模式(也称为 IAsyncResult 模式),这是使用 IAsyncResult 接口提供异步行为的旧模型。 在这种模式下,同步操作需要 Begin 和 End 方法(例如,BeginWrite 和 EndWrite以实现异步写入操作)。 不建议新的开发使用此模式。
2. 基于任务的异步模式(TAP)
本文只探讨目前微软推荐的异步模式TAP, TAP基于System.Threading.Task 命名空间的Task和Task来表示异步操作, 单个方法本身可以表示异步操作的开始和结束
Task类提供了异步操作的生命周期, 该周期由TaskStatus枚举表示, Task也包含Exception内容
TAP有以下三种方式实现:
//使用async关键字的方法会被归类为异步方法
public async Task<TResult> GetAsync(int id)
{
...
return TResult;
}
//可以手动实现
public static Task<int> ReadTask(this Stream stream, byte[] buffer, int offset, int count, object state)
{
var tcs = new TaskCompletionSource<int>();
stream.BeginRead(buffer, offset, count, ar =>
{
try
{
tcs.SetResult(stream.EndRead(ar));
}
catch (Exception exc)
{
tcs.SetException(exc);
}
}, state);
return tcs.Task;
}
//混合方法
public Task<int> MethodAsync(string input)
{
if (input == null) throw new ArgumentNullException("input");
return MethodAsyncInternal(input);
}
private async Task<int> MethodAsyncInternal(string input)
{
// code that uses await goes here
return value;
}
3. TAP的任务调度器
1. CLR线程池引擎
CLR线程池引擎维护了一定数量的空闲工作线程以支持工作项的执行, 并且能够重用已用线程以避免创建新的不必要线程的花费.
使用爬山算法(hill-climbing algorithm), 依据工作项所需资源的可用情况, 例如:CPU, 网络带宽, 来检查吞吐量, 判断是否需要更多的线程来完成更多的工作项
2. 目前版本的TAP 任务调度器(TaskScheduler)
TaskScheduler是基于CLR线程池引擎实现的, 当任务调度器开始分派任务时:
- 在主线程或其他并没有分配给特定任务的线程上下文中创建并启动的任务, 称为顶层任务, 会在全局队列中竞争工作线程(线程池的空闲线程会以FIFO顺序从线程池的全局队列,取任务, 全局队列共享资源, 所以有锁的机制, 当一个任务内部创建很多子任务, 会频繁进出全局队列, 降低性能, 线程池引擎为每个线程引入了局部队列)
- 在其他任务的上下文创建的子任务, 将被分配在线程的局部队列中(通过任务内联 task-inlining 和工作窃取机制可以提升性能, 局部队列“通常”以LIFO的顺序抽取任务并执行,而不是像全局队列那样使用FIFO顺序。LIFO顺序通常用有利于数据局部性,能够在牺牲一些公平性的情况下提升性能。当一个工作线程的局部队列中有很多工作项正在等待时,而存在一些线程却保持空闲,这样会导致CPU资源的浪费。此时任务调度器(TaskScheduler)会让空闲的工作线程进入忙碌线程的局部队列中窃取一个等待的任务,并且执行这个任务。)
4. Async和Await
1. async关键字
当使用async标记一个方法时, 即告诉了编译器两件事:
- 想在方法内部使用await关键字, 编译器会将方法转化为包含状态机的方法, 编译后的方法可以在await 处挂起并在await标记的任务完成后异步唤醒
- 方法的结果和任何可能发生的异常都将作为返回类型返回, 如果方法返回Task或者Task, 意味着任何结果或任何在方法内部未处理的异常都将存储在返回的Task中, 如果返回void, 意味着任何异常都将传播到调用者的上下文(可能直接中断程序, 不推荐)
2. await关键字
await关键字告诉编译器在async标记的方法中插入一个挂起点(唤醒点)
逻辑上当 await someobject, 编译器将生成代码来检查someobject代表的操作是否完成, 如果已完成, 则从await标记的唤醒点(挂起点)继续同步执行, 如果没有, 将为等待的someobject生成一个continue委托, 当someobject代表的操作完成后回调continue委托, 这个continue委托将控制权移交到async方法对应的await唤醒点
返回到await唤醒点之后, 任何结果都可以从返回的task中提取, 如果异常则异常随着task一起返回给SynchronizationContext(同步上下文)
在await someobject之后, 编译器会生成一个包含MoveNext方法的状态机类, 在实例 someobject上使用这些成员来检查该对象是否已完成(通过 IsCompleted),如果未完成,则挂接一个续体(通过 OnCompleted),当所等待实例最终完成时,系统将再次调用 MoveNext 方法,完成后,来自该操作的任何异常将得到传播或作为结果返回(通过 GetResult),并跳转至上次执行中断的位置。:
private class FooAsyncStateMachine : IAsyncStateMachine
{
// Member fields for preserving “locals” and other necessary state
int $state;
TaskAwaiter $awaiter;
…
public void MoveNext()
{
// Jump table to get back to the right statement upon resumption
switch (this.$state)
{
…
case 2: goto Label2;
…
}
…
// Expansion of “await someObject;”
this.$awaiter = someObject.GetAwaiter();
if (!this.$awaiter.IsCompleted)
{
this.$state = 2;
this.$awaiter.OnCompleted(MoveNext);
return;
Label2:
}
this.$awaiter.GetResult();
…
}
}
四. 异步本地变量和执行上下文
线程本地变量不适用于异步操作代码, 因为异步操作中执行异步的线程和回调的线程不一定相同
.Net实现了异步本地变量, 通过执行上下文(区分于线程上下文)实现, 每个托管线程对象都会保存一个执行上下文对象(用于保存异步本地变量)
任务并行库创建Task时会记录当前托管线程的上下文, 并在执行回调之前恢复
- 当前托管线程的执行上下文保存在Thread.m_ExecutionContext成员中
- 默认执行上下文保存在全局变量ExecutionContext.Default,里面没有任何异步本地变量
- 创建Task时,调用ExecutionContext.Capture()方法获取当前托管线程的执行上下文:1.如果当前没有禁止捕捉,则返回当前线程的执行上下文(如果当前执行上下文为null则返回默认执行上下文)2.禁止捕捉则返回null
- 如果返回的执行上下文不为默认执行上下文,且不为null,则将当前执行上下文记录到Task.m_contingentProperties.m_capturedContext成员中
- 异步操作完成后调用回调时,检查Task中记录的执行上下文是否为null:1.如果为null则直接执行回调;2.如果不为null,则调用ExecutionContext.RunInternal方法并传入执行上下文与回调。具体:备份当前的执行上下文(因为可能已经换了一个托管线程了),设置当前执行上下文为传入的执行上下文,执行委托(回调),执行完毕后恢复当前上下文为备份的上下文。
注意,执行上下文是一个不变对象,每次修改异步本地变量都会创建一个新的执行上下文覆盖当前的执行上下文,且修改不会反应到调用来源。调用异步之前,可以调用ExecutionContext.SuppressFlow()禁止捕捉当前执行上下文,即回调时无法恢复异步执行变量值。禁止捕捉,可以通过SuppressFlow()返回值AsyncFlowControl类型的Undo()恢复捕捉。
总结
.Net异步编程整理