轉載_Linux程式與執行緒的區別

不一樣的天空w發表於2018-11-22

程式與執行緒的區別,早已經成為了經典問題。自執行緒概念誕生起,關於這個問題的討論就沒有停止過。無論是初級程式設計師,還是資深專家,都應該考慮過這個問題,只是層次角度不同罷了。一般程式設計師而言,搞清楚二者的概念,在工作實際中去運用成為了焦點。而資深工程師則在考慮系統層面如何實現兩種技術及其各自的效能和實現代價。以至於到今天, Linux 核心還在持續更新完善 ( 關於程式和執行緒的實現模組也是核心完善的任務之一 )

本文將以一個從事 Linux 平臺系統開發的程式設計師角度描述這個經典問題。本文素材全部來源於工作實踐經驗與知識規整,若有疏漏或不正之處,敬請讀者慷慨指出。


0. 首先,簡要了解一下程式和執行緒。對於作業系統而言,程式是核心之核心,整個現代作業系統的根本,就是以程式為單位在執行任務。系統的管理架構也是基於程式層面的。在按下電源鍵之後,計算機就開始了複雜的啟動過程,此處有一個經典問題:當按下電源鍵之後,計算機如何把自己由靜止啟動起來的?本文不討論系統啟動過程,請讀者自行科普。作業系統啟動的過程簡直可以描述為上帝創造萬物的過程,期初沒有世界,但是有上帝,是上帝創造了世界,之後創造了萬物,然後再創造了人,然後塑造了人的七情六慾,再然後人類社會開始遵循自然規律繁衍生息。。。作業系統啟動程式的階段就相當於上帝造人的階段。本文討論的全部內容都是“上帝造人”之後的事情。第一個被創造出來的程式是 號程式,這個程式在作業系統層面是不可見的,但它存在著。 號程式完成了作業系統的功能載入與初期設定,然後它創造了 1 號程式 (init) ,這個 1 號程式就是作業系統的“耶穌”。 1 號程式是上帝派來管理整個作業系統的,所以在用 pstree 檢視程式樹可知, 1 號程式位於樹根。再之後,系統的很多管理程式都以程式身份被 1 號程式創造出來,還創造了與人類溝通的橋樑—— shell 。從那之後,人類可以跟作業系統進行交流,可以編寫程式,可以執行任務。。。

而這一切,都是基於程式的。每一個任務 ( 程式 ) 被建立時,系統會為他分配儲存空間等必要資源,然後在核心管理區為該程式建立管理節點,以便後來控制和排程該任務的執行。

程式真正進入執行階段,還需要獲得 CPU 的使用權,這一切都是作業系統掌管著,也就是所謂的排程,在各種條件滿足 ( 資源與 CPU 使用權均獲得 ) 的情況下,啟動程式的執行過程。

CPU 而外,一個很重要的資源就是儲存器了,系統會為每個程式分配獨有的儲存空間,當然包括它特別需要的別的資源,比如寫入時外部裝置是可使用狀態等等。有了上面的引入,我們可以對程式做一個簡要的總結:

程式,是 計算機中的程式關於某資料集合上的一次執行活動,是系統進行資源分配和排程的基本單位,是作業系統結構的基礎。它的執行需要系統分配資源建立實體之後,才能進行。

隨著技術發展,在執行一些細小任務時,本身無需分配單獨資源時 ( 多個任務共享同一組資源即可,比如所有子程式共享父程式的資源 ) ,程式的實現機制依然會繁瑣的將資源分割,這樣造成浪費,而且還消耗時間。後來就有了專門的多工技術被創造出來——執行緒。

執行緒的特點就是在不需要獨立資源的情況下就可以執行。如此一來會極大節省資源開銷,以及處理時間。

 

1. 好了,前面的一段文字是簡要引入兩個名詞,即程式和執行緒。本文討論目標是解釋清楚程式和執行緒的區別,關於二者的技術實現,請讀者查閱相關資料。

下面我們開始重點討論本文核心了。從下面幾個方面闡述程式和執行緒的區別。

1). 二者的相同點

2). 實現方式的差異

3). 多工程式設計模式的區別

4). 實體間 ( 程式間,執行緒間,進執行緒間 ) 通訊方式的不同

