有一段時間沒有更新部落格了,最近半年都在著寫書《.NET框架設計—大型企業級框架設計藝術》,很高興這本書將於今年的10月份由圖靈出版社出版,有關本書的具體介紹等書要出版的時候我在另寫一篇文行做介紹。可以先透露一下,本書是博主多年來對應用框架學習的總結,裡面包含了十幾個重量級框架模式,這些模式都是我們目前所經常使用到的,對於學習框架和框架開發來說是很好的參考資料,大家敬請期待。
好了,進入文章主題。
最近幾個月本人一直從事著SOA服務開發工作,簡單點講就是提供服務介面的;從提供前端介面WEBAPI,到提供後端介面WCF\SOAFramework,期間學到了不少有關多執行緒使用上的經驗,這些經驗有的是本人自己的錯誤使用後的經驗,有些是公司的前輩的指點,總之這些東西你不遇到過你是不會意識到該如何使用的,所以本人覺得很有必要總結分享給廣大和我一樣工作在一線的博友們。
我們從服務的處理環節為順序來介紹:
1.使用入口執行緒來處理超長時間呼叫:
任何服務的呼叫都需要首先進到服務的入口方法中,該方法通常扮演著領域邏輯的門面介面(將系統用例進行服務介面的劃分),通過該介面進行用例的呼叫。當我們需要處理長時間過程時都會面臨著頭疼的超時異常,如果我們再去設計如何做超時補償措施就會很複雜而且是沒有必要的開銷。長時處理的服務呼叫場景多半在同步資料中,通過某個JobWs(工作服務)定期的來同步資料(本人就是在這個過程中學到的),當我們無法預知我們的服務會處理多長時間時,基本上都會首先去設定呼叫端的連線超時時間(是不是都會這麼想?);這很正常,很來超時時間就是用來給我們用的;但是我們忽視了我們當前的業務場景了,如果你的服務不返回任何有關狀態值的話“其實應該開啟一個獨立的執行緒來處理同步邏輯而讓服務的呼叫者儘早收到相應”。
1 public class ProductApplicationService 2 { 3 public void SyncProducts() 4 { 5 Task.Factory.StartNew(() => 6 { 7 var productColl = DominModel.Products.GetActivateProducts(); 8 if (!productColl.Any()) return; 9 10 DominModel.Products.WriteProudcts(productColl); 11 }); 12 } 13 }
這樣就可以儘早解放呼叫者;通過開啟一的單獨的執行緒來處理具體的同步邏輯。
如果你的服務需要返回某個狀態值怎麼辦?其實我們可以參考”非同步訊息架構模式“來將訊息寫入到某個訊息佇列中,然後客戶端定期來取或者推送都可以,讓當前的這個服務方法能夠平滑的處理,至少為系統的整體效能瓶頸做了一份貢獻。
1.1異常處理:
入口位置通常都會記錄下呼叫的異常資訊,也就是加上一個try{}catch{},用來捕獲本次呼叫的所有異常資訊。(當然你可能會說程式碼中充斥著try{}catch{}不是很好,可以將其放到某個看不見的地方自動處理,這有好有壞,看不見的地方我們就必然少不了配置,少不了對自定義異常型別的配置,總之事物都有兩面性。)
1 public class ProductApplicationService 2 { 3 public void SyncProducts() 4 { 5 try 6 { 7 Task.Factory.StartNew(() => 8 { 9 var productColl = DominModel.Products.GetActivateProducts(); 10 if (!productColl.Any()) return; 11 12 DominModel.Products.WriteProudcts(productColl); 13 }); 14 } 15 catch(Exception exception) 16 { 17 //記錄下來... 18 } 19 } 20 }
像這樣,看上去好像沒問題哦,但是我們仔細看看就會發現,這個try{}catch{}根本捕獲不到我們任何異常資訊的,因為這個方法是在我們開啟的執行緒外面的,也就是說它早就結束了,開啟的執行緒處理棧中根本就沒有任何的try{}catch{}機制程式碼了;所以我們需要稍微調整一下同步程式碼來支援異常捕獲。
1 public class ProductApplicationService 2 { 3 public void SyncProducts() 4 { 5 Task.Factory.StartNew(SyncPrdoctsTask); 6 } 7 8 private static void SyncPrdoctsTask() 9 { 10 try 11 { 12 var productColl = DominModel.Products.GetActivateProducts(); 13 if (!productColl.Any()) return; 14 15 DominModel.Products.WriteProudcts(productColl); 16 } 17 catch (Exception exception) 18 { 19 //記錄下來... 20 } 21 } 22 }
如果你裝了像Resharp這樣的輔助外掛的話會對你重構程式碼很有幫助,提取某一個方法會很方便快捷;
上述程式碼中,就在新開的執行緒中包含了異常捕獲的程式碼;這樣就不會導致你程式丟擲很多未處理異常,在重要的邏輯點可能會丟失資料。不是說所有的異常都應該由框架來處理,我們需要自己手動的控制某個邏輯點的異常,這樣我們可以保證我們自己的邏輯能夠繼續執行下去。有些邏輯是不可能因為異常的出現而終止整個處理過程的。
2.利用並行來提高多組資料的讀取
位於SOA服務的最外層服務介面時,通常都需要包裝內部眾多服務介面來組合出外部需要的資料,此時需要查詢很多介面的資料,然後等待資料都到齊了之後再將其統一的返回給前端。由於我有一段時間是專門給前端H5提供介面的,最讓我感觸的就是服務介面需要整合所有的資料給前端,從使用者的角度講不希望手機的介面還出現非同步的現象吧,畢竟就那麼大螢幕還有白的地方。但是這個需求給我們開發人員帶來了問題,如果用順序讀取方式將資料都組合好,那個時間是人所無法接受的,所以我們需要開啟並行來同時讀取多個後端服務介面的資料(前提是你這些資料沒有前後依賴關係)。
1 public static ProductCollection GetProductByIds(List<long> pIds) 2 { 3 var result = new ProductCollection(); 4 5 Parallel.ForEach(pIds, id => 6 { 7 //並行方法 8 }); 9 10 return result; 11 }
一切看起來很舒服,多個ID同一個時間被一起執行,但是這裡面有個坑。
2.1控制並行執行緒數:
如果我們用上述程式碼開啟並行後,從GetProductByIds業務點來看一切會很順利,而且效果很明顯速度很快;但是如果當前GetProductByIds方法還在處理過程中時你再發起另一個服務呼叫時你就會發現伺服器響應變慢了,因為所有的請求執行緒全部被佔用了,這裡Parallel並沒有我們想的那麼智慧,能根據情況控制執行緒數;我們需要自己控制我們並行時的最大執行緒數,這樣可以防止由於多執行緒被一個業務點佔用而導致服務佇列其他的後續請求(此時看CPU不一定很高,如果CPU過高導致不接受請求能理解,但是由於系統設定的問題讓執行緒數不夠用也是有可能的)
1 public static ProductCollection GetProductByIds(List<long> pIds) 2 { 3 var result = new ProductCollection(); 4 5 Parallel.ForEach(pIds, new ParallelOptions() { MaxDegreeOfParallelism = 5 /*設定最大執行緒數*/}, id => 6 { 7 //並行方法 8 }); 9 10 return result; 11 }
2.2使用並行處理時資料的前後順序是第一原則
這點上我犯了兩次錯,第一次是將前端需要的資料順序打亂了,導致資料的排名出來問題;第二次是將寫入資料庫的同步資料的時間打亂了,導致程式無法再繼續上次的結束時間繼續同步。所以請大家一定要記住,當你使用並行時,首先問自己你當前的資料上下文邏輯在不在乎前後順序關係,一旦開啟並行後所有的資料都是無須的。
3.手動開啟一個執行緒來代替並行庫啟動的執行緒
現在我們提供的服務介面多多少少會用到非同步async,大概就是想讓我們的系統能夠提到點併發量,讓寶貴的請求處理執行緒能夠及時的被系統再利用而不是在等待上浪費。
大概程式碼會是這樣的,服務入口:
1 public async Task<int> OperationProduct(long ids) 2 { 3 return await DominModel.Products.OperationProduct(ids); 4 }
業務邏輯:
1 public static async Task<int> OperationProduct(long ids) 2 { 3 return await Task.Factory.StartNew<int>(() => 4 { 5 System.Threading.Thread.Sleep(5000); 6 return 100; 7 8 //其實這裡開啟的執行緒是請求執行緒池中的請求處理執行緒,說白了這樣並不會提高併發等於沒用。 9 }); 10 }
其實當我們最後開啟了一個新執行緒時,這個新的執行緒和你awit的執行緒是同一種型別,這樣並不會提高併發反而會由於頻繁的切換執行緒影響效能。要想真的讓你的async有實際意義,使用手動開啟新執行緒來提高併發。(前提是你瞭解了當前系統的整體CPU和執行緒的比例,也就是說你開啟一個兩個手動執行緒是不會有問題的,但是你要放在併發的入口上就請慎重考慮)
在Task中開啟手動執行緒有一點麻煩,看程式碼:
1 public async Task<int> OperationProduct(long id) 2 { 3 var funResult = new AWaitTaskResultValues<int>(); 4 return await DominModel.Products.OperationProduct(id, funResult); 5 } 6 7 public static Task<int> OperationProduct(long id, AWaitTaskResultValues<int> result) 8 { 9 var taskMock = new Task<int>(() => { return 0; });//只是一個await模擬物件,主要是讓系統回收當前“請求處理執行緒” 10 11 var thread = new Thread((threadIds) => 12 { 13 Thread.Sleep(7000); 14 15 result.ResultValue = 100; 16 17 taskMock.Start();//由於沒有任何的邏輯,所以處理會很快完成。 18 }); 19 20 thread.Start(); 21 22 return taskMock; 23 }
之所以這麼麻煩是為了讓系統釋放await執行緒而不是阻塞該執行緒。我通過簡單的測試可以使用少量的執行緒來處理更多的併發請求。