再次學習 C# 異步,從老板吃薯條說起

本文主要介紹了在 C# 中使用 Async 和 Await 關(guān)鍵字進行異步編程的心得,是入門級的學習筆記。


題解:關(guān)于「再次」這個說法,是因為前幾次學習都失敗了,這要怪微軟在 MSDN 上給出的那張執(zhí)行順序圖,實在是擰麻花。光是弄清楚所謂的「不阻塞 UI 線程,立即返回,等到執(zhí)行結(jié)束后繼續(xù)后面的操作」這句話是什么含義,我就折騰了好久。

其實 C# 5.0 推出的異步,是個「人人用了都說好」的事情,極大地簡化了多線程的實現(xiàn)方式,所以應該更接地氣才對的。只不過,眾多教程都是秉持科學嚴謹專業(yè)的態(tài)度,不能給初學者一個直觀的感受,讓初學者一下子透徹地理解——異步時,電腦究竟在干什么。

在「終于」學會異步之時,激動地想要實現(xiàn)化繁為簡的偉大事業(yè),遂撰此文。只講故事,不說技術(shù)細節(jié)。


文章結(jié)構(gòu)

  • 前言
  • 0x00 異步是個什么東西
  • 0x01 如何編寫異步代碼
  • 0x02 異步代碼的執(zhí)行順序(★)
  • 0x03 一些問題的解答
  • 0x04 本文沒有介紹的內(nèi)容
  • 參考

0x00 異步是個什么東西

哦,異步(Asynchrony)就是「非同步」(A-Synchrony)

這里你要知道,英語中 a(n) 作為前綴,是可以表示 not 或 without 的,所以「不同步性」、「沒有同步性」就是「異步性」了)。

中文里,「同步」給人的感覺是「同時進行的事情」,例如「挖土和蓋樓兩件事情同步進行中」,表示的是我們在挖土的同時也在蓋大樓。然而在碼農(nóng)的世界里,「同步」的意思其實是「序貫執(zhí)行」,說人話就是「一個接一個地有序執(zhí)行」。例如你寫了 5 件事情在任務清單上,你必須得做完第一件再做第二件,順序不可跳躍。

這帶來一個問題:當上一項任務沒有完成時,下一項任務無法開始。那么當你有 30 分鐘燒開水10 分鐘洗茶杯10 分鐘洗茶壺10分鐘找到茶葉這四件事情要完成時,你無法先去把水燒上,等它燒開的同時,洗杯子,洗壺,找茶葉,只能傻等到水燒開了之后再去做剩下的三件事。如此一來,總共可以在 30 分鐘內(nèi)完成的事情,拖拖拉拉到 60 分鐘才能搞定,實乃人力物力財力的巨大浪費。

為了讓電腦聰明起來,提高效率,人們發(fā)明了「異步」的概念。所以其實「異步」才真正意味著「同時進行」的事情,可以理解為「你去挖土,我來蓋樓」,如此我們「各干各的,分工相異,同時進行,完事兒后一起交差」。放到沏茶的例子里,就是在燒開水的同時,去洗杯子,洗壺,找茶葉,總共 30 分鐘完成。

放在計算機上來講是這樣:WinForm 程序啟動時,只有一個線程,即 UI 線程。此時所有的工作都是由 UI 線程來處理的,當工作量較小時,一瞬間即可完成,用戶不會覺得有任何異樣;而當假設完成一件巨型計算量的工作需要 30 分鐘時,這個線程就會拼命地不停地去計算這個結(jié)果,而無暇顧及用戶對 UI 的操作,導致 UI 卡死無響應。這種情況是要極力避免的,任何時候都應當以向用戶提供實時操作反饋為第一目標,所以那些個極其耗費計算資源的事情,應該扔到后臺去做。

這聽起來像是「多線程」?的確,異步其實是多線程編程的一種實現(xiàn)方法。與傳統(tǒng)方法相比,異步在代碼寫法、實現(xiàn)方式、管理復雜度和異常處理方面更加便捷而高效。并且,異步代碼的寫法,「看上去就像同步的代碼一樣」,簡單而直接,因而被賦予了這樣一個地位極高的名字。當然了,異步的本質(zhì)仍然逃不開多線程,無論是調(diào)用別人的異步方法,還是編寫自己的異步方法,都是要新開線程來完成工作的,單線程的異步,本質(zhì)上還是同步的。不過好在,異步的引入,使得這一過程得到了極大的簡化。