5). 控制方式的異同

6). 資源管理方式的異同

7). 個體間輩分關係的迥異

8). 程式池與執行緒池的技術實現差別

 

接下來我們就逐個進行解釋。

1). 二者的相同點

無論是程式還是執行緒,對於程式設計師而言,都是用來實現多工併發的技術手段。二者都可以獨立排程,因此在多工環境下,功能上並無差異。並且二者都具有各自的實體,是系統獨立管理的物件個體。所以在系統層面,都可以透過技術手段實現二者的控制。而且二者所具有的狀態都非常相似。而且,在多工程式中, 子程式 ( 子執行緒 ) 的排程一般與父程式 ( 父執行緒 ) 平等競爭

其實在 Linux 核心 2.4 版以前,執行緒的實現和管理方式就是完全按照程式方式實現的。在 2.6 版核心以後才有了單獨的執行緒實現。

 

 

2). 實現方式的差異

程式是資源分配的基本單位,執行緒是排程的基本單位。

這句經典名言已流傳數十年,各種作業系統教材都可見此描述。確實如此,這就是二者的顯著區別。讀者請注意“基本”二字。相信有讀者看到前半句的時候就在心裡思考,“程式豈不是不能排程?”,非也!程式和執行緒都可以被排程,否則多程式程式該如何執行呢!

只是,執行緒是更小的可以排程的單位,也就是說,只要達到執行緒的水平就可以被排程了,程式自然可以被排程。它強調的是分配資源時的物件必須是程式,不會給一個執行緒單獨分配系統管理的資源。若要執行一個任務,想要獲得資源,最起碼得有程式,其他子任務可以以執行緒身份執行,資源共享就行了。

     簡而言之, 程式的個體間是完全獨立的,而執行緒間是彼此依存的。多程式環境中,任何一個程式的終止,不會影響到其他程式。而多執行緒環境中,父執行緒終止,全部子執行緒被迫終止 ( 沒有了資源 ) 。而任何一個子執行緒終止一般不會影響其他執行緒,除非子執行緒執行了 exit() 系統呼叫。任何一個子執行緒執行 exit() ,全部執行緒同時滅亡。

其實,也沒有人寫出只有執行緒而沒有程式的程式。多執行緒程式中至少有一個主執行緒,而這個主執行緒其實就是有 main 函式的程式。它是整個程式的程式,所有執行緒都是它的子執行緒。我們通常把具有多執行緒的主程式稱之為主執行緒。

從系統實現角度講,程式的實現是呼叫 fork 系統呼叫:

pid_t fork(void);

執行緒的實現是呼叫 clone 系統呼叫:

int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...

/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */

);

其中, fork() 是將父程式的全部資源複製給了子程式。而執行緒的 clone 只是複製了一小部分必要的資源。在呼叫 clone 時可以透過引數控制要複製的物件。可以說, fork 實現的是 clone 的加強完整版。當然,後來作業系統還進一步最佳化 fork 實現——寫時複製技術。在子程式需要複製資源 ( 比如子程式執行寫入動作更改父程式記憶體空間 ) 時才複製,否則建立子程式時先不復制。

實際中,編寫多程式程式時採用 fork 建立子程式實體。而建立執行緒時並不採用 clone 系統呼叫,而是採用執行緒庫函式。常用執行緒庫有 Linux-Native 執行緒庫和 POSIX 執行緒庫。其中應用最為廣泛的是 POSIX 執行緒庫。因此讀者在多執行緒程式中看到的是 pthread_create 而非 clone

我們知道,庫是建立在作業系統層面上的功能集合,因而它的功能都是作業系統提供的。由此可知,執行緒庫的內部很可能實現了 clone 的呼叫。不管是程式還是執行緒的實體,都是作業系統上執行的實體。

     最後,我們說一下 vfork()  。這也是一個系統呼叫,用來建立一個新的程式。它建立的程式並不複製父程式的資源空間,而是共享,也就說實際上 vfork 實現的是一個接近執行緒的實體,只是以程式方式來管理它。並且, vfork() 的子程式與父程式的執行時間是確定的:子程式“ 結束 ”後父程式才執行。請讀者注意“結束”二字。並非子程式完成退出之意,而是子程式返回時。一般採用 vfork() 的子程式,都會緊接著執行 execv 啟動一個全新的程式,該程式的程式空間與父程式完全獨立不相干,所以不需要複製父程式資源空間。此時, execv 返回時父程式就認為子程式“結束”了,自己開始執行。實際上子程式繼續在一個完全獨立的空間執行著。 舉個例子,比如在一個聊天程式中,彈出了一個影片播放器。你說影片播放器要繼承你的聊天程式的程式空間的資源幹嘛?莫非影片播放器想要窺探你的聊天隱私不成? 懂了吧!

 

