异步陷阱之IO篇

出门莫恨无人随,书中车马多如簇。这篇文章主要讲述异步陷阱之IO篇相关的知识,希望能为你提供帮助。
很多教程和资料都强调流畅的用户体验需要异步来辅助,核心思想就是保证用户前端的交互永远有最高的优先级,让一切费时的逻辑通通放到后台,等到诸事完备,通知一下前端给个提示或者继续下一步。随着.NET发展,async和await关键字的推广,Task Parallel Library (TPL)的稳步发展, 异步编程也越来越多的被重视和采用,很多时候非常便利的解决各种性能问题,但同时也带来了很多的陷阱。
这里我抛出一个实际项目中遇到的陷阱,先简单交代一下故事背景:SpreadJS产品有一个Excel IO部件,是一个ASP.NET MVC Web API(MVC4)应用,用来导入Excel文件到SpreadJS中;其工作过程是客户端先上传Excel文件,服务器端接收文件后读出内容,以SpreadJS特有的JSON格式回传给客户端。很长一段时间工作正常,直到某一天有一个“大神”级的客户反馈他在使用Excel IO过程中会一定几率随机出现导入失败,具体的表现是在返回的JSON数据中提示有IO错误,好吧,附上用户场景的代码片段(略去了脚本引用,DOM以及其他机密代码):

$(document).ready(function()  {    //  initialize  10  spreadjs  widgets     for(var  i  =  0;   i  <   10;   i++)  {         $("#ss_"  +  i).wijspread({  sheetCount:  2  });     }    //  import  handler     $("#importButton").click(function()  {        for(var  i  =  0;   i  <   10;   i++)  {             importToSpread("ss"  +  i);         }     });     //  import  process     function  importToSpread(target)  {        var  formData  =  new  FormData();         formData.append("file",  $("#importingExcelFile").get(0).files[0]);         formData.append("ExcelOpenFlags",  "NoFlagsSet");         formData.append("TextFileOpenFlags",  "None");         formData.append("Password",  "");         $.ajax(  {             url:  "http://your.excelio.path/xsapi/import",             type:  "POST",             success:  function(data,  textStatus,  jqXHR)  {                 $("#"  +  target).wijspread("spread").fromJSON(JSON.parse(jqXHR.responseText).spread);             },             data:  formData,             contentType:  false,             processData:  false,             headers:  {  "Accept":  "application/json"  }         });     } });

也许各位看官可能有话说了:这明显的穷折腾么,有这么把一个文件重复导入10次的实际场景吗?嗯,这是一个社会工程学问题,略过,呵呵。
根据用户的代码,可以分析得到一些关键信息:
1、用户在很短时间内快速提交了多个请求并上传文件;    
2、返回结果会随机出现IO错误;    
由此可以得出结论:应该是服务器处理上传的Excel文件时,某个文件在特定情况下不可用,从而导致处理程序抛出IO异常。什么情况会导致IO不可用呢?似乎一下子还真无从下手,作为开发人员,最容易想到的方法就是祭出IDE,直接挂上调试器,只要捕获到这个IO异常就好了。经过几次尝试,终于看到了IO异常了,如下图:
异步陷阱之IO篇

文章图片

看来前面的分析是对的,文件在特定 情况不可用,但是为什么不可用呢?从上面的IO异常信息可以看出,这个文件是ASP.NET临时保存的上传文件。在ASP.NET WEB API中,处理上传文件的思路和方法如下:
var  root  =  HttpContext.Current.Server.MapPath("~/App_Data"); var  provider  =  new  MultipartFormDataStreamProvider(root); try  {     await  Request.Content.ReadAsMultipartAsync(provider); }  catch  (Exception  ex)  {    return  Request.CreateErrorResponse(HttpStatusCode.InternalServerError,  ex); }var  file  =  provider.FileData.FirstOrDefault(); //  File.OpenRead(file.LocalFileName)  //  may  get  exception  here

从这个片段很容易分析出一下两种可能导致文件IO的情况:
1、文件的LocalFileName不唯一    
2、读取上传内容的异步操作结束但是文件还没有释放    
显然,第一条可以排除,因为异常信息里可以看到文件的名字有一个GUID,基本可以保证绝对唯一,所以,问题肯定发生在这里的异步处理。
为了深入的搞清楚发生了什么,我查看了ReadAsMultipartAsync的源代码,这里面会调用MultipartFormDataStreamProvider上的GetStream方法来处理上传的文件:
//  ...  略去参数处理string  localFileName  =  this.GetLocalFileName(headers); str  =  Path.Combine(this._rootPath,  Path.GetFileName(localFileName)); //  ...  略去部分无关逻辑MultipartFileData  item  =  new  MultipartFileData(headers,  str); this._fileData.Add(item); return  File.Create(str,  this._bufferSize,  FileOptions.Asynchronous);

这里调用GetLocalFileName来获取临时文件名,很清楚的使用了Guid.NewGuid()来保证文件名永远不会重复;焦点转到最后一句返回一个可写的FileStream,注意这里的第三个参数是FileOptions.Asynchronous,就是说,这个FileStream实际是异步IO,但是内部处理逻辑没有等待这个结果就直接走后续的逻辑了,这样导致在服务器运行在高IO并发的情况就很容易发生IO异常。
以上分析了问题,但如何解决呢(某PM话外音:那谁谁,快点啊,客户催着呢),很简单,去除调这个异步IO就可以了,好吧,代码一点也不简单,重写这个GetStream方法,保证获取的FileStream使用同步,虽然一定程度降低了性能,但好歹能解决问题。
参考示例工程代码:下载地址
更新补充:在ASP.NET MVC 5中重写了ReadAsMultipartAsync所在的整个类,已经修复了这个问题(至少我试过同时1000次毫无压力),参考示例中AsyncIoTrap_v5工程。
备注:昨天在OSChina上推出了Wijmo 5 《jQuery UI 组件集 Wijmo 五年最大更新,Mobile First!》。但是本次发布的Wijmo 5 Beta版本未包含SpreadJs。
【异步陷阱之IO篇】


    推荐阅读