async/await到底該怎么用?如何理解多線程與異步之間的關系?


前言

        如標題所訴,本文主要是解決是什么,怎么用的問題,然后會說明為什么這么用。因為我發現很多萌新都會對之類的問題產生疑惑,包括我最初的我,網絡上的博客大多知識零散,剛開始看相關博文的時候,就這樣。然后博文也不一定正確,又變成這樣當然我的觀點也不一定正確,所以,以免誤導萌新,有疑問,歡迎提出!有錯誤,歡迎指正!

一、首先看幾個問題

    • 多線程程序比單線程程序效率高?
    • 什么是IO密集型程序?計算密集型程序又是什么?
    • IO密集型程序和計算密集型程序與多線程和單線程有什么關系?
    • 同步、異步、阻塞、非阻塞又是啥?
    • 多線程與異步到底是啥關系?

二、先了解操作系統的演變過程

在早期的計算機時代,那時候硬件寶貴,什么硬件資源都要精打細算,操作系統剛開始也非常簡單,之后一步步發展,大概經過了以下階段:

1.單道批處理系統(一次只能處理一個任務,任務排隊,串行執行,無交互能力,系統資源利用率不高)

2.多道批處理系統(外存中有一個后備任務隊列,一次取多個任務到內存,當任務處於IO時,會切換到其他任務,無交互能力,系統資源利用率較高)

3.分時操作系統(每個任務都能盡可能的得到調度,有交互能力,系統資源利用率比多道批處理系統略低,因為要花很多時間在調度任務上,例如Windows)

4.實時操作系統(每個任務都能實時調度,有交互能力)


下面簡單的了解一下單道批處理系統和多道批處理系統。

單道批處理系統處理任務過程

image

image

      可以看到在執行任務的過程中,有一部分時間被IO(比如等待用戶輸入,讀取文件內容)占用了,而CPU無事可做,浪費系統資源,為什么IO不需要CPU

參與呢?因為有相應的IO設備,相當於是一個小型的計算機,在有了DMA(直接存儲器)后,IO設備訪問內存也不需要CPU參與了,大大減少了中斷次數,

CPU基本上只要發出IO指令,通知IO設備工作,然后就可以做其他事情了,等待IO處理完,會發出一個中斷,然后CPU接着處理未完成的任務。為了提高C

PU利用率,於是便有了多道批處理操作系統。

多道批處理系統處理任務過程

image

        相比於單道批處理系統,可以看到,在完成T1、T2、T3任務過程中,實際只花了CPU10S,其中額外的調度時間花了1S,總共11S。CPU利用率大大提高,但是還是有一個致

命缺點——無法交互。只有在IO時或者任務已經完成的情況下,才會發生調度。這個對用戶體驗就非常不好了,只要其中一個程序產生死循環或者什么原因,就會導致后面

的任務無法調度,想要恢復執行,必須使用萬能的“重啟大法”,並且找BUG還不能實時調試,必須重啟計算器再重新啟動程序,這就很難受。所以分時操作系統就出

現了。

分時操作系統處理任務過程


        在多道批處理系統過程中,是沒有分時的這個概念的,它的唯一目的是提高CPU的利用率,不讓CPU空轉。但這個用戶體驗就非常的差了,為了提高用戶

體驗,換句話說就是為了讓每個任務都能得到CPU的眷顧,CPU就發表了以下觀點:


CPU:以前你們老是說我不照顧你們,現在我決定了,我給你們每個人固定的時間,然后輪流照顧你們,這樣行不?(時間片輪轉調度算法)

眾任務:好好好!

……

過了許久之后,

任務A:我有急事!我有急事!呼叫呼叫!此時CPU還在服務其他任務,按照規則,任務A還要輪轉999次才能到達A,於是任務A卒。

過了N久,CPU過來了,發現了這個情況,於是它又改變了規則,按照某種優先級算法,然后進行輪轉,例如高優先級任務優先低優先級任務輪轉等等。

這個時候,才很好的解決了用戶體驗差的問題。


        拿上面的場景,對應現代操作系統。任務(或者說作業),可以抽象成線程。例如windows,一個基於線程優先級搶占式的分時操作系統。現在可以回答

如下幾個問題的一部分。

