以生活例子說明單執行緒與多執行緒

薰衣草的旋律發表於2016-05-09

閱讀目錄

1. 程式設計的目標

在我看來單從程式的角度來看,一個好的程式的目標應該是效能與使用者體驗的平衡。當然一個程式是否能夠滿足使用者的需求暫且不談,這是業務層面的問題,我們僅僅討論程式本身。圍繞兩點來展開,效能與使用者體驗。

效能:高效能的程式應該可以等同於CPU的利用率,CPU的利用率越高(一直在工作,沒有閒下來的時候),程式的效能越高。
體驗:這裡的體驗不只是介面多麼漂亮,功能多麼順手,這裡的體驗指程式的響應速度,響應速度越快,使用者體驗越好。

下面我們就這兩點進行各種模型的討論。

2. 單執行緒多工無阻塞

以生活中食堂打飯的場景作為比喻,假設有這樣的場景,小A,小B,小C 在視窗依次排隊打飯。 假設視窗負責打飯的阿姨打一個菜需要耗時1秒。如果小A需要2個菜,小B需要3個菜,小C需要2個菜。如下:

阿姨(CPU):打一個菜需要1秒
小A:2個菜
小B:3個菜
小C:2個菜

那麼在這種模型下將所有服務做完阿姨需要耗時 2 + 3 + 2 = 7秒
阿姨 = CPU
小A,小B,小C = 任務(這裡是以任務為概念,表示需要做一些事情)
這種模型下CPU是滿負荷不間斷運轉的,沒有空閒,使用者體驗還不錯。這種程式中每個任務的耗時都比較小,是非常理想的狀態,一般情況下基本不太可能存在。

3. 單執行緒多工IO阻塞

將上面的場景稍微做改動:
阿姨:打一個菜需要1秒
小A:2個菜,但是忘記帶錢了,要找同學送過來,估計需要等5分鐘可以送到(可以理解為磁碟IO)
小B:3個菜
小C:2個菜

這種情況下小A這裡發生了阻塞,實際上小A這裡耗費了5分鐘也就是 300秒+ 2個菜的時間,也就是302秒,而CPU則空閒了300秒,實際上工作2秒。

所有服務做完花費 302 + 3 + 2 = 307秒  CPU實際工作7秒,等待300秒。 極大浪費了CPU的時鐘週期。 使用者體驗很差,因為小A阻塞的時候,後面的所有人都等著,而實際上此時CPU空閒。所以單執行緒中不要有阻塞出現。

4. 單執行緒多工非同步IO

還是上面的模型,加入一個角色:值日生小哥,他負責事先詢問每一個人是否帶錢了,如果帶錢了則允許打菜,否則把錢準備好了再說。

<1> 值日生小哥問小A準備好打菜了嗎,小A說忘帶錢了,值日生小哥說,你把錢準備好了再說,小A開始準備(需要300秒,從此刻開始記時)。
<2> 值日生小哥問小B準備好打菜了嗎,小B說可以了,阿姨服務小B,耗時3秒
<3> 值日生小哥問小C準備好打菜了嗎,小C說可以了,阿姨服務小C,耗時2秒
<4> 值日生小哥問小A準備好了沒有,小A說還要等一會,阿姨由於沒有人過來服務,處於空閒狀態
<5> 300秒之後,小A準備好了,阿姨服務小A,耗時2秒

整個過程做完耗時 300 + 2 = 302秒  CPU工作7秒,空閒295秒

值日生小哥相當於select模型中的select功能,負責輪詢任務是否可以工作,如果可以則直接工作,否則繼續輪詢。在小A阻塞的300秒裡面,阿姨(CPU)沒有傻等,而是在服務後面的人,也就是小B和小C,所以這裡與模型3不同的是,這裡有5秒CPU是工作的。 如果打飯的人越多,這種模型CPU的利用率越高,例如如果有小D,小E,小F…… 等需要服務,CPU可以在小A阻塞的300秒期間內繼續服務其他人。實際上值日生小哥輪詢也會耗時,這個耗時是很少的,幾乎可以忽略不計,但是如果任務非常多,這個輪詢還是會影響效能的,但是epoll模型已經不使用輪詢的方式,相當於A,B,C會主動跟值日生小哥報告,說我準備好了,可以直接打菜了。

