1. 程式人生 > >Linux程序與執行緒的區別——不要太經典

Linux程序與執行緒的區別——不要太經典

Linux程序與執行緒的區別

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

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

0.首先,簡要了解一下程序和執行緒。對於作業系統而言,程序是核心之核心,整個現代作業系統的根本,就是以程序為單位在執行任務。系統的管理架構也是基於程序層面的。在按下電源鍵之後,計算機就開始了複雜的啟動過程,此處有一個經典問題:當按下電源鍵之後,計算機如何把自己由靜止啟動起來的?本文不討論系統啟動過程,請讀者自行科普。作業系統啟動的過程簡直可以描述為上帝創造萬物的過程,期初沒有世界,但是有上帝,是上帝創造了世界,之後創造了萬物,然後再創造了人,然後塑造了人的七情六慾,再然後人類社會開始遵循自然規律繁衍生息。。。作業系統啟動程序的階段就相當於上帝造人的階段。本文討論的全部內容都是“上帝造人”之後的事情。第一個被創造出來的程序是0號程序,這個程序在作業系統層面是不可見的,但它存在著。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()就可以了。

執行緒池

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

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

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

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

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

<<<本文完結>>>