1. 程式人生 > >【Linux/OS/Network】執行緒以及其與程序區別

【Linux/OS/Network】執行緒以及其與程序區別

執行緒建立、終止、等待、同步、有關分離相關程式碼見:

前言:

程序在各自獨立的地址空間中執行,程序之間共享資料需要用mmap或者程序間通訊機制,有些情況需要在一個程序中同時執行多個控制流程,這時候執行緒就派上了用場,比如:實現一個圖形介面的下載軟體,一方面需要和使用者互動,等待和處理使用者的滑鼠鍵盤事件,另一方面又需要同時下載多個檔案,等待和處理從多個網路主機發來的資料,這些任務都需要一個“等待-處理”的迴圈,可以用多執行緒實現,一個執行緒專門負責與使用者互動,另外幾個執行緒每個執行緒負責和一個網路主機通訊。

以前也許你聽說過,main函式和訊號處理函式是同一個程序地址空間中的多個控制流程,多執行緒也是如此,但是比訊號處理函式更加靈活,訊號處理函式的控制流程只是在訊號遞達時產生,在處理完訊號之後就結束,而多執行緒的控制流程可以長期並存,作業系統會在各執行緒之間排程和切換,就像在 多個程序之間排程和切換一樣。由於同一程序的多個執行緒共享同一地址空間,因此Text Segment、Data Segment都是共享的,如果定義⼀一個函式,在各執行緒中都可以呼叫,如果定義一 個全域性變數,在各執行緒中都可以訪問到,除此之外,各執行緒還共享以下程序資源和環境:


1. 檔案描述符表
2. 每種訊號的處理方式(SIG_IGN、SIG_DFL或者⾃自定義的訊號處理函式)
3. 當前工作目錄
4. 使用者id和組id
但有些資源是每個執行緒各有一份的:
1. 執行緒id
2. 上下文,包括各種暫存器的值、程式計數器和棧指標
3. 棧空間
4. errno變數
5. 訊號遮蔽字
6. 排程優先順序

一、建立執行緒

這裡寫圖片描述

返回值:成功返回0,失敗返回錯誤號。以前學過的系統函式都是成功返回0,失敗返回-1,而錯誤號儲存在全域性變數errno中,而pthread庫的函式都是通過返回值返回錯誤號,雖然每個執行緒也都有一個errno,但這是為了相容其它函式介面而提供的,pthread庫本身並不使用它,通過返回值返回錯誤碼更加清晰。在一個執行緒中呼叫pthread_create()建立新的執行緒後,當前執行緒從pthread_create()返回繼續往下執行,而新的執行緒所執行的程式碼由我們傳給pthread_create的函式指標start_routine決 定。start_routine函式接收一個引數,是通過pthread_create的arg引數傳遞給它的,該引數的型別為void ,這個指標按什麼型別解釋由呼叫者自己定義。start_routine的返回值型別也是void

,這個指標的含義同樣由呼叫者自己定義。start_routine返回時,這個執行緒就退出了,其它執行緒可以呼叫pthread_join得到start_routine的返回值,類似於父程序呼叫wait(2)得到子程序的退出狀態,稍後詳細介紹pthread_join。attr引數表示執行緒屬性,傳NULL給attr引數,表示執行緒屬性取預設值

pthread_create成功返回後,新建立的執行緒的id被填寫到thread引數所指向的記憶體單元。我們知道程序id的型別是pid_t,每個程序的id在整個系統中是唯一的,呼叫getpid(2)可以獲得當前程序的id,是一個正整數值。執行緒id的型別是thread_t,它只在當前程序中保證是唯一的,在不同的系統中thread_t這個型別有不同的實現,它可能是一個整數值,也可能是一個結構體,也可能是一個地址,所以不能簡單地當成整數用printf列印,呼叫pthread_self(3)可以獲得當前執行緒的id。

看看例項:

這裡寫圖片描述

執行結果:

這裡寫圖片描述

可知在Linux上,thread_t型別是一個地址值,屬於同一程序的多個執行緒調⽤用getpid(2)可以得到相同的程序號,而呼叫pthread_self(3)得到的執行緒號各不相同。由於pthread_create的錯誤碼不儲存在errno中,因此不能直接用perror(3)列印錯誤資訊,可以先用strerror(3)把錯誤碼轉換成錯誤資訊再列印。
如果任意一個執行緒呼叫了exit或_exit,則整個程序的所有執行緒都終止,由於從main函式return也相當於呼叫exit,為了防止新建立的執行緒還沒有得到執行就終止,我們在main函式return之前延時1秒,這只是一種權宜之計,即使主執行緒等待1秒,核心也不一定會排程新建立的執行緒執行。