這種模式下使用者體驗好,CPU利用率高(任務越多利用率越高)

5. 單執行緒多工,有耗時計算

回到最開始的模型,如下:
阿姨:打一個菜需要1秒
小A:200個菜
小B:3個菜
小C:2個菜

順序做完所有任務,需要耗時 200 + 3 + 2 = 205秒, CPU無空閒,但是使用者體驗卻不是很好,因為顯然後面的 B,C 需要等待小A 200秒的時間,這種情況下是沒有IO阻塞的,但是任務A本身太耗CPU了,所以說如果單執行緒中出現了耗時的操作,一定會影響體驗(IO操作或者是耗時的計算都屬於耗時的操作,都會導致阻塞,但是這兩種導致阻塞的性質是不一樣的)。在所有的單執行緒模型中都不允許出現阻塞的情況,如果出現,那麼使用者體驗是極差的,例如在UI程式設計中(QT,C# Winform)是不允許在UI執行緒中做耗時的操作的,否則會導致UI介面無響應。 編寫Nodejs程式的時候,我們所寫的程式碼實際上是在一個執行緒中執行的,所以也不允許有阻塞的操作(當然整個Nodejs框架實現非同步,一定不止一個執行緒)。

出現阻塞的情況一般有2種,一種是IO阻塞,例如典型的如磁碟操作,這種情況下的阻塞會導致CPU空閒等待(當然現代作業系統中如果IO阻塞,作業系統一定會將導致IO阻塞的執行緒掛起)。這種阻塞的情況,可以通過非同步IO的方法避免,這樣就避免程式中僅有的單執行緒被作業系統掛起。另一種情況下是確實有非常多的計算操作,例如一個複雜的加密演算法,確實需要消耗非常多的CPU時間,這種情況下CPU並不是空閒的,反而是全負荷工作的。這種CPU密集的工作不適合放在單執行緒中,雖然CPU的利用率很高,但是使用者體驗並不是很好。這種情況下使用多執行緒反而會更好,例如如果3個任務,每個任務都在一個執行緒中,也就是有3個執行緒,A任務在ThreadA中,B任務在ThreadB中,C任務在ThreadC中,那麼即使A任務的計算量比較大,B,C兩個任務所在的執行緒也不必等待A任務完成之後再工作,他們也有機會得到排程,這是由作業系統來完成的。這樣就不會因為某一個任務計算量大,而導致阻塞其他任務而影響體驗了。

6. 多執行緒程式

我們將上面的模型改造成多執行緒的模型是怎樣的呢,我們在模型5的基礎上新增一個角色,管理員大叔(作業系統的角色):
阿姨:打一個菜需要1秒
小A:200個菜
小B:3個菜
小C:2個菜

加入管理員大叔之後變成這樣的了,小A打兩個菜之後,大叔說,你打的菜太多了,不能因為你要打200個菜,讓後面的同學都沒有機會打菜,你打兩個菜之後等一會,讓後面的同學也有機會。

大叔讓小B打兩個菜,然後讓小C打兩個菜(小C完成),然後再讓小A打兩個菜(完成之後小A總共就有4個菜了),再讓小B打1個菜(此時小B總共打3個菜,完成),然後小A打剩下的196個菜。

CPU的利用率:很高,阿姨在不斷的工作

使用者體驗:不錯,即使小A要打200個菜,小B,小C也有機會。 當然如果小A說我是幫校長打菜,要快一點(執行緒優先順序高),那也只能先把小A服務完

總耗時:   200 + 3 + 2 + (大叔指揮安排所消耗的時間,包括從小C切換回小A的時候,大叔要知道小A上次打的菜是哪兩個,這次應該接著打什麼菜,這相當於執行緒上下文切換的開銷以及執行緒環境的儲存與恢復),所以並不是執行緒越多越好,執行緒非常多的時候大叔估計會焦頭爛額吧,要記住這麼狀態,切換來切換去也耗時間。

這種模型下實際上是將小A的耗時任務,分成多份去執行而不是集中執行,所以小A要完成他的任務,可能需要更多的時間(期間他也需要等別人,阿姨不會一直為他一個人服務,但是阿姨為他服務的時間是沒有變化的),這種其實有點以時間換取使用者體驗(小B和小C的體驗,小A的體驗可能就不會那麼好了,但是小A本來也非常耗時,所以多等一會是不是也沒關係)

那麼IO阻塞和CPU計算耗時阻塞這兩者有什麼區別呢? 區別在於IO阻塞是不使用CPU的,而CPU計算耗時導致的阻塞是會使用CPU的。 例如上面的例子中,小A說忘記帶錢了需要同學送錢,於是小A等著同學送錢過來,這個過程中阿姨並沒有為小A提供服務,這個過程中為小A提供服務的是他的同學(送錢過來),實際上小A的同學相當於現代計算機系統中的DMA(直接記憶體操作),小A同學送錢的過程相當於DMA從磁碟讀取資料到記憶體的過程,這個過程基本不需要CPU干預。

當然在DMA技術還沒有出現的年代,從磁碟讀取檔案也是需要CPU傳送指令去讀取的,也就是說需要CPU的計算,應用到這裡的場景中,就是阿姨親自跑一趟幫小A把錢拿過來。

7. 多CPU

多CPU是一個更加複雜的問題,多CPU如何排程? 小A在第一個視窗打兩個菜,又跑到第二個視窗打兩個菜這種情況如何處理。小A在第一個視窗,小B在第二個視窗他們要同一個菜,但是這個菜只夠一個人,那麼兩個視窗阿姨如何分配這種需求(實際上應該是由作業系統也就是管理員大叔來決定如何分配,也就是多核下的執行緒同步與互斥)?

多核CPU情況下,多執行緒的排程,互斥,鎖與同步相對來講更加複雜,多核情況下是真正的並行,同一時刻有多個執行緒在同時執行,他們的競爭怎麼處理,多個CPU之間如何同步(多CPU之間的快取狀態一致性)等等一系列的問題。

8. 多執行緒與多程式

上面描述的多執行緒實際上是討論的是多執行緒的排程問題,這裡我們說一說多執行緒與多程式與資源的分配問題。什麼意思呢,一群人(多個執行緒)在一個桌子(程式)上吃飯,他們會涉及到一些問題,比如多個人可能會夾一個菜(競爭),A和B同時看到盤子裡面有一塊肉,同時伸出筷子去夾,A先夾走,B遲了一點伸到盤子的時候已經沒了,只能縮回來(臨界資源,互斥),有一個點心需要用饃夾肉一起吃。A夾了肉,B夾了饃,A需要B的饃,B需要A的肉,他們僵持不下誰都不讓步(死鎖)。

多執行緒之間的資源共享是非常方便的,因為他們共用程式的資源空間(在一個桌子上),但是需要注意一系列的問題,競爭,死鎖,同步等。如果在旁邊再開一個桌子(程式)。 那麼桌子之間講話,遞東西又不方便(程式間通訊),而開一個桌子的開銷比在一個桌子上多加一個人的開銷要大。另外一個桌子上的人數不可能無限制增加,桌子的容量有限也坐不下這麼多人(程式的執行緒控制程式碼是有限制的)。一個桌子壞了不會影響到另一個桌子上面人的就餐情況(程式間相互獨立,一個程式崩潰不會影響另一個),而一個桌子上的某人喝掛了需要送醫院,估計這一桌人都要散了(執行緒掛掉會導致整個程式也掛掉)。所以多執行緒與多程式是各有優缺點,不能一概而論。

說明:多執行緒桌子的比喻受到知乎使用者[pansz]的啟發,但是該比喻似乎說明不了執行緒同步的情況。

9. 總結

單執行緒程式:適合IO非同步,不能阻塞,不能有大量耗CPU的計算。典型如Nodejs,還有一些網路程式

多執行緒程式:適合CPU密集型程式

相關文章