3). 多工程式設計模式的區別

由於程式間是獨立的,所以在設計多程式程式時,需要做到資源獨立管理時就有了天然優勢,而執行緒就顯得麻煩多了。比如多工的 TCP 程式的服務端,父程式執行 accept() 一個客戶端連線請求之後會返回一個新建立的連線的描述符 DES ,此時如果 fork() 一個子程式,將 DES 帶入到子程式空間去處理該連線的請求,父程式繼續 accept 等待別的客戶端連線請求,這樣設計非常簡練,而且父程式可以用同一變數 (val) 儲存 accept() 的返回值,因為子程式會複製 val 到自己空間,父程式再覆蓋此前的值不影響子程式工作。但是如果換成多執行緒,父執行緒就不能複用一個變數 val 多次執行 accept() 了。因為子執行緒沒有複製 val 的儲存空間,而是使用父執行緒的,如果子執行緒在讀取 val 時父執行緒接受了另一個客戶端請求覆蓋了該值,則子執行緒無法繼續處理上一次的連線任務了。改進的辦法是子執行緒立馬複製 val 的值在自己的棧區,但父執行緒必須保證子執行緒複製動作完成之後再執行新的 accept() 。但這執行起來並不簡單,因為子執行緒與父執行緒的排程是獨立的,父執行緒無法知道子執行緒何時複製完畢。這又得發生執行緒間通訊,子執行緒複製完成後主動通知父執行緒。這樣一來父執行緒的處理動作必然不能連貫,比起多程式環境,父執行緒顯得效率有所下降。

PS 這裡引述一個知名的面試問題:多程式的 TCP 服務端,能否互換 fork() accept() 的位置?請讀者自行思考

關於資源不獨立,看似是個缺點,但在有的情況下就成了優點。多程式環境間完全獨立,要實現通訊的話就得采用程式間的通訊方式,它們通常都是耗時間的。而執行緒則不用任何手段資料就是共享的。當然多個子執行緒在同時執行寫入操作時需要實現互斥,否則資料就寫“髒”了。

 

4). 實體間 ( 程式間,執行緒間,進執行緒間 ) 通訊方式的不同

程式間的通訊方式有這樣幾種:

A. 共享記憶體      B. 訊息佇列      C. 訊號量      D. 有名管道      E. 無名管道      F. 訊號

G. 檔案          H.socket

執行緒間的通訊方式上述程式間的方式都可沿用,且還有自己獨特的幾種:

A. 互斥量         B. 自旋鎖        C. 條件變數    D. 讀寫鎖         E. 執行緒訊號

G. 全域性變數

值得注意的是,執行緒間通訊用的訊號不能採用程式間的訊號,因為訊號是基於程式為單位的,而執行緒是共屬於同一程式空間的。故而要採用執行緒訊號。

綜上,程式間通訊手段有 8 種。執行緒間通訊手段有 13 種。

而且,程式間採用的通訊方式要麼需要 切換核心上下文 ,要麼要與外設訪問 ( 有名管道,檔案 ) 。所以速度會比較慢。而執行緒採用自己特有的通訊方式的話,基本都在自己的程式空間內完成,不存在切換,所以通訊速度會較快。也就是說, 程式間與執行緒間分別採用的通訊方式,除了種類的區別外,還有速度上的區別

另外, 程式與執行緒之間穿插通訊的方式,除 訊號 以外 其他程式間通訊方式都可採用
    
執行緒有核心態執行緒與使用者級執行緒,相關知識請參看我的另一篇博文《 Linux 執行緒的實質》。

 

5). 控制方式的異同