二、終止執行緒

如果需要只終止某個執行緒而不終止整個程序,可以有三種方法:
1. 從執行緒函式return。這種⽅方法對主執行緒不適用,從main函式return相當於呼叫exit。
2. 一個執行緒可以呼叫pthread_cancel終止同一程序中的另一個執行緒。
3. 執行緒可以呼叫pthread_exit終止自己。

用pthread_cancel終止一個執行緒分同步和非同步兩種情況,比較複雜,不打算詳細介紹。

下面介紹pthread_exit和pthread_join的用法。

這裡寫圖片描述

retval是void *型別,和執行緒函式返回值的用法一樣,其它執行緒可以呼叫pthread_join獲得這個指標。
需要注意,pthread_exit或者return返回的指標所指向的記憶體單元必須是全域性的或者是用malloc分配的,不能線上程函式的棧上分配,因為當其它執行緒得到這個返回指標時執行緒函式已經退出了。

三、執行緒等待

這裡寫圖片描述

返回值:成功返回0,失敗返回錯誤號
呼叫該函式的執行緒將掛起等待,直到id為thread的執行緒終止。thread執行緒以不同的方法終止,通過pthread_join得到的終止狀態是不同的,總結如下:
1. 如果thread執行緒通過return返回,value_ptr所指向的單元裡存放的是thread執行緒函式的返回值
2. 如果thread執行緒被別的執行緒呼叫pthread_cancel異常終掉,value_ptr所指向的單元裡存放的是常數PTHREAD_CANCELED(-1)。
3. 如果thread執行緒是自己呼叫pthread_exit終止的,value_ptr所指向的單元存放的是傳給pthread_exit的引數。 如果對thread執行緒的終止狀態不感興趣,可以傳NULL給value_ptr引數。

這裡寫圖片描述
這裡寫圖片描述

執行結果

這裡寫圖片描述

可見在Linux的pthread庫中常數PTHREAD_CANCELED的值是-1。可以在標頭檔案pthread.h中找到它

這裡寫圖片描述

一般情況下,執行緒終止後,其終止狀態一直保留到其它執行緒呼叫pthread_join獲取它的狀態為止。 但是執行緒也可以被置為detach 狀態,這樣的執行緒一旦終止就立刻回收它佔用的所有資源,而不保留終止狀態。不能對一個已經處於detach狀態的執行緒呼叫pthread_join,這樣的呼叫將返回EINVAL。 對一個尚未detach的執行緒調⽤用pthread_join或pthread_detach都可以把該執行緒置為detach狀態,也就是說,不能對同一執行緒呼叫兩次pthread_join,或者如果已經對一個執行緒呼叫 了pthread_detach就不能再呼叫pthread_join了。

四、分離執行緒

這裡寫圖片描述

返回值:成功返回0,失敗返回錯誤號。

在任何一個時間點上,執行緒是可結合的(joinable)或者是分離的(detached)。一個可結合的執行緒能夠被其他執行緒收回其資源和殺死。在被其他執行緒回收之前,它的儲存器資源(例如棧)是不釋放的。相反,一個分離的執行緒是不能被其他執行緒回收或殺死的,它的儲存器資源在它終止時由系統自動釋放。

預設情況下,執行緒被建立成可結合的。為了避免儲存器洩漏,每個可結合線程都應該要麼被顯示地回收,即呼叫pthread_join;要麼通過呼叫pthread_detach函式被分離。如果一個可結合線程結束執行但沒有被join,則它的狀態類似於程序中的Zombie Process,即還有一部分資源沒有被回收,所以建立執行緒者應該呼叫pthread_join來等待執行緒執行結束,並可得到執行緒的退出程式碼,回收其資源。由於呼叫pthread_join後,如果該執行緒沒有執行結束,呼叫者會被阻塞,在有些情況下我們並不希望如此。例如,在Web伺服器中當主執行緒為每個新來的連線請求建立一個子執行緒進行處理的時候,主執行緒並不希望因為呼叫pthread_join而阻塞(因為還要繼續處理之後到來的連線請求),這時可以在子執行緒中加入程式碼
pthread_detach(pthread_self())
或者父執行緒呼叫
pthread_detach(thread_id)(非阻塞,可立即返回)
這將該子執行緒的狀態設定為分離的(detached),如此一來,該執行緒執行結束後會自動釋放所有資源。