什么是IO密集型程序?什么是計算密集型程序?

        IO密集型就是指一個程序的執行時間中,IO操作占了絕大多數時間,比如Web服務器,涉及了大量IO操作,HTTP請求,數據庫讀取,模板渲染引擎

讀取模板文件(通過緩存可以解決)等IO操作,實際要CPU參與計算的部分很少,反之就是計算密集型程序,例如視頻編碼輸出,加密解密、科學計算等等。

多線程效率比單線程效率高?

        不討論多核或者多CPU的情況下,對於計算密集型程序來講,單線程效率一般是最高的,因為它不需要進行線程調度,就不會產生調度開銷,調度開銷

包括調度算法的執行,線程上下文的切換(堆棧寄存器和相關寄存器以及程序計數器的還原)等等,你可能會問,不是還有其他線程嗎?的確是有,但

一般來講,大多數線程是處於掛起狀態的,而掛起的線程是不會分到CPU時間片,就算有些許線程處於活動狀態,但是基本上分配到的時間片很少(看下圖),而

且由於活動線程數其實很少,所以調度開銷也很小,所以單線程效率比較高,如果開多線程來執行這個計算密集型程序,情況就不一樣了,因為是同等

優先級,所以會發生頻繁的線程調度,產生額外開銷,當計算任務很長時,這個就非常明顯了,雖然對於整體時間來說不明顯。

image

那如果是IO密集型呢???這就跟異步有關系了。

三、阻塞、非阻塞、同步、異步

這里的A和B的主體是不確定的,並且 需要注意,這里至少有兩個角色

同步:A和B有順序,A完成工作之后B才能繼續工作。

異步:A和B無順序,A和B可以同時工作

阻塞和非阻塞是有一定語境的,它是專門針對線程來說的,它指的是狀態

阻塞:意思是指線程被掛起,不能做其他任務

非阻塞:意思是指線程未被掛起,處於就緒或運行狀態,可以做其他任務

有常說的以下四種組合:

同步阻塞、同步非阻塞(不存在)、異步阻塞(不存在)、異步非阻塞


現在假設一個場景:

線程A在執行代碼的過程中,其中執行到了一個ReadFile()函數,這個任務最后交由IO設備B完成,很明顯,線程A可以繼續執行其他代碼,在IO設備B完成之后,線程A繼續執行依賴於ReadFile()的代碼塊。很明顯,他們之間是異步的,也就是CPU於IO設備之間是異步的,因為他們能同時工作。那么代碼看起來就可能像下面這樣

result = ReadFile();//首先發起異步請求,以便IO設備能盡早處理

flag = true;

if(result.IsComplied && flag){做依賴於讀文件的操作();flag = false;}

吃西瓜();

if(result.IsComplied && flag){做依賴於讀文件的操作();flag = false;}

打War3();



或者這樣

result = ReadFile();//首先發起異步請求,以便IO設備能盡早處理

吃西瓜();

打War3();

while(!result.IsComplied);

做依賴於讀文件的操作();


或者這樣

ReadFile(callBack:做依賴於讀文件的操作);//首先發起異步請求,以便IO設備能盡早處理

吃西瓜();

打War3();


可以看到:

在第一種模式下,寫代碼復雜丑陋;

在第二種模式下,代碼相對於比較優雅,但可能需要輪詢,忙等,浪費CPU時間。

第三種模式,好像非常好,但其實是回調層數不夠深,也就是所謂的回調地獄,雖然有辦法可以把嵌套式改成平坦式,例如then.then.then的形式,但是代碼還是不夠優雅,所以出現了async/await形式,也就是號稱的用寫同步代碼的方式寫異步代碼,不理解的看下面就理解了。


為什么不夠優雅?究其原因是因為它們之間是異步的,那有沒有一種辦法能讓它們之間同步進行呢?只要IO設備沒完成,線程A就不能執行代碼,不能工作,待IO設備完成之后,線程繼續執行代碼,繼續工作,那么代碼看起來就像這樣。


吃西瓜();

打War3();

result = ReadFileSync();

做依賴於讀文件的操作();


