C# 非同步程式設計全面解析

小白哥哥的部落格發表於2015-06-28

當我們處理一些長線的呼叫時,經常會導致介面停止響應或者IIS執行緒佔用過多等問題,這個時候我們需要更多的是用非同步程式設計來修正這些問題,但是通常都是說起來容易做起來難,誠然非同步程式設計相對於同步程式設計來說,它是一種完全不同的程式設計思想,對於習慣了同步程式設計的開發者來說,在開發過程中難度更大,可控性不強是它的特點。

在.NET Framework5.0種,微軟為我們系統了新的語言特性,讓我們使用非同步程式設計就像使用同步程式設計一樣相近和簡單,本文中將會解釋以前版本的Framework中基於回撥道德非同步程式設計模型的一些限制以及新型的API如果讓我們簡單的做到同樣的開發任務。

為什麼要非同步

一直以來,使用遠端資源的程式設計都是一個容易造成困惑的問題,不同於“本地資源”,遠端資源的訪問總會有很多意外的情況,網路環境的不穩定機器服務端的故障,會造成很多程式設計師完全不可控的問題,所以這也就要求程式設計師需要更多的去保護遠端資源的呼叫,管理呼叫的取消、超市、執行緒的等待以及處理執行緒長時間沒響應的情況等。而在.NET中我們通常忽略了這些挑戰,事實上我們會有多種不用的模式來處理非同步程式設計,比如在處理IO密集型操作或者高延遲的操作時候不組測執行緒,多數情況我們擁有同步和非同步兩個方法來做這件事。可是問題在於當前的這些模式非常容易引起混亂和程式碼錯誤,或者開發人員會放棄然後使用阻塞的方式去開發。

而在如今的.NET中,提供了非常接近於同步程式設計的程式設計體驗,不需要開發人員再去處理只會在非同步程式設計中出現的很多情況,非同步呼叫將會是清晰的且不透明的,而且易於和同步的程式碼進行組合使用。

過去糟糕的體驗

最好的理解這種問題的方式是我們最常見的一種情況:使用者介面只擁有一個執行緒所有的工作都執行在這個執行緒上,客戶端程式不能對使用者的滑鼠時間做出反應,這很可能是因為應用程式正在被一個耗時的操作所阻塞,這可能是因為執行緒在等待一個網路ID或者在做一個CPU密集型的計算,此時使用者介面不能獲得執行時間,程式一直處於繁忙的狀態,這是一個非常差的使用者體驗。

很多年來,解決這種問題的方法都是做非同步花的呼叫,不要等待響應,儘快的返回請求,讓其他事件可以同時執行,只是當請求有了最終反饋的時候通知應用程式讓客戶程式碼可以執行指定的程式碼。

而問題在於:非同步程式碼完全毀掉了程式碼流程,回撥代理解釋了之後如何工作,但是怎麼在一個while迴圈裡等待?一個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列表裡下載檔案,統計他們的大小並且同時更新狀態資訊,很明顯這個方法不屬於UI執行緒因為它需要花費非常長的時間來完成,這樣它會完全的掛起UI,但是我們又希望UI能被持續的更新,怎麼做呢?

我們可以建立一個後臺程式設計,讓它持續的給UI執行緒傳送資料來讓UI來更新自身,這個看起來是很浪費的,因為這個執行緒把大多時間花在等下和下載上,但是有的時候,這正是我們需要做的。在這個例子中,WebClient提供了一個非同步版本的DownloadData方法—DownloadDataAsync,它會立即返回,然後在DownloadDataCompleted後觸發一個事件,這允許使用者寫一個非同步版本的方法分割所要做的事,呼叫立即返回並完成接下來的UI執行緒上的呼叫,從而不再阻塞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迴圈並且手動獲得了一個enumerator,每一個呼叫都建立了一個事件回撥。程式碼用遞迴取代了迴圈,這種程式碼你應該都不敢直視了吧。不要著急,還沒有完 。

原始的程式碼返回了一個總數並且顯示它,新的一步版本在統計還沒有完成之前返回給呼叫者。我們怎麼樣才可以得到一個結果返回給呼叫者,答案是:呼叫者必須支援一個回掉,我們可以在統計完成之後呼叫它。

然而異常怎麼辦?原始的程式碼並沒有關注異常,它會一直傳遞給呼叫者,在非同步版本中,我們必須擴充套件回掉來讓異常來傳播,在異常發生時,我們不得不明確的讓它傳播。

最終,這些需要將會進一步讓程式碼混亂:

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玩意嗎?

恐怕不能,我們開始只是想和同步方法那樣只是用一個非同步的呼叫來替換阻塞的呼叫,讓它包裝在一個foreach迴圈中,想想一下試圖去組合更多的非同步呼叫或者有更復雜的控制結構,這不是一個SubPageSizesAsync的規模能解決的。