程式與執行緒的身份標示 ID 管理方式不一樣,程式的 ID pid_t 型別,實際為一個 int 型的變數 ( 也就是說是有限的 )

/usr/include/unistd.h:260:typedef __pid_t     pid_t;

/usr/include/bits/types.h:126:# define __STD_TYPE       typedef

/usr/include/bits/types.h:142:__STD_TYPE    __PID_T_TYPE      __pid_t;

/usr/include/bits/typesizes.h:53:#define __PID_T_TYPE      __S32_TYPE

/usr/include/bits/types.h:100:#define     __S32_TYPE         int

在全系統中,程式 ID 是唯一標識,對於程式的管理都是透過 PID 來實現的。每建立一個程式,核心去中就會建立一個結構體來儲存該程式的全部資訊:

注:下述程式碼來自   Linux 核心 3.18.1

 

include/linux/sched.h:1235:struct task_struct {

         volatile long state;      /* -1 unrunnable, 0 runnable, >0 stopped */

         void *stack;

...

         pid_t pid;

         pid_t tgid;

...

};

每一個儲存程式資訊的節點也都儲存著自己的 PID 。需要管理該程式時就透過這個 ID 來實現 ( 比如傳送訊號 ) 。當子程式結束要回收時 ( 子程式呼叫 exit() 退出或程式碼執行完 ) ,需要透過 wait() 系統呼叫來進行,未回收的消亡程式會成為殭屍程式,其程式實體已經不復存在,但會虛佔 PID 資源,因此回收是有必要的。

執行緒的 ID 是一個 long 型變數:

/usr/include/bits/pthreadtypes.h:60:typedef unsigned long int pthread_t;

它的範圍大得多,管理方式也不一樣。執行緒 ID 一般在本程式空間內作用就可以了,當然系統在管理執行緒時也需要記錄其資訊。其方式是,在核心建立一個核心態執行緒與之對應,也就是說每一個使用者建立的執行緒都有一個核心態執行緒對應。但這種對應關係不是一對一,而是多對一的關係,也就是一個核心態執行緒可以對應著多個使用者級執行緒。還是請讀者參看《 Linux 執行緒的實質》普及相關概念。此處貼出 blog 地址:

http://my.oschina.net/cnyinlinux/blog/367910

對於執行緒而言,若要主動終止需要呼叫 pthread_exit()  ,主執行緒需要呼叫 pthread_join() 來回收 ( 前提是該執行緒沒有被 detached ,相關概念請查閱執行緒的“分離屬性” ) 。像線傳送執行緒訊號也是透過執行緒 ID 實現的。

 

6). 資源管理方式的異同

程式本身是資源分配的基本單位,因而它的資源都是獨立的,如果有多程式間的共享資源,就要用到程式間的通訊方式了,比如共享記憶體。共享資料就放在共享記憶體去,大家都可以訪問,為保證資料寫入的安全,加上訊號量一同使用。一般而言,共享記憶體都是和訊號量一起使用。訊息佇列則不同,由於訊息的收發是原子操作,因而自動實現了互斥,單獨使用就是安全的。

執行緒間要使用共享資源不需要用共享記憶體,直接使用全域性變數即可,或者 malloc() 動態申請記憶體。顯得方便直接。而且互斥使用的是同一程式空間內的互斥量,所以效率上也有優勢。

實際中,為了使程式內資源充分規整,也都採用共享記憶體來儲存核心資料。不管程式還是執行緒,都採用這種方式。原因之一就是,共享記憶體是脫離程式的資源,如果 程式發生意外終止的話,共享記憶體可以獨立存在不會被回收 ( 是否回收由使用者程式設計實現 ) 。程式的空間在程式崩潰的那一刻也被系統回收了。雖然有 coredump 機制,但也只能是有限的彌補。共享記憶體在程式 down 之後還完整儲存,這樣可以拿來分析程式的故障原因。同時, 執行的寶貴資料沒有丟失,程式重啟之後還能繼續處理之前未完成的任務 ,這也是採用共享記憶體的又一大好處。

