前言
如標題所訴,本文主要是解決是什么,怎么用的問題,然后會說明為什么這么用。因為我發現很多萌新都會對之類的問題產生疑惑,包括我最初的我,網絡上的博客大多知識零散,剛開始看相關博文的時候,就這樣
。然后博文也不一定正確,又變成這樣
,當然我的觀點也不一定正確,所以,以免誤導萌新,有疑問,歡迎提出!有錯誤,歡迎指正!
一、首先看幾個問題
- 多線程程序比單線程程序效率高?
- 什么是IO密集型程序?計算密集型程序又是什么?
- IO密集型程序和計算密集型程序與多線程和單線程有什么關系?
- 同步、異步、阻塞、非阻塞又是啥?
- 多線程與異步到底是啥關系?
二、先了解操作系統的演變過程
在早期的計算機時代,那時候硬件寶貴,什么硬件資源都要精打細算,操作系統剛開始也非常簡單,之后一步步發展,大概經過了以下階段:
1.單道批處理系統(一次只能處理一個任務,任務排隊,串行執行,無交互能力,系統資源利用率不高)
2.多道批處理系統(外存中有一個后備任務隊列,一次取多個任務到內存,當任務處於IO時,會切換到其他任務,無交互能力,系統資源利用率較高)
3.分時操作系統(每個任務都能盡可能的得到調度,有交互能力,系統資源利用率比多道批處理系統略低,因為要花很多時間在調度任務上,例如Windows)
4.實時操作系統(每個任務都能實時調度,有交互能力)
下面簡單的了解一下單道批處理系統和多道批處理系統。
單道批處理系統處理任務過程
可以看到在執行任務的過程中,有一部分時間被IO(比如等待用戶輸入,讀取文件內容)占用了,而CPU無事可做,浪費系統資源,為什么IO不需要CPU
參與呢?因為有相應的IO設備,相當於是一個小型的計算機,在有了DMA(直接存儲器)后,IO設備訪問內存也不需要CPU參與了,大大減少了中斷次數,
CPU基本上只要發出IO指令,通知IO設備工作,然后就可以做其他事情了,等待IO處理完,會發出一個中斷,然后CPU接着處理未完成的任務。為了提高C
PU利用率,於是便有了多道批處理操作系統。
多道批處理系統處理任務過程
相比於單道批處理系統,可以看到,在完成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時間片,就算有些許線程處於活動狀態,但是基本上分配到的時間片很少(看下圖),而
且由於活動線程數其實很少,所以調度開銷也很小,所以單線程效率比較高,如果開多線程來執行這個計算密集型程序,情況就不一樣了,因為是同等
優先級,所以會發生頻繁的線程調度,產生額外開銷,當計算任務很長時,這個就非常明顯了,雖然對於整體時間來說不明顯。
那如果是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
最后