0x01 如何編寫異步代碼

關(guān)于這個,MSDN 的官方講解應該是介紹最為完全的了(使用 Async 和 Await 的異步編程(C# 和 Visual Basic))。但遺憾的是,MSDN 本身解耦工作做得并不到位,存在嚴重的用術(shù)語解釋術(shù)語的問題,以及出神入化的行文邏輯,讓初學者越看越暈,所以我們要從一個更小的切入點開始說起。

先來建立一下對異步編程模型各個要素的認識。

  • 異步方法的返回值類型有三種:void,Task 和 Task<T>。
  • 使用 async 關(guān)鍵字修飾方法簽名,表示該方法為異步方法。
  • 在異步方法內(nèi)使用 await 關(guān)鍵字來等待一個「可等待」類型,實現(xiàn)異步。

這樣說來太抽象,用一個例子來說明。假設我們要實現(xiàn)這樣的功能:點擊一個按鈕,進行一個計算量巨大的操作,要耗時 30 秒鐘,計算結(jié)束后在窗口內(nèi)顯示計算結(jié)果。代碼如下:

private void button1_Click(object sender, EventArgs e)
{
    var result = doSomething();
    label1.Text = result;
}

private string doSomething()
{
    System.Threading.Thread.Sleep(30000);
    return "result";
}

這里將當前線程掛起 30 秒,來模擬耗時 30 秒的計算過程。很顯然,運行程序點擊按鈕后,UI 會在 30 秒內(nèi)毫無響應,全身心地投入到了復雜的計算過程中。

接下來我們用異步編程的方法來改善這一問題。異步編程的核心思想是,執(zhí)行異步方法,當遇到 await 關(guān)鍵字時,控制權(quán)立即返回給調(diào)用者,同時等待 await 語句所指代的異步方法的結(jié)束,當方法執(zhí)行完畢返回結(jié)果時,接著執(zhí)行 await 語句后面的代碼。

放在這里就是,當點擊按鈕時,我們要進行巨型耗時計算,此時我們希望將控制權(quán)立刻返還給 UI,使得 UI 可以相應用戶的其他操作,同時在后臺進行計算工作,當?shù)贸鲇嬎憬Y(jié)果時,我們把它顯示在窗口上。

那么就按照如下方法改造之前的代碼。

// 給事件處理器添加 async 關(guān)鍵字
private async void button1_Click(object sender, EventArgs e)
{
    // 給對計算方法的調(diào)用添加 await 關(guān)鍵字
    var result = await doSomething();
    label1.Text = result;
}

// 將返回值類型改為 Task<string>
private Task<string> doSomething()
{
    // 將計算操作放到一個 Task<string> 中去,新開線程
    var t = Task.Run(() => 
    {
        // 使用 lambda 表達式定義計算和返回工作
        System.Threading.Thread.Sleep(30000);
        return "result";
    });
    // 返回這個 Task
    return t;
}

現(xiàn)在再運行一遍,可以發(fā)現(xiàn),點擊按鈕后計算開始運行,但是 UI 仍然可以響應用戶的操作,例如對窗口的移動、縮放,和點擊其他控件等等,30 秒后,計算完成,窗口上的標簽控件給出了結(jié)果「result」;

關(guān)于程序的運行順序,先按下不表,下文詳談。來說說這里幾處關(guān)鍵的代碼變動。

  1. 添加 async 關(guān)鍵字

    添加 async 關(guān)鍵字的目的在于,將方法明示為一個異步方法,從而在其內(nèi)部的 await 單詞會被識別為一個關(guān)鍵字,如果方法簽名中沒有 async 關(guān)鍵字的話,方法體中的 await 是作為標識符來識別的,也就是說你可以定義一個名為 await 的變量,例如 string await = "hehe"(不推薦這么做)。因而要使用 await 語句,必須在方法簽名中加入 async 關(guān)鍵詞。其實這對于編譯器來說是多余的,但對于代碼的可讀性而言大有裨益。

  2. 在對 doSomething 方法的調(diào)用前添加 await 關(guān)鍵字

    await 是異步編程的靈魂,用于等待一個「可等待」(awaitable)的對象返回值,同時向異步方法的調(diào)用者返回控制權(quán)。這里,我們使用 Task 對象來實現(xiàn)計算任務。

  3. 將計算任務的返回值更改為 Task<string>

    這里,如果不了解 Task 的話,需要去補補課。這里的含義是「返回值類型為字符串的任務」。Task 本身是可等待的對象,因而可以作為 await 關(guān)鍵字操作的要素。這個方法是 await 要等待的任務,它本身是不需要用 async 關(guān)鍵字來修飾的。

  4. 建立新線程完成具體工作

    1. Task.Run 方法直接將 t 定義為一個新的 Task,并且立刻執(zhí)行。由于 Task 本身是利用線程池在后臺執(zhí)行的,所以這一步是實現(xiàn)異步編程多線程步驟的核心。當我們撰寫自己的異步實現(xiàn)方法(注意不是異步方法)時要進行多線程的操作,否則代碼始終還是同步(按順序)執(zhí)行的。
    2. 變量 t 作為返回值,必須與方法簽名相同,是 Task<string> 類型的,但是在 Task.Run 中并沒有體現(xiàn),而是在參數(shù)中的 lambda 表達式所體現(xiàn)的,因為 lambda 表達式代碼塊中返回了一個字符串。這里如有不明的地方,需要去補充一下關(guān)于 lambda 表達式的知識。實際上,也可以顯示地將 t 定義為 Task<string>.Run
  5. 返回變量 t

    異步實現(xiàn)方法 doSomething 的返回值類型是 Task<string>,為什么在調(diào)用方法中由類型為 string 的變量接收返回值?這是由異步編程模型和 Task 模型內(nèi)部的邏輯所決定的,更多深入的內(nèi)容請參見文末的參考文獻,此處不做過多介紹。

如此,我們就實現(xiàn)了一個簡單的異步編程,不僅包含了編寫異步方法,也包含了編寫異步實現(xiàn)方法。這可能是我個人的說法:異步方法就是簽名中包含 async 關(guān)鍵字,在方法體中包含 await 關(guān)鍵字,用來執(zhí)行異步操作的方法;而異步實現(xiàn)方法就是,返回值類型為可等待的,由多線程來執(zhí)行具體任務的方法。

在 .NET 4.5 中,微軟提供了一批已經(jīng)預先編寫好的異步實現(xiàn)方法,例如 HttpClient 對象的 GetStringAsync 方法,其返回值是 Task<string> 類型,我們可以在使用中直接編寫如下代碼:

using System.Net.Http;
......
private async void button1_Click(object sender, EventArgs e)
{
  var result = await new HttpClient().GetStringAsync("about:blank");
  label1.Text = result;
}

這樣,我們就可以十分方便地實現(xiàn)異步編程,無序大量的多線程處理,就可以實現(xiàn)后臺工作和前臺響應兩不誤。

或者,可以編寫自己的異步實現(xiàn)方法,用來實現(xiàn)異步調(diào)用,如同上文的例子一樣。

0x02 異步代碼的執(zhí)行順序

在 Visual Studio 2012 和 2013 版的 MSDN 上,關(guān)于這個問題,微軟提供了一張圖,就是下面這個。

What Happens in an Async Method

遺憾的是,雖然圖上畫的東西完全正確,但對于初學者來說,實在是太懵圈了。我自己在學習的時候,反復閱讀也只能是有一個抽象的印象,不能建立直觀的了解,不清楚這背后究竟是什么邏輯。更要命的是,這兩份文檔現(xiàn)在已經(jīng)歸檔,不再維護,而新版的文檔里一張圖都沒有。許多引用這張圖來講解異步編程的博客也都沒能給出足夠容易的表達。所以這事兒只好我自己想明白之后來做了。

先來設想一個場景:老板想吃薯條并聽音樂,于是對三個員工說:「我要吃薯條,我要聽音樂」。可是三個人剛聽到「我要吃薯條」就立刻轉(zhuǎn)身離開,去計劃如何做薯條了。任務內(nèi)容包括:買土豆,可能需要 10 分鐘時間;準備廚具 ,這個很快就搞定;土豆買回來削皮清洗切絲下鍋炸;完后就可以送回給老板了。直到這個過程結(jié)束,三個員工才會去關(guān)心老板想要聽音樂的事情。

用程序來表示這個過程,代碼如下:

private void 老板()
{
    老板_我要吃薯條();
    老板_我要聽音樂();
}

private void 老板_我要吃薯條()
{
    員工.執(zhí)行(買土豆);
    員工.執(zhí)行(準備廚具);
    var 薯條 = 員工.執(zhí)行(處理土豆炸薯條);
    老板.吃(薯條);
}

private void 老板_我要聽音樂()
{
    員工.執(zhí)行(打開留聲機播放唱片);
    老板.聽(愛的禮贊);
}

這個過程的問題在于,做薯條這個事情進行了 30 分鐘,老板除了干等著什么都干不了,員工們也不聽使喚,直到薯條炸出來了,才去解決聽音樂的問題。

現(xiàn)在對這個過程進行異步改造,代碼如下:

private void 老板()
{
    老板_我要吃薯條();
    老板_我要聽音樂();
}

private async void 老板_我要吃薯條()
{
    var 買土豆 = Task.Run(() =>
    {
        // 這是一個返回值類型為 Task<土豆> 的匿名方法
        return 員工.執(zhí)行(買土豆);
    });
    員工.執(zhí)行(準備廚具);
    var 土豆 = await 買土豆;
    var 薯條 = 員工.執(zhí)行(處理土豆炸薯條);
    老板.吃(薯條);
}

private void 老板_我要聽音樂()
{
    員工.執(zhí)行(打開留聲機播放唱片);
    老板.聽(愛的禮贊);
}

現(xiàn)在這個故事的劇情就變成了:老板說「我要吃薯條」,于是三個員工去開始做薯條。三人覺得這個過程可以分開來做,第一個人去買土豆;第二個人在原地等著買土豆的人回來,一起炸薯條,在買來之前,這個人先行準備廚具;第三個人回去報告老板,薯條正在制作請稍等,還有沒有別的事情要做。老板說「我要聽音樂」,于是第三個人立馬去放音樂給老板聽。如此一來,老板手下的員工還聽使喚,并且不必非要等到薯條做好才能聽到音樂了。當薯條做好的時候,員工把做好的薯條呈送給老板,老板來吃薯條。

個人覺得這個例子直觀多了:

  • 一個員工去買土豆:即新生成一個 Task,利用線程池在后臺執(zhí)行。
  • 一個員工等在原地:await 關(guān)鍵字,等待這個買土豆 Task 的執(zhí)行結(jié)果,當土豆買回來了,也即 Task 執(zhí)行結(jié)束后,繼續(xù)后面炸薯條的工作;在買來之前,也即程序遇到 await 關(guān)鍵字之前,這個人先做準備廚具的工作。薯條炸出來之后,交給老板。
  • 一個員工回去報告老板:await 關(guān)鍵詞,立刻向調(diào)用方返回控制權(quán),也即,在薯條還沒做好,甚至是土豆還沒買來的時候,就將程序的控制權(quán)交回給老板,執(zhí)行老板的下一條語句,即 老板_我要聽音樂();

值得一提的是,這個例子中,我們沒有單獨編寫異步實現(xiàn)方法,而是直接在異步方法內(nèi)部定義了一個 Task,并在后面 await 之。

老板能手下有多少員工?理論上,一大群員工即線程池,由 CLR(Common Language Runtime,公共語言運行時) 根據(jù)計算機性能進行分配和管理,所以實際上可以同時執(zhí)行的異步方法比三個員工這個案例多得多。

不知道這樣講述下來,關(guān)于異步的執(zhí)行順序是否會更加清晰而具體。

0x03 一些問題的解答

  • 異步一定能提高效率嗎?

    不一定。異步本質(zhì)上還是多線程,只是簡化多線程的實現(xiàn)方式。至于使用多線程編程時能否提高程序執(zhí)行效率,取決于 CPU 核心數(shù),計算任務的復雜度以及該項任務本身是否適合被切分為并行計算模塊。過于頻繁地將不適合并行計算的任務拆分成異步編程中去,反而會導致密集計算性能的下降,因為此時線程池會疲于應對大量的線程調(diào)度操作。

  • 有 async 一定要有 await 嗎?

    不一定。在標記為 async 的方法中,不必須出現(xiàn) await 關(guān)鍵字,只是若沒有 await 關(guān)鍵字,這個方法不是真正意義上的異步方法,它會與普通方法一樣是同步執(zhí)行的。編譯器不會報錯,但會給出提示。

    相反,若要使用 await 關(guān)鍵字,則必須在方法簽名中包含 async 關(guān)鍵字。否則 await 將被當做標識符,而不能被當做一個關(guān)鍵字來處理。也就是說,當一個方法的簽名中不包含 async 關(guān)鍵字時,你甚至可以在方法體中把 await 作為變量名。但這種操作是極其不推薦的,很容易造成誤導。

  • 異步方法的名稱一定要以「Async」為結(jié)尾嗎?

    不一定。這只是習慣問題,就跟微軟推薦所有的自定義特性后面都以「Attributes」為結(jié)尾一樣,這不是必須的,只是如果大家都這樣做了,理解起來更加方便一些。具體情況取決于不同場合下的規(guī)范要求。

  • 使用 Task 并且 Run 了之后就實現(xiàn)異步了嗎?

    不是,這只是進行了一次多線程操作,后面的語句還是同步執(zhí)行的。直到遇見 await 關(guān)鍵字,隨著控制權(quán)的返回,才真正能實現(xiàn)異步。

  • 異步是線程安全的嗎?

    理論上是的,這也是為什么異步編程模型能夠極大地簡化傳統(tǒng)多線程操作所帶來的各種問題的一大原因。盡管 await 所指的對象運行在其他線程上,但其后的語句還是會在原始線程上被執(zhí)行。更深層次地說,后續(xù)的語句實際上是使用 Task 的 ContinueWith 方法來實現(xiàn)的。所以我們大可以放心的在異步方法中修改諸如 UI 元素等由主線程管理的資源。

    但是,異步編程模型只是簡化了這個過程,而不能替代我們解決具體的數(shù)據(jù)同步問題。如果在 await 之后有對其他共享資源的訪問,而在 await 獲取執(zhí)行結(jié)果之前,這些資源已經(jīng)被其他線程修改,那么 await 后續(xù)語句執(zhí)行時所面對的數(shù)據(jù)內(nèi)容將是不可預測的。

  • 異步一定是返回控制權(quán)與等待結(jié)果同時進行的嗎?

    第一時間返回控制權(quán)是一定的,而等待與否要看任務執(zhí)行的狀態(tài)。當程序遇到 await 關(guān)鍵字時,如果 Task 所指代的對象以極快的速度完成,那么異步方法內(nèi)部就會以同步執(zhí)行的方式繼續(xù)向后執(zhí)行 await 語句后面的操作,不會產(chǎn)生等待。只有當 Task 沒有執(zhí)行完畢時,才會進行等待。流程如下圖所示。

Await handing via the awaitable pattern

這里有個問題,即 await 要求 Task 一定要有執(zhí)行結(jié)果,如果只是聲明了一個 Task,但是沒有運行,await 是不會繼續(xù)向后進行的。雖然編譯器不會報錯,但是程序會永無休止地等下去。例如下面的代碼:

private asycn void doSthNoResponse()
{
  var t = new Task(() => {});
  await t;    // 永無休止地等下去
}

新人更容易犯的是造成程序鎖死(Deadlock)的事故,例如如下代碼:

private void doSthDeadlock()
{
  var t = new Task<string>(() => { return String.Empty; });
  label1.Text = t.Result; // 鎖死
}

當然了,這屬于關(guān)于 Task 使用的問題,這里不做詳述了,有興趣可以參考 Stephen Cleary 的博客文章《Don't Block on Async Code》。

  • 異步的循環(huán)嵌套?

    這曾經(jīng)是個困擾我的問題,尤其是我在看了微軟給的異步方法執(zhí)行流程圖之后:一旦在某個節(jié)點使用了 async 關(guān)鍵字,那么它內(nèi)部一定要包含一個異步方法,而它本身又是一個異步方法,于是乎就要一層一層又一層的都變成異步方法才行。

    實際上不必,如同前文所述。但是 Jon Skeet 在《C# in Depth》中強調(diào),如果有可能的話,要秉持著「將異步進行到底」的精神,一路異步下去,這樣有助于保持程序的穩(wěn)健性。但愿我理解得是對的,或者他只是想說養(yǎng)成這樣的習慣,可以給軟件開發(fā)帶來更多的益處。

  • 用多個放在一起的 await 等待多個任務?

    不行。每一個 await 在放回控制權(quán)給調(diào)用者的同時,都是阻塞執(zhí)行結(jié)果的,不能夠通過多個并列的 await 語句來同時等待多個結(jié)果。例如如下代碼:

    private async void doSth()
    {
      var t1 = new Task.Run(() => {......});
      var t2 = new Task.Run(() => {......});
      var t3 = new Task.Run(() => {......});
      await t1;
      await t2;
      await t3;
    }
    

    這段代碼的意思其實基本無異于

    private async void doSth()
    {
      var t1 = new Task.Run(() => {......});
      var t2 = new Task.Run(() => {......});
      var t3 = new Task.Run(() => {......});
      t1.Wait();
      t2.Wait();
      t3.Wait();
    }
    

    前者只是比后者多了控制權(quán)的返回罷了。因此,即便 t2 和 t3 在 t1 之前運行結(jié)束,程序也會一直等到 t1 運行結(jié)束才會繼續(xù)。正確的做法是使用 Task 的 WhenAll 或者 WhenAny 方法處理執(zhí)行結(jié)束的后續(xù)事宜。

0x04 本文沒有介紹的內(nèi)容

篇幅和水平所限,以下這些內(nèi)容沒有涉及,或者所談很淺。如有需要了解詳細內(nèi)容的,應當參閱更加專業(yè)的書籍、文檔和博客。

  • 異常處理

    本文所介紹的主體是基于任務的異步編程模式(TAP,Task-based Asynchronous Pattern),因此異常處理也是與 Task 對象高度相關(guān)聯(lián)的,這需要專門去了解 Task 相關(guān)的異常捕獲和處理方法,以及在異步編程下的處理方法。雖然我學習、閱讀過這部分內(nèi)容,但是覺得體系極為龐雜,由于水平有限,就暫時不寫上來了。畢竟本文的主旨是幫助初學者快速建立對異步編程模型的認識。

  • 異步的實現(xiàn)和編譯原理

    嗯,沒有這部分是因為,我沒太看懂……

  • 很多細節(jié)

    這篇文章總之是過于籠統(tǒng)了些,很多細節(jié)上需要注意的小問題可能無暇涉及。比如說,Task 以及 Task<T> 是最為推薦的異步實現(xiàn)方法返回值類型,因為可以對任務執(zhí)行的狀態(tài)和異常進行合理的控制。而 void 類型的異步方法則多用于事件處理器(Event Handler)上。


這本是我自己想要整理的學習筆記,怕幾個月之后自己又忘了,所以寫得稍微啰嗦了些,希望是讓小白也能輕松看懂的水平。希望能給有需求人士帶來一定的幫助。


參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容

  • 什么是異步編程 什么是異步編程呢?舉個簡單的例子: 上面這段代碼中,Main方法中的代碼是按照自上而下的順序執(zhí)行的...
    雪飛鴻閱讀 4,429評論 0 12
  • 1. 引言 最近在學習Abp框架,發(fā)現(xiàn)Abp框架的很多Api都提供了同步異步兩種寫法。異步編程說起來,大家可能都會...
    圣杰閱讀 4,113評論 8 77
  • 異步編程對JavaScript語言太重要。Javascript語言的執(zhí)行環(huán)境是“單線程”的,如果沒有異步編程,根本...
    呼呼哥閱讀 7,333評論 5 22
  • 關(guān)于C# async/await的一些說明 下文以個人對async/await的理解為基礎進行一些說明。 1、自定...
    Huisama閱讀 21,602評論 2 58
  • 一.非阻塞和異步 借用知乎用戶嚴肅的回答在此總結(jié)下,同步和異步是針對消息通信機制,同步代表一個client發(fā)出一個...
    Daniel_adu閱讀 1,855評論 0 8