在上周解決“博客程序異步化改造之后遭遇的性能問題”的過程中,我們干了一件自以為很有成就感的事——在表現層(MVC與WebForms)將所有使用await的地方都加上了ConfigureAwait(false),比如下面代碼:
var taskCategories = GetCategoriesAsync(); model.Posts = await GetPostsAsync(model).ConfigureAwait(false); model.Paging.TotalCount = await taskTotalCount.ConfigureAwait(false); model.HeadlineHtml = await taskHeadlineHtml.ConfigureAwait(false); model.Categories = await taskCategories.ConfigureAwait(false);
干完之后才恍然大悟,我們“出色”地完成了一件傻事,性能問題並沒有得到解決,最終發現問題的真正原因是我們修改的EnyimMemcaced代碼存在內存泄漏問題。
犯傻之后,郁悶之時,意外地發現竟然有一些收獲可以分享,於是自傷的心有了些許安慰。
在干這次傻事之前,我們分不清默認的ConfigureAwait(continueOnCapturedContext:true)與ConfigureAwait(false)的區別,只知道一個是在異步執行時捕獲上下文,一個是在異步執行時不捕獲上下文。更不知道ConfigureAwait(false)會帶來什么影響?
傻過之后,我們對此多了一點了解:
1)當ConfigureAwait(true),代碼由同步執行進入異步執行時,當前同步執行的線程上下文信息(比如HttpConext.Current,Thread.CurrentThread.CurrentCulture)就會被捕獲並保存至SynchronizationContext中,供異步執行中使用,並且供異步執行完成之后(await之后的代碼)的同步執行中使用(雖然await之后是同步執行的,但是發生了線程切換,會在另外一個線程中執行「ASP.NET場景」)。這個捕獲當然是有代價的,當時我們誤以為性能問題是這個地方的開銷引起,但實際上這個開銷很小,在我們的應用場景不至於會帶來性能問題。
2)當Configurewait(flase),則不進行線程上下文信息的捕獲,async方法中與await之后的代碼執行時就無法獲取await之前的線程的上下文信息,在ASP.NET中最直接的影響就是HttpConext.Current的值為null。
我們在犯傻過程中,工作量最大的就是處理HttpConext.Current為null的情況。
由於之前寫代碼時的幼稚與偷懶,造成了很多地方用HttpConext.Current去獲取http請求相關信息。在異步化改造之后,HttpConext.Current也遍布在很多aync方法中。Configurewait(flase)之后,NullReferenceException如雨后春筍。
針對這樣的窘境,我們只能一個個修改代碼,通過方法參數傳遞所需要的HttpConext信息,取代原先的HttpConext.Current“綠色通道”訪問方式。
還有些地方根本不需要HttpConext.Current,只是因為當初的幼稚,比如Server.MapPath(),Server.UrlEncode(),進行了這樣的更改:
System.Web.Hosting.HostingEnvironment.MapPath();
HttpUtility.UrlEncode();
在處理HttpConext.Current為null的情況中,我們遇到了一個棘手的問題,它出現在MVC與WebForms混用的場景——在MVC Controller中加載WebForms中的UserControl(也就是讓UserControl直接Render為字符串)。
之前我們在MVC中是這樣處理的:
page.Controls.Add(commentControl); using (var sw = new StringWriter()) { HttpContext.Current.Server.Execute(page, sw, true); commentsHtml = sw.ToString(); }
可是現在HttpContext.Current為null,不得不改成這樣:
page.Controls.Add(commentControl); var sb = new StringBuilder(); using (var sw = new StringWriter(sb)) { using (var htw = new HtmlTextWriter(sw)) { commentControl.RenderControl(htw); commentsHtml = sb.ToString(); } }
改好之后發現,只要.ascx中用到了HyperLink控件並訪問NavigateUrl屬性,就會出現NullReferenceException。原來HyperLink.NavigateUrl中調用了ResolveClientUrl方法,而ResolveClientUrl時會訪問Context.Request.ClientBaseDir.VirtualPathString,而Context為null。
在這個地方折騰了不少時間,后來繞道解決了這個問題,用Attributes.Add取代HyperLink.NavigateUrl,比如:
hl.Attributes.Add("href", "http://www.cnblogs.com/");
在WebForms的UserControl中添加ConfigureAwait(false)時,我們開始時以為await之后的代碼如果訪問Context,也會引發NullReferenceException,而事實表明不會引發。因為在ASP.NET實例化UserControl時會將HttpContext的值傳給UserControl的Context屬性,所以在UserControl無需通過線程上文獲取HttpContext信息(Page的情況也一樣)。
另外一個受影響的地方就是線程的CurrentCulture設置,之前我們是在Application_BeginRequest中處理的,代碼如下:
protected void Application_BeginRequest(Object sender, EventArgs e) { CultureInfo newci = new CultureInfo("zh-CN"); newci.DateTimeFormat.DayNames = new string[] { "日", "一", "二", "三", "四", "五", "六" }; newci.DateTimeFormat.FirstDayOfWeek = DayOfWeek.Sunday; System.Threading.Thread.CurrentThread.CurrentCulture = newci; }
但是由於ConfigureAwait(false),異步執行中的線程切換會造成CurrentCulture丟失。
解決方法很簡單,直接設置所有線程的CurrentCulture,代碼如下:
protected void Application_Start(Object sender, EventArgs e) { var newCulture = new CultureInfo("zh-CN"); newCulture.DateTimeFormat.DayNames = new string[] { "日", "一", "二", "三", "四", "五", "六" }; newCulture.DateTimeFormat.FirstDayOfWeek = DayOfWeek.Sunday; CultureInfo.DefaultThreadCurrentCulture = newCulture; }
ConfigureAwait(false)的使用經驗值得分享的就這些。
在這次異步化改造過程中,不僅加ConfigureAwait(false)是干傻事,整個異步化改造就是一件大傻事。改造過程很艱辛,多次猶豫是不是要這么徹底地異步化,最終還是堅持了下來。當解決了內存泄漏問題之后,如釋重負!異步化改造效果明顯,超出了預期——響應性能與吞吐能力都得到了提升。過程中所有的煎熬與痛苦都被成功后涌上心頭的那種興奮所秒殺。
【參考資料】
Best practice to call ConfigureAwait for all server-side code