五、執行緒同步

A. mutex (互斥量)

多個執行緒同時訪問共享資料時可能會衝突,比如:兩個執行緒都要把某個全域性變數增加1,這個操作在某平臺需要三條指令完成:
1. 從記憶體讀變數值到暫存器
2. 暫存器的值加1
3. 將暫存器的值寫回記憶體
假設兩個執行緒在多處理器平臺上同時執行這三條指令,則可能導致下圖所示的結果,最後變數只加了一次而非兩次。

這裡寫圖片描述

我們在“讀取變數的值”和“把變數的新值儲存回去”這兩步操作之間插入一個printf呼叫,它會執行write系統呼叫進核心,為核心排程別的執行緒執行提供了一個很好的時機。我們在一個迴圈中重複上述操作幾千次,就會觀察到訪問衝突的現象。

看個例子 :

這裡寫圖片描述
這裡寫圖片描述

執行結果:

這裡寫圖片描述
再執行一次:
這裡寫圖片描述
再執行一次:
這裡寫圖片描述

你會發現,建立兩個執行緒,各自把counter增加5000次,正常情況下最後counter應該等於10000,但事實上每次執行該程式的結果都不一樣,有時候數到5000多,有時候數到6000多。

對於多執行緒的程式,訪問衝突的問題是很普遍的,解決的辦法是引⼊入互斥鎖(Mutex,Mutual Exclusive Lock),獲得鎖的執行緒可以完成“讀-修改-寫”的操作,然後釋放鎖給其它執行緒,沒有獲得鎖的執行緒只能等待而不能訪問共享資料,這樣“讀-修改-寫”三步操作組成一個原子操作,要麼都執行,要麼都不執行,不會執行到中間被打斷,也不會在其它處理器上並行做這個操作。

Mutex用pthread_mutex_t型別的變量表示,可以這樣初始化和銷燬:

這裡寫圖片描述

返回值:成功返回0,失敗返回錯誤號。
pthread_mutex_init函式對Mutex做初始化,引數attr設定Mutex的屬性,如果attr為NULL則表示預設屬性;用pthread_mutex_init函式初始化的Mutex可以用pthread_mutex_destroy銷燬。如果Mutex變數是靜態分配的(全域性變數 或static變數),也可以用巨集定義PTHREAD_MUTEX_INITIALIZER來初始化,相當於用pthread_mutex_init初始化並且attr引數為NULL。Mutex的加鎖和解鎖操作可以用下列函式:

這裡寫圖片描述

返回值:成功返回0,失敗返回錯誤號。
一個執行緒可以呼叫pthread_mutex_lock獲得Mutex,如果這時另一個執行緒已經呼叫pthread_mutex_lock獲得了該Mutex,則當前執行緒需要掛起等待,直到另一個執行緒呼叫pthread_mutex_unlock釋放Mutex,當前執行緒被喚醒,才能獲得該Mutex並繼續執行。如果一個執行緒既想獲得鎖,又不想掛起等待,可以調⽤用pthread_mutex_trylock,如果Mutex已經被另一個執行緒獲得,這個函式會失敗返回EBUSY,而不會使執行緒掛起等待。

此時,我們可以解決上面g_count的問題

這裡寫圖片描述

執行結果

這裡寫圖片描述

也許還有讀者好奇,“掛起等待”和“喚醒等待執行緒”的操作如何實現?每個Mutex有一個等待佇列,一 個執行緒要在Mutex上掛起等待,首先在把自己加入等待佇列中,然後置執行緒狀態為睡眠,然後呼叫排程器函式切換到別的執行緒。一個執行緒要喚醒等待佇列中的其它執行緒,只需從等待佇列中取出一 項,把它的狀態從睡眠改為就緒,加入就緒佇列,那麼下次排程器函式執行時就有可能切換到被喚 醒的執行緒。