那重點來了!!!怎么做到呢?阻塞。在執行到ReadFileSync()時,把線程阻塞。也就是說操作系統其實是用阻塞模擬同步,所以說同步代表着阻塞。也就是同步阻塞的由來。那么同步非阻塞呢?沒有!按照同步定義,A完成工作之后,B才能繼續工作,如果是非阻塞,那么就不叫同步了,因為A和B可以同時工作,所以非阻塞只能搭配異步。這個模式的缺點就是,會導致創建許多線程。 ,在早期Web服務器中,針對每個請求,創建一個線程,請求結束之后,線程銷毀,因為創建線程和銷毀線程代價非常大,所以發明了線程池,雖然有了線程池,但如果線程執行了同步IO,那么還是會導致線程阻塞,從而依然會導致該線程不能及時回收利用,從而又會導致創建許多線程,所以我們要盡量寫異步非阻塞代碼,但是寫異步非阻塞代碼又不夠優雅,怎么辦呢?怎么辦呢?怎么辦呢?這時候主角async/await閃亮登場。

吃西瓜();

打War3();

result = await ReadFileAsync();

做依賴於讀文件的操作();


看見沒有!!!這個和同步版的是不是差不多?和同步版達到的效果是一樣的,但不會導致線程被阻塞,可以讓線程及時去處理其他任務。另外,由於CPU和IO設備是異步的,所以應盡早發起異步請求,正確的做法應該是下面這樣的

result = ReadFileAsync();//首先發起異步請求,以便IO設備能盡早處理

吃西瓜();

打War3();

await result ;

做依賴於讀文件的操作();


那么阻塞、非阻塞、同步、異步是啥的問題也解決了。

四、async/await怎么工作?

        在這里簡述一下,在C#中,每個使用了await的異步方法,都會被編譯器魔改成了一個狀態機的實現,使用了n個await關鍵字,就會有n+1個狀態,利用這個狀態機,便可以實現異步函數的掛起和恢復(有沒有感覺和線程很像?),以便異步任務完成之后,回到剛開始使用await的地方,然后繼續執行。在具體一點點,每當一個線程執行await的地方,這個線程就會回到被調用的地方,如果被調用的地方也使用了await,然后繼續上一步驟,最終會回到線程池,如果有其他任務就繼續執行其他任務,否則會阻塞,這沒關系,因為已經沒事做了。等待某個時候,異步任務完成,觸發狀態機調用MoveNext(),然后回到調用這個await的地方,繼續往下執行,需要注意的是,這時候執行線程不一定是剛開始調用await的線程了,這是由任務調度器決定的,在.NET中,默認的任務調度器使用了默認的線程池作為任務的消費者,也可以自定義任務調度器,讓一個線程處理所有任務。

五、理解Task

        在C#中,async/await是與Task協同工作的,而異步函數就是一個Task,Task與async/await配合可以被掛起和恢復,是不是有點線程的味道?它其實就是用戶模式下的“線程”,也就是協程,線程需要調度,協程同樣需要調度,不同的是,線程是搶占式的,被動的。協程需要主動讓出執行權,也就是非搶占式的,通過await便可以讓出執行權,所以協程可以充分利用線程資源,執行用戶代碼,Task便可以理解為一個協程,最后,Task最終是要由線程來執行的,可以是一個線程,也可以是多個線程,這個是由任務調度器決定的。由於默認的任務調度器使用的是線程池中的線程來執行,所以await前后執行線程很可能不一樣。要想使用自定義的任務調度器,通過創建TaskFactory實例指定任務調度器,或者創建Task實例,並在Start(TaskSheduler t)傳入指定的任務調度器,通過Task.Run()方法或者不傳參的Start()實例方法默認都使用的是默認的任務調度器。


      我寫了一個單線程同步阻塞、多線程同步非阻塞、單線程異步非阻塞、多線程異步非阻塞的簡單的Web服務器作為示例,並有大量注釋。還有一個反編譯異步方法的C#實現,和一個自定義的任務調度器,並有一個簡單的發起並發Http請求的控制台程序,有興趣的可以研究下,可以加深萌新對async/await的理解,最后還有開頭的一些疑問沒解決,理解這幾個例子你就能知道答案了。

倉庫地址:http://gitlab.fyfhk.cn/hekun3344/simplehttpserver

最后

覺得有收獲的

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM