親身體驗及舉例來全面解析C# 異步編程
當我們處理一些長線的調(diào)用時,經(jīng)常會導(dǎo)致界面停止響應(yīng)或者IIS線程占用過多等問題,這個時候我們需要更多的是用異步編程來修正這些問題,但是通常 都是說起來容易做起來難,誠然異步編程相對于同步編程來說,它是一種完全不同的編程思想,對于習(xí)慣了同步編程的開發(fā)者來說,在開發(fā)過程中難度更大,可控性 不強是它的特點。
在.NET Framework5.0種,微軟為我們系統(tǒng)了新的語言特性,讓我們使用異步編程就像使用同步編程一樣相近和簡單,本文中將會解釋以前版本的Framework中基于回調(diào)道德異步編程模型的一些限制以及新型的API如果讓我們簡單的做到同樣的開發(fā)任務(wù)。
為什么要異步
一直以來,使用遠程資源的編程都是一個容易造成困惑的問題,不同于“本地資源”,遠程資源的訪問總會有很多意外的情況,網(wǎng)絡(luò)環(huán)境的不穩(wěn)定機器服務(wù)端的故障,會造成很多程序員完 全不可控的問題,所以這也就要求程序員需要更多的去保護遠程資源的調(diào)用,管理調(diào)用的取消、超市、線程的等待以及處理線程長時間沒響應(yīng)的情況等。而 在.NET中我們通常忽略了這些挑戰(zhàn),事實上我們會有多種不用的模式來處理異步編程,比如在處理IO密集型操作或者高延遲的操作時候不組測線程,多數(shù)情況 我們擁有同步和異步兩個方法來做這件事。可是問題在于當前的這些模式非常容易引起混亂和代碼錯誤,或者開發(fā)人員會放棄然后使用阻塞的方式去開發(fā)。
而在如今的.NET中,提供了非常接近于同步編程的編程體驗,不需要開發(fā)人員再去處理只會在異步編程中出現(xiàn)的很多情況,異步調(diào)用將會是清晰的且不透明的,而且易于和同步的代碼進行組合使用。
過去糟糕的體驗
最好的理解這種問題的方式是我們最常見的一種情況:用戶界面只擁有一個線程所有的工作都運行在這個線程上,客戶端程序不能對用戶的鼠標時間做出反 應(yīng),這很可能是因為應(yīng)用程序正在被一個耗時的操作所阻塞,這可能是因為線程在等待一個網(wǎng)絡(luò)ID或者在做一個CPU密集型的計算,此時用戶界面不能獲得運行 時間,程序一直處于繁忙的狀態(tài),這是一個非常差的用戶體驗。
很多年來,解決這種問題的方法都是做異步花的調(diào)用,不要等待響應(yīng),盡快的返回請求,讓其他事件可以同時執(zhí)行,只是當請求有了最終反饋的時候通知應(yīng)用程序讓客戶代碼可以執(zhí)行指定的代碼。
而問題在于:異步代碼完全毀掉了代碼流程,回調(diào)代理解釋了之后如何工作,但是怎么在一個while循環(huán)里等待?一個if語句?一個try塊或者一個using塊?怎么去解釋“接下來做什么”?
看下面的一個例子:
- public int SumPageSizes(IList<Uri> uris)
- {
- int total = 0;
- foreach (var uri in uris)
- {
- txtStatus.Text = string.Format("Found {0} bytes...", total);
- var data = new WebClient().DownloadData(uri);
- total += data.Length;
- }
- txtStatus.Text = string.Format("Found {0} bytes total", total);
- return total;
- }
這個方法從一個uri列表里下載文件,統(tǒng)計他們的大小并且同時更新狀態(tài)信息,很明顯這個方法不屬于UI線程因為它需要花費非常長的時間來完成,這樣它會完全的掛起UI,但是我們又希望UI能被持續(xù)的更新,怎么做呢?
我們可以創(chuàng)建一個后臺編程,讓它持續(xù)的給UI線程發(fā)送數(shù)據(jù)來讓UI來更新自身,這個看起來是很浪費的,因為這個線程把大多時間花在等下和下載上,但 是有的時候,這正是我們需要做的。在這個例子中,WebClient提供了一個異步版本的DownloadData方法— DownloadDataAsync,它會立即返回,然后在DownloadDataCompleted后觸發(fā)一個事件,這允許用戶寫一個異步版本的方法 分割所要做的事,調(diào)用立即返回并完成接下來的UI線程上的調(diào)用,從而不再阻塞UI線程。下面是第一次嘗試:
- public void SumpageSizesAsync(IList<Uri> uris)
- {
- SumPageSizesAsyncHelper(uris.GetEnumerator(), 0);
- }
- public void SumPageSizesAsyncHelper(IEnumerator<Uri> enumerator, int total)
- {
- if (enumerator.MoveNext())
- {
- txtStatus.Text = string.Format("Found {0} bytes...", total);
- var client = new WebClient();
- client.DownloadDataCompleted += (sender,e)=>{
- SumPageSizesAsyncHelper(enumerator, total + e.Result.Length);
- };
- client.DownloadDataAsync(enumerator.Current);
- }
- else
- {
- txtStatus.Text = string.Format("Found {0} bytes total", total);
- }
- }
然后這依然是糟糕的,我們破壞了一個整潔的foreach循環(huán)并且手動獲得了一個enumerator,每一個調(diào)用都創(chuàng)建了一個事件回調(diào)。代碼用遞歸取代了循環(huán),這種代碼你應(yīng)該都不敢直視了吧。不要著急,還沒有完 。
原始的代碼返回了一個總數(shù)并且顯示它,新的一步版本在統(tǒng)計還沒有完成之前返回給調(diào)用者。我們怎么樣才可以得到一個結(jié)果返回給調(diào)用者,答案是:調(diào)用者必須支持一個回掉,我們可以在統(tǒng)計完成之后調(diào)用它。
然而異常怎么辦?原始的代碼并沒有關(guān)注異常,它會一直傳遞給調(diào)用者,在異步版本中,我們必須擴展回掉來讓異常來傳播,在異常發(fā)生時,我們不得不明確的讓它傳播。
最終,這些需要將會進一步讓代碼混亂:
- public void SumpageSizesAsync(IList<Uri> uris,Action<int,Exception> callback)
- {
- SumPageSizesAsyncHelper(uris.GetEnumerator(), 0, callback);
- }
- public void SumPageSizesAsyncHelper(IEnumerator<Uri> enumerator, int total,Action<int,Exception> callback)
- {
- try
- {
- if (enumerator.MoveNext())
- {
- txtStatus.Text = string.Format("Found {0} bytes...", total);
- var client = new WebClient();
- client.DownloadDataCompleted += (sender, e) =>
- {
- SumPageSizesAsyncHelper(enumerator, total + e.Result.Length,callback);
- };
- client.DownloadDataAsync(enumerator.Current);
- }
- else
- {
- txtStatus.Text = string.Format("Found {0} bytes total", total);
- enumerator.Dispose();
- callback(total, null);
- }
- }
- catch (Exception ex)
- {
- enumerator.Dispose();
- callback(0, ex);
- }
- }
當你再看這些代碼的時候,你還能立馬清楚的說出這是什么JB玩意嗎?
恐怕不能,我們開始只是想和同步方法那樣只是用一個異步的調(diào)用來替換阻塞的調(diào)用,讓它包裝在一個foreach循環(huán)中,想想一下試圖去組合更多的異步調(diào)用或者有更復(fù)雜的控制結(jié)構(gòu),這不是一個SubPageSizesAsync的規(guī)模能解決的。
我們的真正問題在于我們不再可以解釋這些方法里的邏輯,我們的代碼已經(jīng)完全無章可循。異步代碼中很多的工作讓整件事情看起來難以閱讀并且似乎充滿了BUG。
#p#
一個新的方式
如今,我們擁有了一個新的功能來解決上述的問題,異步版本的代碼將會如下文所示:
- public async Task<int> SumPageSizesAsync(IList<Uri> uris)
- {
- int total = 0;
- foreach (var uri in uris)
- {
- txtStatus.Text = string.Format("Found {0} bytes...", total);
- var data = await new WebClient().DownloadDataTaskAsync(uri);
- total += data.Length;
- }
- txtStatus.Text = string.Format("Found {0} bytes total", total);
- return total;
- }
除了添加的高亮的部分,上文中的代碼與同步版本的代碼非常相似,代碼的流程也從未改變,我們也沒有看到任何的回調(diào),但是這并不代表實際上沒有回調(diào)操作,編譯器會搞定這些工作,不再需要您去關(guān)心。
異步的方法是用了Task<int>替代了原來返回的Int類型,Task和Task<T>是在如今的framework提供的,用來代表一個正在運行的工作。
異步的方法沒有額外的方法,依照慣例為了區(qū)別同步版本的方法,我們在方法名后添加Async作為新的方法名。上文中的方法也是異步的,這表示方法體會讓編譯器區(qū)別對待,允許其中的一部分將會變成回調(diào),并且自動的創(chuàng)建Task<int>作為返回類型。
關(guān)于這個方法的解釋:在方法內(nèi)部,調(diào)用另外一個異步方法DownloadDataTaskAsync,它快速的返回一個 Task<byte[]>類型的變量,它會在下載數(shù)據(jù)完成以后被激活,到如前為止,在數(shù)據(jù)沒有完成之前,我們不想做任何事,所以我們使用 await來等待操作的完成。
看起來await關(guān)鍵字阻塞了線程直到task完成下載的數(shù)據(jù)可用,其實不然,相反它標志了任務(wù)的回調(diào),并且立即返回,當這個任務(wù)完成之后,它會執(zhí)行回調(diào)。
Tasks
Task和Task<T>類型已經(jīng)存在于.NET Framework 4.0中,一個Task代表一個進行時的活動,它可能是一個運行在單獨線程中的一個CPU密集型的工作或者一個IO操作,手動的創(chuàng)建一個不工作在單獨線程的任務(wù)也是非常容易的:
- static Task ReadFileAsync(string filePath,out byte[] buffer)
- {
- Stream stream = File.Open(filePath, FileMode.Open);
- buffer = new byte[stream.Length];
- var tcs = new TaskCompletionSource<double>();
- stream.BeginRead(buffer, 0, buffer.Length, arr =>
- {
- var length = stream.EndRead(arr);
- tcs.SetResult(stream.Length);
- }, null);
- return tcs.Task;
- }
- 一旦創(chuàng)建了一個TaskCompletionSource對象,你就可以返回與它關(guān)聯(lián)的Task對象,問相關(guān)的工作完成后,客戶代碼才得到最終的結(jié)果,這時Task沒有占據(jù)自己的線程。
- 如果實際任務(wù)失敗,Task從樣可以攜帶異常并且向上傳播,如果使用await將觸發(fā)客戶端代碼的異常:
- static async void ReadAssignedFile()
- {
- byte[] buffer;
- try
- {
- double length = await ReadFileAsync("SomeFileDoNotExisted.txt", out buffer);
- }
- catch (Exception ex)
- {
- Console.WriteLine(ex.Message);
- }
- }
- static Task<double> ReadFileAsync(string filePath,out byte[] buffer)
- {
- Stream stream = File.Open(filePath, FileMode.Open);
- buffer = new byte[stream.Length];
- var tcs = new TaskCompletionSource<double>();
- stream.BeginRead(buffer, 0, buffer.Length, arr =>
- {
- try
- {
- var length = stream.EndRead(arr);
- tcs.SetResult(stream.Length);
- }
- catch (IOException ex)
- {
- tcs.SetException(ex);
- }
- }, null);
- return tcs.Task;
- }
基于任務(wù)的異步編程模型
上文中解釋了異步方法應(yīng)該是的樣子-Task-based asynchronous Pattern(TAP),上文中異步的體現(xiàn)只需要一個調(diào)用方法和異步異步方法,后者返回一個Task或者Task<T>。
下文中將介紹一些TAP中的約定,包括怎么處理“取消”和“進行中”,我們將進一步講解基于任務(wù)的編程模型。
Async和await
理解async方法不運行在自己的線程是非常重要的,事實上,編寫一個async方法但是沒有任何await的話,它就將會是一個不折不扣的同步方法:
- static async Task<int> TenToSevenAsync()
- {
- await Task.Delay(3000);
- return 7;
- }
假如你調(diào)用這個方法,將會阻塞線程10秒后返回7,這也許不是你期望的,在VS中也將得到一個警告,因為這可能永遠不是想要的結(jié)果。
只有一個async方法運行到一個await語句時,它才立即把控制權(quán)返回給調(diào)用方,然而只有當?shù)却娜蝿?wù)完成之后,它才會真正的返回結(jié)果,這意味著你需要確保async方法中的代碼不會做過多的任務(wù)或者阻塞性能的調(diào)用。下面的實例才是你所期望的效果
- static async Task<int> TenToSevenAsync()
- {
- await Task.Delay(3000);
- return 7;
- }
Task.Delay實際上是異步版本的Tread,Sleep,它返回一個Task,這個Task將會在指定的時間內(nèi)完成。
時間處理程序和無返回值的異步方法
異步方法可以從其他異步方法使用await創(chuàng)建,但是異步在哪里結(jié)束?
在客戶端程序中,通常的回答是異步方法由事件發(fā)起,用戶點擊一個按鈕,一個異步方法被激活,直到它完成,事件本身并不關(guān)系方法何時執(zhí)行完成。這就是通常所說的“發(fā)后既忘”
為了適應(yīng)這種模式,異步方法通常明確的被設(shè)計為“發(fā)后既忘”-使用void作為返回值替代Task<TResult>類型,這就讓方法 可以直接作為一個事件處理程序。當一個void saync的方法執(zhí)行時,沒有Task被返回,調(diào)用者也無法追蹤調(diào)用是否完成。
- private async void someButton_Click(object sender, RoutedEventArgs e)
- {
- someButton.IsEnabled = false;
- await SumPageSizesAsync(GetUrls()));
- someButton.IsEnabled = true;
- }
結(jié)束語
越寫到最后,越不說人話啦。。。。。