一般情況下,如果同一個執行緒先後兩次呼叫lock,在第二次呼叫時,由於鎖已經被佔用,該執行緒會掛起等待別的執行緒釋放鎖,然而鎖正是被自己佔用著的,該執行緒又被掛起而沒有機會釋放鎖,因此就永遠處於掛起等待狀態了,這叫做死鎖(Deadlock)。另一種典型的死鎖情形是這樣:執行緒A獲得了鎖1,執行緒B獲得了鎖2,這時執行緒A呼叫lock試圖獲得鎖2,結果是需要掛起等待執行緒B釋放鎖2,而這時執行緒B也呼叫lock試圖獲得鎖1,結果是需要掛起等待執行緒A釋放鎖1,於是執行緒A和B都永遠處於掛起狀態了。不難想象,如果涉及到更多的執行緒和更多的鎖,有沒有可能死鎖的問題將會變得複雜和難以判斷。

寫程式時應該儘量避免同時獲得多個鎖,如果一定有必要這麼做,則有一個原則:如果所有執行緒在需要多個鎖時都按相同的先後順序(常見的是按Mutex變數的地址順序)獲得鎖,則不會出現死 鎖。比如:一個程式中用到鎖1、鎖2、鎖3,它們所對應的Mutex變數的地址是鎖1<鎖2<鎖3,那麼所有執行緒在需要同時獲得2個或3個鎖時都應該按鎖1、鎖2、鎖3的順序獲得。如果要為所有的鎖確定一個先後順序比較困難,則應該儘量使用pthread_mutex_trylock呼叫代替pthread_mutex_lock 呼叫,以免死鎖。

B. Condition Variable(條件變數)

執行緒間的同步還有這樣一種情況:執行緒A需要等某個條件成立才能繼續往下執行,現在這個條件不成立,執行緒A就阻塞等待,而執行緒B在執行過程中使這個條件成立了,就喚醒執行緒A繼續執行。 在pthread庫中通過條件變數(Condition Variable)來阻塞等待一個條件,或者喚醒等待這個條件的執行緒。Condition Variable⽤用pthread_cond_t型別的變量表示,可以這樣初始化和銷燬:

這裡寫圖片描述

返回值:成功返回0,失敗返回錯誤號。
和Mutex的初始化和銷燬類似,pthread_cond_init函式初始化一個Condition Variable,attr引數 為NULL則表示預設屬性,pthread_cond_destroy函式銷燬一個Condition Variable。如果Condition Variable是靜態分配的,也可以用巨集定義PTHEAD_COND_INITIALIZER初始化,相當於用pthread_cond_init函式初始化並且attr引數為NULL。Condition Variable的操作可以用下列函式

這裡寫圖片描述

返回值:成功返回0,失敗返回錯誤號。
可見,一個Condition Variable總是和一個Mutex搭配使用的。一個執行緒可以呼叫pthread_cond_wait在一個Condition Variable上阻塞等待,這個函式做以下三步操作:
1. 釋放Mutex
2. 阻塞等待
3. 當被喚醒時,重新獲得Mutex並返回

pthread_cond_timedwait函式還有一個額外的引數可以設定等待超時,如果到達了abstime所指定的時刻仍然沒有別的執行緒來喚醒當前執行緒,就返回ETIMEDOUT。一個執行緒可以呼叫pthread_cond_signal喚醒在某個Condition Variable上等待的另一個執行緒,也可以呼叫pthread_cond_broadcast喚醒在這個Condition Variable上等待的所有執行緒。

下面的程式演示了一個生產者-消費者的例子,生產者生產一個結構體串在連結串列的表頭上,消費者從表頭取走結構體。

這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述
這裡寫圖片描述

執行結果:

pthread_cond_wait()之後沒有資料進行消費的時候,阻塞等待,直到pthread_cond_signal()提醒有資料可消費的時候,才能喚醒該執行緒。

這裡寫圖片描述

上面的例子只是兩個執行緒之間的生產和消費,讀者可以在主函式中多建立幾個執行緒就可以實現多執行緒之間進行資料的消費。

六、總結執行緒與程序的區別:

在Linux環境下程序與執行緒的區別:
(1)程序是分配系統資源(CPU時間、記憶體)的基本單位
(2)執行緒是排程的基本單位,是程序中的一個執行流,程序被分配出來是被執行緒使用,同一程序內的多個執行緒共享程序的資源(執行緒之間交換資訊的成本不大、強調共享的同時也需要私有化,即每個執行緒必須有自己的排程資訊和優先順序、硬體上下文、獨立的執行時棧(儲存變數等))
(3)程序不存在,執行緒也不存在;但是執行緒不存在,程序可以存在,即執行緒是程序的一部分