目录结构:
在这篇文章中,笔者将会讨论如何执行异步的IO操作。上面一篇文章,笔者介绍了如何执行异步计算操作。在读完本文后,将异步应用到IO操作中,能够提供读取的效率。
1.为什么需要异步IO操作
关于异步操作,想必读者已知道异步IO操作,笔者在这里展示FileStream类读取本地文件的过程。首先展示FileStream类同步IO操作的流程图。
上面的执行流程中,在第4步Windows将IRP数据包传送给恰当的设备驱动的IRP队列(每个设备驱动程序都维护着自己的IRP队列,其中包含了机器上运行的所有进程发出的I/O请求)。在IRP数据包到达时,设备驱动程序将IRP信息传给物理硬件设备上的安装电路板,然后由硬件驱动设备执行请求的I/O操作,也就是第5个步骤。
当硬件驱动设备执行I/O操作期间,发出了I/O请求的线程将无事可做,所以Windows将线程变成睡眠线程,防止它浪费CPU的时间(步骤6)。这当然好,虽然线程不浪费时间,但其仍然浪费空间(内存),这当然就不好了。
当硬件设备执行完I/0操作。然后Windows会唤醒你的线程,把它调度给一个CPU,使其从内核模式返回至用户模式,然后返回至托管代码(步骤7、8、9)。
上面的步骤看起来很不错,但是依旧存在两个问题:1.请求的数量越来越多,创建的线程就越来越多,那么被阻塞的线程就会越来越多,这样会更浪费内存。2.用执行结果来响应请求,如果请求的数量非常多,那么解锁的阻塞线程也就很多,而且机器上的线程数都会远远大于CPU数,所以在线程阻塞期间很有可能会频繁地发生上下文切换,损害性能。
下面展示Windows如何异步执行I/O操作,仍然使用FileStream来构建对象,但是需要传递FileOptions.Asynchronous标志,告诉Windows希望文件的读/写以异步的方式进行。
在使用FileOptions.Asynchronous创建FileStream对象后,就应该使用ReadAsync(...)来读取文件,而不是Read(...)。在ReadAsync内部分配一个Task<Int32>对象来代表用于完成的读取操作的代码。然后ReadAsync调用Win32ReadFile函数(步骤1),ReadFile分配IRP数据包(步骤2),然后将其传递给Windows内核(步骤3)。Windows内核把IRP数据包添加到IRP队列中(步骤4)。此时线程不会再阻塞,而是可以直接运行返回至你的代码。所以线程能够立即从ReadAsync调用中返回(步骤5、6、7)。
在调用ReadAsync后返回一个Task<Int32>对象,可以在该对象上调用ContinueWith来登记任务完成时执行的回调方法,然后在回调方法中处理数据。当硬件设备处理好IRP后(步骤a)。硬件设备会把IRP放到CLR的线程池中队列中(步骤b)。将来某个时候,一个线程池会提取完成的IRP并执行任务的代码,最终要么设置异常(如果发生异常),要么返回结果(步骤c)。在知道这些之后,就知道使用异步I/O可以尽量的减少同步I/O访问存在的这些问题。
2.C#的异步函数
之前的一篇文章中,我们讨论了《计算限制的异步操作》,其中绝大部分代码都是使用Task来完成的。C#还为我们提供了另一种异步糖语法—异步函数,使用异步函数时可以以顺序的写异步代码,感觉像是在进行同步操作。Task和异步函数的功能类似,那么在需要进行异步操作的时候,如何选择如果涉及到计算操作,与I/0无关的话,就选择Task,否则建议使用异步函数。
2.1 async和await的使用
async和await是C#异步函数编程的核心,被async标记的方法表明该方法应该以异步的方式运行;await操作符用于标记异步函数执行完成后状态机恢复的位置(注意,这里不是等待),同时指示包含该await操作符的方法以异步的方式运行。如果async中不包含await,那么async会以同步的方式运行。
下面展示异步访问网络的步骤:
static void Main(string[] args) { Task<int> task= AccessTheWebAsync(); task.ContinueWith((t) => { Console.WriteLine(t.Result); }); Console.ReadLine(); } static async Task<int> AccessTheWebAsync() { //需要引入System.Net.Http程序集 HttpClient httpClient = new HttpClient(); Task<string> getStringTask = httpClient.GetStringAsync("https://www.baidu.com/"); // await操作符挂起AccessTheWebAsync方法 // AccessTheWebAsync不能够继续执行,直到getStringTask任务完成。 // AccessTheWebAsync可以从这里直接异步返回给AccessTheWebAsync的调用者。 // 当getStringTask任务完成后,状态机可以直接从这里恢复,并且await操作符会返回任务的Result值。 String urlContents = await getStringTask; //返回长度 return urlContents.Length; }
使用async和await有以下几点需要注意:
1.方法名应该以Async结尾(比如:AccessTheWebAsync)。
2.方法应该包含有async修饰符。
3.方法的返回类型应该是Task<TResult>或Task或void或其他类型(从C#7.0,.NET Core开始,其他类型的返回值应该提供GetAwaiter方法)。
4.方法中至少应该包含一个await表达式。
3.异步函数的状态机
3.1 异步函数如何转化为状态机
通常情况下,观察编译器给我们编译好的代码,可以帮助我们更好的理解我们的代码。像async和await操作符,编译器其实是把这些操作符转化成了一种状态机的机制。将含有async和await的代码编译为IL代码,再将IL代码反编译为C#代码,就可以得到状态机。
比如:
class Type { } class Program { private static async Task<Type> Method1() { /*执行一些异步操作,最后返回一个Type类型的数据*/ HttpClient httpClient = new HttpClient(); String result= await httpClient.GetStringAsync("http://www.baidu.com"); return new Type(); } private static async Task<String> MyMethodAsync() { Type result1 = await Method1(); return result1.ToString(); } static void Main(string[] args) { } }
编译为IL代码后,再利用ILSPY把IL代码反编译为C#代码,在返编译IL代码的时候,需要注意,不能勾选“decompile async methods(async/await)”
然后就可以看到async和await转化成的状态机
通过查看反编译后的C#代码,C#中的异步函数的运行过程,可以用下图进行简单的概括:
但任务未完成时,isCompleted返回false,所以会在onCompleted登记任务完成时会调用的action动作,action动作执行完成后,会再一次调用MoveNext,然后isCompleted就返回true,此时就可以通过GetResult获得结果。
3.2 如何扩展异步函数
在扩展性方面,能用Task对象包装一个即将完成的操作,就可以使用await操作符来等待该操作。
下面是一个TaskLogger类,可用它显示未完成的异步操作。
static class TaskLogger { public enum TaskLogLevel { None,Pending} public static TaskLogLevel LogLevel { get; set; } public sealed class TaskLogEntry { public Task Task { get; internal set; } public String Tag { get; internal set; } public DateTime LogTime { get; internal set; } public String CallerMemberName { get; internal set; } public String CallerFilePath { get; internal set; } public Int32 CallerLineNumber { get; internal set; } public override string ToString() { return String.Format("LogTime={0},Tag={1},Member={2},File={3}({4})", LogTime,Tag??"(none)",CallerMemberName,CallerFilePath,CallerLineNumber); } } private static readonly ConcurrentDictionary<Task, TaskLogEntry> s_log = new ConcurrentDictionary<Task, TaskLogEntry>(); public static IEnumerable<TaskLogEntry> GetLogEntries() { return s_log.Values;} public static Task<TResult> Log<TResult>(this Task<TResult> task, String tag = null, [CallerMemberName] String callerMemberName=null, [CallerFilePath] String callerFilePath=null, [CallerLineNumber] Int32 callerLineNumber=-1) { return (Task<TResult>)Log(task, tag, callerMemberName, callerFilePath, callerLineNumber); } public static Task Log(this Task task, String tag = null, [CallerMemberName] String callerMemberName = null, [CallerFilePath] String callerFilePath = null, [CallerLineNumber] Int32 callerLineNumber = -1) { if (LogLevel == TaskLogLevel.None) { return task; } var logEntry = new TaskLogEntry { Task=task, LogTime=DateTime.Now, Tag=tag, CallerMemberName=callerMemberName, CallerFilePath=callerFilePath, CallerLineNumber=callerLineNumber }; s_log[task] = logEntry; //附加一个异步任务,当一个任务执行完成后,应该将其从清单中移除 task.ContinueWith(t => { TaskLogEntry entry; s_log.TryRemove(t,out entry); },TaskContinuationOptions.ExecuteSynchronously); return task; } }
Callation类,用于取消正在执行的异步操作
static class Cancellation { public struct Void { } public static async Task WithCancellation(this Task originalTask, CancellationToken ct) { //创建在Cancellation被取消时完成的一个Task var cancelTask = new TaskCompletionSource<Void>(); using (ct.Register(t => ((TaskCompletionSource<Void>)t).TrySetResult(new Void()), cancelTask)) { //创建在原始Task或CancellationToken Task完成时都完成的一个Task Task any = await Task.WhenAny(originalTask,cancelTask.Task); //任务Task因为CancellationToken而完成,就抛出OperationCanceledException if (any == cancelTask.Task) ct.ThrowIfCancellationRequested(); }; //等待原始任务;若任务失败,它将抛出一个异常 await originalTask; } }
最后,展示如何使用
class Program { static void Main(string[] args) { Go(); Console.ReadLine(); } public static async Task Go() { #if DEBUG //使用TaskLogger会影响内存和性能,所以只在调试生成中启用它 TaskLogger.LogLevel=TaskLogger.TaskLogLevel.Pending; #endif //初始化3个任务;为了测试TaskLogger,我们显示控制持续时间 var tasks = new List<Task>{ Task.Delay(2000).Log("2s op"), Task.Delay(5000).Log("5s op"), Task<String>.Delay(8000).Log("8s op"), }; try { //等待全部任务,但在3秒后取消;只有一个任务能够按时完成 await Task.WhenAll(tasks).WithCancellation(new CancellationTokenSource(3000).Token); } catch (OperationCanceledException) { //查询logger哪些任务尚未完成,按照从等待时间从最长到最短的顺序排序 foreach (var op in TaskLogger.GetLogEntries().OrderBy(tle => tle.LogTime)) { Console.WriteLine(op); } } } }
我的得到如下的输出结果:
LogTime=2018/11/7 1:30:41,Tag=8s op,Member=Go,File=e:\MyLearn\ConsoleApplication1\Program.cs(28)
LogTime=2018/11/7 1:30:41,Tag=5s op,Member=Go,File=e:\MyLearn\ConsoleApplication1\Program.cs(27)
除了增强使用Task的灵活性,异步函数对另一个扩展性有力的地方在于编译器可以在await的任何操作数上调用GetAwaiter。所以操作数不一定是Task对象。可以是任何任意类型,只要提供一个调用GetAwaiter的方法就可以了。
例如:
public sealed class EventAwaiter<TEventArgs> : INotifyCompletion { private ConcurrentQueue<TEventArgs> m_events = new ConcurrentQueue<TEventArgs>(); private Action m_continuation; //状态机调用GetAwaiter获得Awaiter,这里返回自己 public EventAwaiter<TEventArgs> GetAwaiter() { return this; } //告诉状态机是否发生了任何事件 public Boolean IsCompleted { get { return m_events.Count > 0; } } //状态机告诉我们以后要调用什么方法,continuation中包含有恢复状态机的操作 public void OnCompleted(Action continuation) { Volatile.Write(ref m_continuation,continuation); } //状态机查询结果,这是awaiter操作符的结果 public TEventArgs GetResult() { TEventArgs e; m_events.TryDequeue(out e); return e; } public void EventRaised(Object sender, TEventArgs eventArgs) { m_events.Enqueue(eventArgs); //如果有一个等待运行的延续任务,该线程会运行它 Action continuation = Interlocked.Exchange(ref m_continuation, null); if (continuation != null) { continuation();//恢复状态机 } } }
在EventAwaiter类在事件发生的时候从await操作符返回。在本例中,一旦AppDomain中的任何线程抛出异常,状态机就会继续。
private static async void ShowException() { var eventAwaiter = new EventAwaiter<FirstChanceExceptionEventArgs>(); AppDomain.CurrentDomain.FirstChanceException += eventAwaiter.EventRaised; while (true) { Console.WriteLine((await eventAwaiter).Exception.GetType()); } }
最后的代码演示了这一切是如何工作的
static void Main(string[] args) { ShowException(); for (int i = 0; i < 3; i++) { try { switch (i) { case 0: throw new InvalidCastException(); case 1: throw new InvalidOperationException(); case 2: throw new ArgumentException(); } } catch (Exception) { } } Console.ReadLine(); }
4.FCL中的异步IO操作
FCL中的异步函数非常容易辨认,因为命名规范要求异步函数必须加上Async的后缀。在FCL中,支持I/O操作的许多类型都提供了XxxAsync方法
例如:
a.System.IO.Stream的所有派生类都提供了ReadAsync,WriteAsync,FlushAsync和CopyToAsync方法
b.System.IO.TextReader的所有派生类都提供了ReadAsync,ReadLineAsync,ReadToEndAsync和ReadBlockAsync方法。System.IO.TextWriter的派生类提供了WriteAsync,WriteLineAsync和FlushAsync.
c.System.Net.Http.HttpClient 类提供了GetAsync,GetStreamAsync,GetByteArrayAsync,PostAsync,PutAsync,DeleteAsync和其他许多方法。
d.System.Net.WebRequest的所派生类(包括FileWebRequest,FtpWebRequest和HttpWebRequest)都提供了GetRequestStreamAsync和GetResponseAsync方法。
e.System.Data.SqlClient.SqlCommand类提供了ExecuteDbDataReaderAsync,ExecuteNonQueryAsync,ExecuteReaderAsync,ExecuteScalarAsync和ExecuteXmlReaderAsync方法。
f.生成Web服务代理工具(比如SvcUtil.exe)也生成了XxxAsync方法。
这里笔者以System.Net.Http.HttpClient来举例:
static async void Go() { HttpClient httpClient = new HttpClient(); Stream stm = await httpClient.GetStreamAsync("http://www.baidu.com"); StreamReader sr = new StreamReader(stm); String line= ""; while ((line = await sr.ReadLineAsync()) != null) { Console.WriteLine(line); } }
FCL中有许多编程都使用了BeginXxx/EndXxx方法模型和IAsyncResult接口,还有基于事件的编程模型,它也提供了XxxAsync方法(不返回Task对象),能在异步操作完成时调用事件处理程序。这两种编程模型都已经过时,使用Task的新模型才是你的首要选择。
在FCL中,有一些类缺少XxxAsync方法,只提供了BeginXxx和EndXxx方法。可以通过TaskFactory将其转化为基于Task的模型。
BeginExecuteXXX 和EndExecuteXXX 使用TaskFactory来转化的步骤,例如:
返回值= Task.Factory.FromAsync(BeginEexcuteXXX,EndExecuteXXX,...);
返回值是EndExecute的返回值。
例如:NamedPipeServerStream类定义了BeginWaitForConnection和EndWaitForConnection,但是没有定义WaitForConnectionAsync方法,可以按照如下代码来完成转化。
static async void StartServer() { while (true) { //循环不停的接受来自客户端的链接 var pipe = new NamedPipeServerStream(c_pipeName,PipeDirection.InOut,-1,PipeTransmissionMode.Message,PipeOptions.Asynchronous|PipeOptions.WriteThrough); //异步的接受来自客户端的连接 //用TaskFactory的FromAsync将旧的异步编程模型转化为新的Task模型 //当没有客户端连接时,线程将会挂起,并且允许方法已异步的方式返回调用者(本例中未有返回) //当有客户端连接后,立即唤醒状态机,线程继续执行。 await Task.Factory.FromAsync(pipe.BeginWaitForConnection,pipe.EndWaitForConnection,null); //为客户端提供服务 //startServiceConnectionAsync 也是异步方法,所以能够立即返回 startServiceConnectionAsync(pipe); } }
FCL没有提供任何的辅助方法将旧的、基于事件的编程模型转化为新的、基于Task的编程模型。所有只能使用硬编码的方式。例如下面演示了使用TaskCompletionSource包装使用了“基于事件的编程模型”的WebClient,以便在异步函数中等待它。
static async Task<String> AwaitWebClient(Uri uri) { //System.Net.WebClient var wc = new System.Net.WebClient(); //创建TaskCompletionSource及其基础Task对象 var tcs = new TaskCompletionSource<String>(); //字符串下载完成后,WebClient对象引发DownloadStringCompleted事件 wc.DownloadStringCompleted += (s, e) => { if (e.Cancelled) tcs.SetCanceled(); else if (e.Error != null) tcs.SetException(e.Error); else tcs.SetResult(e.Result); }; //启动异步操作 wc.DownloadStringAsync(uri); //现在可以等待TaskCompletion String result = await tcs.Task; return result; }
4.1 FileStream类
创建FileStream对象时,可通过FileOptions.AsyncChronous标志指定以同步方式还是异步方式进行通信。如果不指定该标志,Windows将以同步方式执行所有文件操作。当然,仍然可以调用FileStream的ReadAsync方法,对于你的应用程序,表面上是异步执行,但FileStream类在内部用另一个线程模拟异步行为。这个额外的线程纯属是浪费。
如果创建FileStream对象时指定FileOptions.AsyncChronous标志。然后,可以调用FileStream的Read方法执行一个同步操作。在内部,FileStream类会开始一个异步操作,然后立即调用线程进入睡眠状态,直到操作完成才唤醒,从而模拟同步行为,这样依然效率低下。
总之,使用FileStream时应该想好是以同步方式还是以异步方式执行I/O操作,并指定FileOptions.Asynchronous标志来指明自己的选择。如果指定了该标志,就总是调用ReadAsync。如果没有使用这个标志,就总是调用Read。这样能够获得最佳性能。如果想先对FileStream执行一些同步操作,再执行一些异步操作,那么更高效的做法是使用FileOptions.Asynchronous标志来构造它。另外也可针对同一个文件,创建两个FileStream对象,一个FileStream进行同步操作,另一个FileStream执行异步操作。
FileStream的辅助方法(Create,Open和OpenWrite)创建并返回FileStream对象,这些方法都没有指定FileOptions.Asynchronous标志,所以为了实现响应灵敏的、可伸缩性的应用程序,应避免使用这些方法。
5.异步实现服务器
FCL内建了对伸缩性很好的一些异步服务器的支持。下面列举中MSDN文档中值的参考的地方。
1.要构建异步ASP.NET Web窗体,在.aspx文件中添加Async="true"的网页指令,并参考System.Web.UI.Page的RegisterAsyncTask方法。
2.要构建异步ASP.NET MVC控制器,使你的控制器类从System.Web.Mvc.AsyncController派生,让操作方法返回一个Task<ActionResult>即可。
3.要构建异步ASP.NET 处理程序,使你的类从System.Web.HttpTaskAsyncHandler派生,重写其ProcessRequestAsync方法。
4.要构建异步WCF服务,将服务作为异步函数来实现,让它返回Task或Task<TResult>。
6.如何取消异步IO操作
Windows一般没有提供取消未完成I/O操作的途径,这是许多开发人员都想要的功能,实现起来却很困难。毕竟,如果向服务器请求了1000个字节,然后决定不再需要这些字节,那么其实没有办法高数服务器忘掉你的请求。在这种情况下,只能让字节照常返回,再将他们丢弃。此外,这里还发生竞态条件-取消请求的请求可能正在服务器发送响应的时候到来,要在代码中处理这种潜在的竞态条件,决定是丢弃还是使用数据。
建议实现一个WithCancellation扩展方法Task<TResult>(需要重载版本来扩展Task)上面的案例中,我们已经使用过Task的扩展版本了,下面是Task<TResult>版本:
static class CancelleationClass { private struct Void { }//没有泛型的TaskCompletionSource类 public static async Task<TResult> WithCancellation<TResult>(this Task<TResult> originalTask, CancellationToken ct) { //创建在CancellationToken被取消时完成的一个Task var cancelTask = new TaskCompletionSource<Void>(); //一旦CancellationToken被取消,就完成Task CancellationTokenRegistration cancellationTokenRegistration = ct.Register(t => { ((TaskCompletionSource<Void>)t).TrySetResult(new Void()); }, cancelTask); //创建在原始task或cancel task完成时都完成的Task Task any = await Task.WhenAny(originalTask, cancelTask.Task); //只要是cancel task先完成,就抛出OperationCanceledException if (any == cancelTask.Task) { ct.ThrowIfCancellationRequested(); } //释放资源 cancellationTokenRegistration.Dispose(); //返回原始任务 return originalTask.Result; } }
按照如下的代码来使用它:
public static async Task<Int32> go() { var cts = new CancellationTokenSource(); var ct = cts.Token; try { Int32 max = 10; Task<Int32> task = new Task<Int32>(() => { Int32 result = 0; for (int i = 0; i < max; i++) { result += i; Thread.Sleep(1000); } return result; }); task.Start(); //在指定的时间后取消操作 Task.Delay(500).ContinueWith((obj) => { cts.Cancel(); }); Int32 res=await task.WithCancellation<Int32>(ct); return res; } catch (OperationCanceledException e) { Console.WriteLine(e.Message); } return -1; }