總結之,程式間的通訊方式都是脫離於程式本身存在的,是全系統都可見的。這樣一來,程式的單點故障並不會損毀資料,當然這不一定全是優點。比如, 程式崩潰前對訊號量加鎖,崩潰後重啟,然後再次進入執行狀態,此時直接進行加鎖,可能造成死鎖,程式再也無法繼續運轉 。再比如, 共享記憶體是全系統可見的,如果你的程式資源被他人誤讀誤寫,後果肯定也是你不想要的。 所以,各有利弊,關鍵在於程式設計時如何考量,技術上如何規避。這說起來又是程式設計技巧和經驗的事情了。

 

7). 個體間輩分關係的迥異

程式的備份關係森嚴,在父程式沒有結束前,所有的子程式都尊從父子關係,也就是說 A 建立了 B ,則 A B 是父子關係, B 又建立了 C ,則 B C 也是父子關係, A C 構成爺孫關係,也就是說 C A 的孫子程式。在系統上使用 pstree 命令列印程式樹,可以清晰看到備份關係。

多執行緒間的關係沒有那麼嚴格,不管是父執行緒還是子執行緒建立了新的執行緒,都是共享父執行緒的資源,所以,都可以說是父執行緒的子執行緒,也就是隻存在一個父執行緒,其餘執行緒都是父執行緒的子執行緒。

 

8). 程式池與執行緒池的技術實現差別

我們都知道,程式和執行緒的建立時需要時間的,並且系統所能承受的程式和執行緒數也是有上限的,這樣一來,如果業務在執行中需要動態建立子程式或執行緒時,系統無法承受不能立即建立的話,必然影響業務。綜上,聰明的程式設計師發明了一種新方法——池。

在程式啟動時,就預先建立一些子程式或執行緒,這樣在需要用時直接使喚。這就是老人口中的“ 多生孩子多種樹 ”。程式才開始執行,沒有那麼多的服務請求,必然大量的程式或執行緒空閒,這時候一般讓他們“冬眠”,這樣不耗資源,要不然一大堆孩子的口食也是個負擔啊。對於程式和執行緒而言,方式是不一樣的。另外,當你有了任務,要分配給那些孩子的時候,手段也不一樣。下面就分別來解說。

程式池

首先建立了一批程式,就得管理,也就是你得分開儲存程式 ID ,可以用陣列,也可用連結串列。建議用陣列,這樣可以實現常數內找到某個執行緒,而且既然做了程式池,就預先估計好了生產多少程式合適,一般也不會再動態延展。就算要動態延展,也能預估範圍,提前做一個足夠大的陣列。不為別的,就是為了快速響應。本來錯程式池的目的也是為了效率。

接下來就要讓閒置程式冬眠了,可以讓他們 pause() 掛起,也可用訊號量掛起,還可以用 IPC 阻塞 ,方法很多,分析各自優缺點根據實際情況採用就是了。

然後是分配任務了,當你有任務的時候就要讓他幹活了。喚醒了程式,讓它從哪兒開始幹呢?肯定得用到程式間通訊了,比如訊號喚醒它,然後讓它在預先指定的地方去讀取任務,可以用 函式指標 來實現,要讓它幹什麼,就在約定的地方設定程式碼段指標。這也只是 告訴了它怎麼幹,還沒說幹什麼 ( 資料條件 ) ,再 透過共享記憶體把要處理的資料設定好 ,這也子程式就知道怎麼做了。幹完之後再來一次程式間通訊然後自己繼續冬眠,父程式就知道孩子幹完了,收割成果。

最後結束時回收子程式,向各程式傳送訊號喚醒,改變啟用狀態讓其主動結束,然後逐個 wait() 就可以了。

執行緒池

執行緒池的思想與上述類似,只是它更為輕量級,所以排程起來不用等待額外的資源。

要讓執行緒阻塞,用 條件變數 就是了,需要幹活的時候父執行緒改變條件,子執行緒就被啟用。

執行緒間通訊方式就不用贅述了,不用繁瑣的通訊就能達成,比起程式間效率要高一些。

執行緒幹完之後自己再改變條件,這樣父執行緒也就知道該收割成果了。

整個程式結束時,逐個改變條件並改變啟用狀態讓子執行緒結束,最後逐個回收即可。

<<< 本文完結 >>>

本文轉自:http://my.oschina.net/cnyinlinux/blog/422207


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31397003/viewspace-2221080/,如需轉載,請註明出處,否則將追究法律責任。

相關文章