我們的真正問題在於我們不再可以解釋這些方法裡的邏輯,我們的程式碼已經完全無章可循。非同步程式碼中很多的工作讓整件事情看起來難以閱讀並且似乎充滿了BUG。

一個新的方式

如今,我們擁有了一個新的功能來解決上述的問題,非同步版本的程式碼將會如下文所示:

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;
        }

除了新增的高亮的部分,上文中的程式碼與同步版本的程式碼非常相似,程式碼的流程也從未改變,我們也沒有看到任何的回撥,但是這並不代表實際上沒有回撥操作,編譯器會搞定這些工作,不再需要您去關心。

非同步的方法是用了Task<int>替代了原來返回的Int型別,Task和Task<T>是在如今的framework提供的,用來代表一個正在執行的工作。

非同步的方法沒有額外的方法,依照慣例為了區別同步版本的方法,我們在方法名後新增Async作為新的方法名。上文中的方法也是非同步的,這表示方法體會讓編譯器區別對待,允許其中的一部分將會變成回撥,並且自動的建立Task<int>作為返回型別。

關於這個方法的解釋:在方法內部,呼叫另外一個非同步方法DownloadDataTaskAsync,它快速的返回一個Task<byte[]>型別的變數,它會在下載資料完成以後被啟用,到如前為止,在資料沒有完成之前,我們不想做任何事,所以我們使用await來等待操作的完成。

看起來await關鍵字阻塞了執行緒直到task完成下載的資料可用,其實不然,相反它標誌了任務的回撥,並且立即返回,當這個任務完成之後,它會執行回撥。

Tasks

Task和Task<T>型別已經存在於.NET Framework 4.0中,一個Task代表一個進行時的活動,它可能是一個執行在單獨執行緒中的一個CPU密集型的工作或者一個IO操作,手動的建立一個不工作在單獨執行緒的任務也是非常容易的:

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;
        }

一旦建立了一個TaskCompletionSource物件,你就可以返回與它關聯的Task物件,問相關的工作完成後,客戶程式碼才得到最終的結果,這時Task沒有佔據自己的執行緒。

如果實際任務失敗,Task從樣可以攜帶異常並且向上傳播,如果使用await將觸發客戶端程式碼的異常:

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;
        }

image

基於任務的非同步程式設計模型

上文中解釋了非同步方法應該是的樣子-Task-based asynchronous Pattern(TAP),上文中非同步的體現只需要一個呼叫方法和非同步非同步方法,後者返回一個Task或者Task<T>。

下文中將介紹一些TAP中的約定,包括怎麼處理“取消”和“進行中”,我們將進一步講解基於任務的程式設計模型。

Async和await

理解async方法不執行在自己的執行緒是非常重要的,事實上,編寫一個async方法但是沒有任何await的話,它就將會是一個不折不扣的同步方法:

static async Task<int> TenToSevenAsync()
        {
            Thread.Sleep(10000);
            return 7;
        }

假如你呼叫這個方法,將會阻塞執行緒10秒後返回7,這也許不是你期望的,在VS中也將得到一個警告,因為這可能永遠不是想要的結果。

只有一個async方法執行到一個await語句時,它才立即把控制權返回給呼叫方,然而只有當等待的任務完成之後,它才會真正的返回結果,這意味著你需要確保async方法中的程式碼不會做過多的任務或者阻塞效能的呼叫。下面的例項才是你所期望的效果

static async Task<int> TenToSevenAsync()
{
    await Task.Delay(3000);
    return 7;
}

Task.Delay實際上是非同步版本的Tread,Sleep,它返回一個Task,這個Task將會在指定的時間內完成。

時間處理程式和無返回值的非同步方法

非同步方法可以從其他非同步方法使用await建立,但是非同步在哪裡結束?

在客戶端程式中,通常的回答是非同步方法由事件發起,使用者點選一個按鈕,一個非同步方法被啟用,直到它完成,事件本身並不關係方法何時執行完成。這就是通常所說的“發後既忘”

為了適應這種模式,非同步方法通常明確的被設計為“發後既忘”-使用void作為返回值替代Task<TResult>型別,這就讓方法可以直接作為一個事件處理程式。當一個void saync的方法執行時,沒有Task被返回,呼叫者也無法追蹤呼叫是否完成。

private async void someButton_Click(object sender, RoutedEventArgs e)
{
    someButton.IsEnabled = false;
    await SumPageSizesAsync(GetUrls()));
    someButton.IsEnabled = true;
}

結束語

越寫到最後,越不說人話啦。。。。。

相關文章