Linux 基礎 - 11. 線程

Linux 基礎 - 11. 線程

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-SOupoXp3-1589783553270)(https://linotes.imliloli.com/assets/images/bio-photo.jpg)]

Hawk Zhang

\11. 線程11.1 線程11.1.1 線程的概念11.1.2 LINUX 的線程實現11.1.3 線程的特點11.1.4 用戶線程與內核線程11.2 進程與線程的區別11.2.1 進程與線程的相同點11.2.2 實現方式的差異11.2.3 多任務程序設計模式的區別11.2.4 實體間通信方式的不同11.2.5 控制方式的區別11.2.6 資源管理方式的區別11.2.7 進程池與線程池的技術實現差別11.3 多任務11.3.1 多任務操作系統11.3.2 多線程11.3.3 多進程11.3.4 操作系統分類

11.1 線程

11.1.1 線程的概念

Thread

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

進程是一個程序運行的時候被 CPU 抽象出來的,一個程序運行後被抽象爲一個進程。但是線程是從一個進程裏面分割出來的,由於 CPU 處理進程的時候是採用 時間片輪轉 的方式,所以要把一個大個進程給分割成多個線程。

  • 實體:線程是進程的一個實體,是 CPU 調度和分派的基本單位,它是比進程更小的、能獨立運行的基本單位,是 真正的執行實體
  • 與進程關係:爲了讓進程完成一定的工作,進程必須至少包含一個線程。一條線程指的是進程中一個 單一順序的控制流,線程是屬於進程的,線程運行在進程空間內。當進程退出時,該進程所產生的線程都會被強制退出並清除。
  • 資源:線程在不需要獨立資源的情況下就可以運行。如此一來會極大節省資源開銷,以及處理時間。線程自己基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧),但是它可與同屬一個進程的其他的線程共享進程所擁有的全部資源。對於一些 “要求同時進行,並且又要共享某些變量的併發操作”,只能用線程,不能用進程。線程有 自己的堆棧和局部變量,但線程之間沒有單獨的地址空間,一個線程死掉就等於整個進程死掉。
  • 線程的操作:創建、終止、同步(join、block)、調度、數據管理、進程交互。線程若要 主動終止,需要調用 pthread_exit() 函數 ,主線程需要調用 pthread_join() 來回收,前提是該線程沒有被 detached
  • 父子線程:父線程不會對其創建的子線程進行維護,子線程也不知道自己的爹是誰。一個線程可以創建和撤銷另一個線程
  • 多線程:同一進程中的多條線程將 共享 該進程的 相同地址空間,可以與同進程中的其他線程 共享數據,但擁有 自己的棧空間,擁有 獨立的執行序列。一個進程中可以 併發多個線程,每條線程並行執行不同的任務。

大多數軟件應用中,線程的數量都不止一個。多個線程可以互不干擾地併發執行,並共享進程的全局變量和堆的數據。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-w1rlxzB2-1589783553273)(https://linotes.imliloli.com/assets/images/thread.png)]

引入線程帶來的主要好處

  • 在進程內 創建、終止線程 比創建、終止進程要
  • 同一進程內的 線程間切換 比進程間的切換要 ,尤其是用戶線程間的切換。

從堆棧的角度理解線程

線程本質就是 堆棧,當一段程序在執行,能代表它的是它的過去和現在。

“過去” 在 堆棧 中,”現在” 則是 CPU 的 所有寄存器。如果我們要掛起一個線程,我們把寄存器也保存到堆棧中,我們就具有它的所有狀態,可以隨時恢復它。

當我們 切換線程 的時候,同時切換它的 地址空間(通過修改 MMU 即可),則我們認爲發生了進程切換。

所以進程的本質是地址空間,我們可以認爲地址空間決定了進程是否發生切換。

線程的狀態

線程有四種基本狀態,分別爲:

  • 產生 spawn
  • 阻斷 block
  • 非阻斷 unblock
  • 結束 finish

線程上下文

類似於進程上下文,線程也有上下文。當線程被搶佔時,就會發生線程之間的上下文切換。

如果線程屬於相同的進程,它們共享相同的地址空間,進程需要恢復的多數信息對於線程而言是不需要的。儘管進程和它的線程共享了很多內容,但最爲重要的是其地址空間和資源,有些信息對於線程而言是本地且唯一的,而線程的其他方面包含在進程的各個段的內部。

上下文內容 進程 線程
指向可執行文件的指針 x
x x
內存(數據段和堆) x
狀態 x x
優先級 x x
程序 I/O 的狀態 x
授予權限 x
調度信息 x
審計信息 x
有關資源的信息(文件描述符,讀/寫指針) x
有關事件和信號的信息 x
寄存器組(棧指針,指令計數器等) x x

線程本地(且唯一)的信息包括線程id、處理器寄存器(當線程執行時寄存器的狀態,包括程序計數器和棧指針)、線程狀態及優先級、線程特定數 據(thread-specific data,TSD)。

線程id是在創建線程時指定的。線程能夠訪問它所屬進程的數據段,因此線程可以讀寫它所屬進程的全局聲明數據。進程中一個線程做出的 任何改動都可以被進程中的所有線程以及主線程獲得。在多數情況下,這要求某種類型的同步以防止無意的更新。線程的局部聲明變量不應當被任何對等線程訪問。 它們被放置到線程棧中,而且當線程完成時,它們便會被移走。

11.1.2 Linux 的線程實現

當 Linux 最初開發時,在內核中並不能真正支持線程。但是它的確可以通過 clone() 系統調用將進程作爲可調度的實體。這個調用創建了調用進程(calling process)的一個拷貝,該拷貝與調用進程共享相同的地址空間。LinuxThreads 項目使用這個調用來完全在用戶空間模擬對線程的支持。不幸的是,這種方法有一些缺點,尤其是在信號處理、調度和進程間同步方面都存在問題。另外,這個線程模型也不符合 POSIX 的要求。

爲了完善 Linux 的線程實現,Red Hat 的一些開發人員開展了 NPTL (Native POSIX Thread Library)項目。它是 Linux 線程的一個新實現,它克服了 LinuxThreads 的缺點,同時也符合 POSIX 的需求。與 LinuxThreads 相比,它在性能和穩定性方面都提供了重大的改進。與 LinuxThreads 一樣,NPTL 也實現了一對一的模型。

實際使用中,創建線程時並不採用 clone 系統調用,而是採用 線程庫函數。常用線程庫有 Linux-Native 線程庫和 POSIX 線程庫(NPTL)。其中應用最爲廣泛的是 POSIX 線程庫。因此經常在多線程程序中看到的是 pthread_create 而非 clone

我們知道,庫是建立在操作系統層面上的功能集合,因而它的功能都是操作系統提供的。由此可知,線程庫的內部很可能實現了 clone 的調用。不管是進程還是線程的實體,都是操作系統上運行的實體。

11.1.3 線程的特點

同一進程中的線程共享:

  • 進程指令
  • 大部分數據
  • 打開的文件(文件描述符)
  • 信號及信號處理器
  • 當前工作路徑
  • UID、GID

每個線程有獨立的:

  • 線程 ID
  • 寄存器環境
  • 線程本地存儲(Thread-local Storage)
  • 調用棧(Call Stack)
  • 優先級
  • 返回值

11.1.4 用戶線程與內核線程

用戶線程:UserLevel Threads,ULT

內核線程:Kernel Supported threads,KST

linux 內核不存在整真正意義上的線程。linux 將所有的執行實體都稱之爲任務(task),每一個任務在概念上都類似於一個單線程的進程,具有內存空間、執行實體、文件資源等。但是不同任務之間可以選擇共用內存空間,因而在實際意義上,共享同一個內存空間的多個任務構成了一個進程,而這些任務就成爲這個任務裏面的線程。

內核線程

對於 一切的進程,無論是系統進程還是用戶進程,進程的 創建和撤銷,以及 I/O 操作 都是利用系統調用進入到內核,由內核處理完成,所以說在 KST 下,所有進程都是在操作系統內核的支持下運行的,是與內核緊密相關的。

內核空間實現還爲每個內核線程設置了一個 線程控制塊(Thread Control Block,TCB),內核是根據該控制塊而感知某個線程是否存在,並加以控制的。在一定程度上類似於進程,只是 創建、調度的開銷要比進程小。有的統計是 1:10。

內核線程可以在全系統內進行資源的競爭。

內核線程 切換由內核控制,當線程進行切換的時候,由用戶態轉化爲內核態。切換完畢要從內核態返回用戶態,即 存在用戶態和內核態之間的轉換

優點
  • 在多處理器系統中,內核能夠同時調度同一進程中 多個線程並行執行到多個處理器中
  • 如果進程中的 一個線程被阻塞內核可以調度 同一個進程中的 另一個線程
缺點

線程切換的代價太大,在同一個進程中,從一個線程切換到另一個線程時,需要從用戶態,進入到內核態並且由內核切換。因爲 線程調度和管理在內核實現

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-lFFUWLTp-1589783553275)(https://linotes.imliloli.com/assets/images/kst.jpg)]

內核線程駐留在 內核空間,它們是內核對象。

有了內核線程,每個用戶線程被映射或綁定到一個內核線程。用戶線程在其生命期內都會綁定到該內核線程。一旦用戶線程終止,兩個線程都將離開系統。這被稱作 “一對一” 線程映射。

內核中的操作系統調度器負責管理、調度並分派這些線程。運行時庫爲每個用戶線程請求一個內核線程。

操作系統的內存管理和調度子系統必須要考慮到數量巨大的用戶線程。您必須瞭解每個進程允許的線程的最大數目是多少。

操作系統 爲每個線程創建上下文

進程的 每個線程 在資源可用時 都可以被指派到處理器內核

用戶線程

用戶進程 ULT 僅存在於 用戶空間 中。

用戶線程的 創建、撤銷、線程之間的同步和通信 等功能,都 無需系統調用 來實現。

同一進程的線程之間切換不需要內核支持,內核也完全不會知道用戶線程的存在

但是有一點必須注意:設置了用戶線程的系統,其調度仍然是以進程爲單位進行的哦。

優點:
  1. 線程切換不需要轉換到內核空間,故切換開銷小,速度非常快
  2. 調度算法可以是進程專用,由用戶程序進行指定
  3. 用戶線程實現和操作系統無關
缺點:
  1. 系統調用的阻塞問題:對應用程序來講,同一進程中只能同時有一個線程在運行,一個線程的阻塞將導致整個進程中所有線程的阻塞
  2. 由於這裏的處理器時間片分配是以進程爲基本單位,所以 每個線程執行的時間相對減少

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-y6Sa2YXM-1589783553278)(https://linotes.imliloli.com/assets/images/ult.jpg)]

運行時庫管理這些線程,它也位於用戶空間。它們對於操作系統是不可見的,因此無法被調度到處理器內核。

每個線程並 不具有自身的線程上下文。因此,就線程的同時執行而言,任意給定時刻,每個進程只能夠有一個線程在運行,而且 只有一個處理器內核會被分配給該進程。對於一個進程,可能有成千上萬個用戶線程,但是它們對系統資源沒有影響。運行時庫調度並分派這些線程。如同在圖中看到的那樣,庫調度器從進程的多個線程中 選擇一個線程,然後該線程和該進程允許的一個內核線程關聯起來。內核線程將被操作系統調度器指派到處理器內核。用戶線程是一種 “多對一” 的線程映射。

混合方式

在很多的操作系統中把 ULT 和 KLT 進行組合,集中了它們的優點。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-B3zfQTLj-1589783553280)(https://linotes.imliloli.com/assets/images/ult.kst.jpg)]

混合線程 實現是用戶線程和內核線程的交叉,使得 庫和操作系統都可以管理線程。用戶線程由運行時庫調度器管理,內核線程由操作系統調度器管理。

在這種實現中,進程有着自己的內核線程池。可運行的用戶線程由運行時庫分派,並標記爲就緒態的可用線程。操作系統選擇用戶線程並將它映射到線程池中的可用內核線程。多個用戶線程可以分配給相同的內核線程

圖中,進程 A 在它的線程池中有兩個內核線程,而進程 B 有 3 個內核線程。

進程 A 的用戶線程 2 和 3 被映射到內核線程 2。

進程 B 有 5 個線程,用戶線程 1 和 2 映射到同一個內核線程 3,用戶線程 4 和 5 映射到內核同一個內核線程 5。

當創建新的用戶線程時,只需要簡單地將它映射到線程池中現有的一個內核線程即可。

這種實現使用了 “多對多“” 線程映射。該方法中儘量使用多對一映射。很多用戶線程將會映射到一個內核線程。因此,對內核線程的請求將會少於用戶線程的數目。

內核線程池 不會被銷燬和重建,這些線程 總是存在於系統中。它們會在必要時分配給不同的用戶線程,而不是當創建新的用戶線程時就創建一個新的內核線程,而純內核線程被創建時,就會創建一個新的內核線程。只對池中的每個線程創建上下文。有了內核線程和混合線程,操作系統分配一組處理器內核,進程的線程可以在這些處理器內核之上運行。線程只能在爲它們所屬線程指派的處理器內核上運行。

11.2 進程與線程的區別

  • 一個程序至少有一個進程,一個進程至少有一個線程。
  • 線程的劃分尺度小於進程,使得多線程程序的併發性高。
  • 線程執行開銷小,但不利於資源的管理和保護;而進程正相反。
  • 進程在執行過程中擁有 獨立的內存單元,而多個線程 共享內存,從而極大地提高了程序的運行效率。
  • 每個獨立的線程有一個程序運行的入口、順序執行序列和程序的出口。但是線程不能夠獨立執行,必須依存在應用程序中,由應用程序提供多個線程執行控制。
  • 從邏輯角度來看,多線程的意義在於一個應用程序中,有 多個執行部分可以同時執行。但操作系統並沒有將多個線程看做多個獨立的應用,來實現進程的調度和管理以及資源分配。
  • 線程適合於在 SMP(對稱多處理) 機器上運行,而進程則可以跨機器遷移。
對比維度 多進程 多線程 總結
數據共享、同步 數據共享複雜,需要進程間通信;數據是分開的,同步簡單 因爲共享進程數據,數據共享簡單,但也是因爲這個原因,導致同步複雜 各有優勢
內存、CPU 佔用內存多,切換複雜,CPU 利用率低 佔用內存少,切換簡單,CPU 利用率高 線程佔優
創建、銷燬、切換 創建、銷燬、切換複雜,速度慢 創建、銷燬、切換簡單,速度很快 線程佔優
編程、調試 編程簡單,調試簡單 編程複雜,調試複雜 進程佔優
可靠性 進程間不會互相影響 一個線程掛掉將導致整個進程掛掉 進程佔優
分佈式 適應於多核、多機分佈式;如果一臺機器不夠,擴展到多臺機器比較簡單 適應於多核分佈式 進程佔優

11.2.1 進程與線程的相同點

  • 都是用來實現多任務併發的技術手段
  • 都可以獨立調度
  • 都具有各自的實體,是系統獨立管理的對象個體
  • 在系統層面,都可以通過技術手段實現二者的控制
  • 進程與線程的狀態非常相似
  • 在多任務程序中,子進程(子線程)的調度一般與父進程(父線程)平等競爭

在 Linux 內核 2.4 版本之前,線程的實現和管理方式就是完全按照進程方式實現的。在 2.6 版內核以後纔有了單獨的線程實現。

11.2.2 實現方式的差異

進程是 資源分配 的基本單位,線程是 調度 的基本單位

  • 進程和線程都可以被調度,但 線程是更小的可以調度的單位,即只要達到線程的水平就可以被調度了。
  • 分配資源時的對象必須是進程,不會給一個線程單獨分配系統管理的資源。若要運行一個任務,想要獲得資源,最起碼得有進程,其他子任務可以以線程的身份運行,資源共享就可以了。
  • 進程的個體間是完全獨立的,而線程間是彼此依存的。多進程環境中,任何一個進程的終止,不會影響到其他進程。而多線程環境中,父線程終止,全部子線程被迫終止,因爲沒有了資源。而任何一個子線程的終止,一般不會影響其他線程,除非子線程執行了 exit() 系統調用。任何一個子線程執行 exit() ,全部線程同時滅亡。
  • 多線程程序中至少有一個主線程,這個主線程其實就是有 main() 函數 的進程。它是整個程序的進程,所有線程都是它的子線程。我們通常把具有多線程的主進程稱之爲 主線程
  • 進程 的實現是調用 fork 系統調用,線程 的實現是調用 clone 系統調用。fork 是將父進程的 全部資源 複製給了子進程,而 clone 只是 複製了一小部分必要的資源,可以說 fork 實現的是 clone 的加強完整版。後來操作系統還進一步優化 frok 實現 — 寫時複製技術,在子進程需要複製資源(如子進程執行寫入動作,更改父進程內存空間)時才複製,否則創建子進程時先不復制。

main() 函數 】
在 C 語言當中,一個程序,無論複雜或簡單,總體上都是一個 “函數”;這個函數就稱爲 main() 函數,也就是 “主函數”。比如有個 “做菜” 程序,那麼 “做菜” 這個過程就是 “主函數”。在主函數中,根據情況,你可能還需要調用 “買菜,切菜,炒菜” 等子函數。
main() 函數在程序中大多數是必須存在的,但是依然有例外情況,比如 windows 編程中可以編寫一個動態鏈接庫(dll)模塊,這是其他 windows 程序可以使用的代碼。由於 DLL 模塊不是獨立的程序,因此不需要 main 函數。再比如,用於專業環境的程序 — 如機器人中的控制芯片 — 可能不需要 main() 函數。
C 程序最大的特點就是所有的程序都是用函數來裝配的。main() 稱之爲主函數,是 所有程序運行的入口。其餘函數分爲 有參無參 兩種,均由 main() 函數或其它一般函數調用,若調用的是有參函數,則參數在調用時傳遞。

【 寫時複製 】
寫入時複製(Copy-on-write,COW)是一種計算機程序設計領域的 優化策略
其核心思想是,如果有多個調用者(callers)同時請求相同的資源(如內存或磁盤上的數據存儲),他們會共同獲取相同的指針,指向相同的資源,直到某個調用者試圖修改資源的內容時,系統纔會真正複製一份專用副本(private copy)給該調用者,而其他調用者所見到的最初的資源仍然保持不變。
這過程對其他的調用者都是透明的(transparently)。此作法主要的優點是如果調用者沒有修改該資源,就不會有副本(private copy)被創建,因此多個調用者只是讀取操作時可以共享同一份資源。

11.2.3 多任務程序設計模式的區別

  • 由於進程間相互獨立,適合設計需要資源獨立管理的多進程程序。但如果進程之間需要通信的話,就得采用進程間的通信方式,通常是耗時間的。
  • 同一進程的線程之間不需要通信,就可以共享資源。但父線程無法通過複用變量的方式來多次執行函數,無法在不同線程分別執行,要處理這樣的任務就需要進行線程間通信,父線程顯得效率有所下降。多個子線程在同時執行寫入操作時需要實現互斥,否則數據就寫 “髒” 了。

11.2.4 實體間通信方式的不同

進程間通信方式
  • 共享內存
  • 消息隊列
  • 信號量
  • 有名管道
  • 無名管道
  • 信號
  • 文件
  • socket
線程間通信方式

以上進程間通信方式都可沿用,而且還有自己獨特的幾種方式:

  • 互斥量
  • 自旋鎖
  • 條件變量
  • 讀寫鎖
  • 線程信號
  • 全局變量

線程間通信用的信號不能採用進程間的信號,因爲信號是基於進程爲單位的,而線程是共屬於同一進程空間的,因此要採用線程信號。

進程間採用的通信方式,要麼需要 切換內核上下文,要麼需要訪問外設(有名管道、文件)。所以速度會比較慢。

而線程採用自己特有的通信方式的話,基本都在自己的進程空間內完成,不存在切換,所以通信速度會較快。

進程與線程間通信

除信號以外的其他進程間通信方式都可採用。

11.2.5 控制方式的區別

進程的 ID 爲 pid_t 類型,實際爲一個 int 型的變量,也就是說是有限的。

在全系統中,PID 是唯一標識,對於進程的管理都是通過 PID 來實現的。每創建一個進程,內核中就會創建一個進程描述符來存儲該進程的全部信息。

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

線程的 ID 是一個 long 型變量,它的範圍大的多,管理方式也不一樣。

線程 ID 一般在本進程空間內作用就可以了,當然系統在管理線程時也需要記錄其信息。方式是,在內核創建一個內核態線程與之對應,也就是說 每一個用戶創建的線程都有一個內核態線程與之對應。但這種對應關係不是一對一,而是 多對一 的關係,即 一個內核態線程可以對應多個用戶線程

11.2.6 資源管理方式的區別

進程的資源是相互獨立的,如果多進程間需要共享資源,就要用到進程間的通信方式了,比如 共享內存。它是脫離於進程本身存在的,是全系統都可見的,進程的單點故障並不會損毀數據。共享內存是全系統可見的,如果編程不當,進程資源有可能被他人誤讀誤寫。

線程間要使用共享資源不需要用共享內存,直接使用 全局變量 即可,或者 malloc() 動態申請內存,更加方便直接。

實際使用中,爲了使程序內資源充分規整,都 採用共享內存來存儲核心數據。不管進程還是線程,都採用這種方式。原因之一就是,共享內存是脫離進程的資源,如果進程發生意外終止的話,共享內存可以獨立存在不會被回收。

11.2.7 進程池與線程池的技術實現差別

:進程和線程的創建是需要一定的時間的,並且系統所能承受的進程和線程數也是有上限的,如果在程序啓動時,就預先創建一些子進程或線程,在需要時就可以直接使用。

進程池

分開保存 PID,用數組或鏈表。做一個足夠大的池,便於快速響應。

任務不多時,讓閒置進程通過 pause() 掛起,也可用信號量掛起,還可以用 IPC(進程間通信)阻塞等多種方法。

有任務要執行時就喚醒進程,讓它從預先指定的地方去讀取任務,如可以用函數指針,在約定的地方設置代碼段指針。再通過共享內存把要處理的數據設置好,子進程就知道怎麼做了。執行完之後再來一次進程間通信,然後自己繼續冬眠,父進程就知道孩子幹完了,收割成果。

最後結束時,回收子進程,向各進程發送信號喚醒,改變激活狀態讓其主動結束,然後逐個 wait() 就可以了。

線程池

線程池的思想與上述類似,只是更爲輕量級,所以調度起來不用等待額外的資源。

要讓線程阻塞,用條件變量就是了,需要幹活的時候父線程改變條件,子線程就被激活。

線程間通信方式就不用贅述了,不用繁瑣的通信就能達成,比起進程間效率要高一些。

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

整個程序結束時,逐個改變條件並改變激活狀態讓子線程結束,最後逐個回收即可。

11.3 多任務

Linux 是多任務操作系統,多個進程可以同時運行,通過 搶佔時間片 的方式來控制。在一定時間(數毫秒)以後,操作系統把操作從一個進程轉移到另一個進程。

11.3.1 多任務操作系統

操作系統將 CPU 的 時間片 分配給 多個線程,每個線程在操作系統指定的時間片內完成(注意,這裏的多個線程是 分屬於不同進程 的)。

操作系統不斷的從一個線程的執行切換到另一個線程的執行,如此往復,宏觀上看來,就 好像是多個線程在一起執行

由於這多個線程分屬於不同的進程,因此在我們看來,就 好像是多個進程在同時執行,這樣就實現了 多任務

11.3.2 多線程

在任何時間,每個 CPU 同時只運行一個進程

多線程技術

某個操作可能會陷入長時間等待,等待的線程會進入睡眠狀態,無法繼續執行。多線程執行可以 有效利用等待的時間。典型的例子是等待網絡響應,這可能要花費數秒甚至數十秒。

某個操作(常常是計算)會消耗大量的時間,如果只有一個線程,程序和用戶之間的交互會中斷。多線程可以讓一個線程負責交互,另一個線程負責計算。

程序邏輯本身就要求併發操作,例如一個多端下載軟件(例如Bittorrent)。多 CPU 或多核計算機本身具備同時執行多個線程的能力,此時,單線程程序無法全面地發揮計算機的全部計算能力。相對於多進程應用,多線程在數據共享方面效率要高很多。在多核、多 CPU、或支持超線程(Hyper-threading)的 CPU 上,使用多線程程序設計,可提高程序的執行吞吐率。 在單 CPU、單核的計算機上,使用多線程技術,可以把進程中負責 “I/O 處理、人機交互等常被阻塞的部分”,與密集計算的部分分開來執行,編寫專門的 workhorse 線程用於執行密集計算,從而提高了程序的執行效率。

Linux 的多線程

在 Linux 中,進行 CPU 分配是以線程爲單位 的。

Windows 對進程和線程的實現如同教科書一般標準,Windows 內核有明確的線程和進程的概念。在 Windows API 中,可以使用明確的 API:CreateProcess 和 CreateThread 來創建進程和線程,並且有一系列的 API 來操縱它們。但對於 Linux 來說,線程並不是一個通用的概念。

Linux 對多線程的支持頗爲貧乏,事實上,在 Linux 內核中並不存在真正意義上的線程概念。Linux 將所有的執行實體(無論是線程還是進程)都稱爲 任務(Task),每一個任務概念上都類似於一個單線程的進程,具有內存空間、執行實體、文件資源等。不過,Linux 下不同的任務之間可以選擇共享內存空間,因而在實際意義上,共享了同一個內存空間的多個任務構成了一個進程,這些任務也就成了這個進程裏的線程。

線程數小於 CPU 數量

並行 運行 】

如果一臺計算機有多個 CPU,如果進程數小於 CPU 數,則不同的線程要以分配給不同的 CPU 來運行,多個線程真正的同時運行,這便是 並行運行

並行運行的效率顯然高於併發運行,所以在多 CPU 的計算機中,多任務的效率比較高。但是,如果在多 CPU 計算機中只運行一個進程(線程),就不能發揮多 CPU 的優勢。

線程數量大於 CPU 數量

至少有一個處理器會運行多個線程。這是大部分用戶使用計算機的常態。

併發 運行 】

通常 CPU 的數量要小於進程的數量,要讓它一心多用,同時運行多個線程,必須使用 併發技術

於是在 CPU 空閒下來變得可用之前,其餘的線程必須等待,直到它們可以被運行。

線程調度

實現併發技術相當複雜,最容易理解的是時間片輪轉線程調度算法,即輪轉法(Round Robin):

在操作系統的管理下,所有正在運行的線程輪流使用 CPU,每個進程允許佔用 CPU 的時間非常短,通常是幾十到幾百毫秒,這樣用戶根本感覺不出來 CPU 是在輪流爲多個線程服務,就好象所有的線程都在不間斷地運行一樣。但實際上在任何一個時間內有且僅有一個線程佔有 CPU。這樣的一個不斷在處理器上切換不同的線程的行爲稱之爲 線程調度(Thread Schedule),這決定了線程之間 交錯執行 的特點。。

處於運行中線程擁有一段可以執行的時間,這段時間稱爲 時間片(Time Slice),當時間片用盡的時候,該進程將進入就緒狀態。如果在時間片用盡之前進程就開始等待某事件,那麼它將進入等待狀態。每當一個線程離開運行狀態時,調度系統就會選擇一個其他的就緒線程繼續執行。

優先級調度

線程調度自多任務操作系統問世以來就不斷地被提出不同的方案和算法,還有一種 優先級調度(Priority Schedule) 的算法。優先級調度則決定了線程按照什麼 順序 輪流執行。

在具有優先級調度的系統中,線程都擁有各自的 線程優先級(Thread Priority)。具有高優先級的線程會更早地執行,而低優先級的線程常常要等待到系統中已經沒有高優先級的可執行的線程存在時才能夠執行。

線程的優先級不僅可以由用戶 手動設置,系統還會根據不同線程的表現 自動調整 優先級,以使得調度更有效率。

例如通常情況下,頻繁地進入等待狀態(進入等待狀態,會放棄之後仍然可佔用的時間份額)的線程(例如處理 I/O 的線程)比頻繁進行大量計算、以至於每次都要把時間片全部用盡的線程要受歡迎得多。其實道理很簡單,頻繁等待的線程通常只佔用很少的時間,CPU 也喜歡 先捏軟柿子。我們一般把 頻繁等待的線程稱之爲 I/O 密集型線程(IO Bound Thread),而把 很少等待的線程稱爲 CPU 密集型線程(CPU Bound Thread)。

I/O 密集型線程總是比 CPU 密集型線程容易得到優先級的提升。

在優先級調度下,存在一種 餓死(Starvation)的現象,一個線程被餓死,是說它的優先級較低,在它執行之前,總是有較高優先級的線程試圖執行,因此這個低優先級線程始終無法執行。當一個 CPU 密集型的線程獲得較高的優先級時,許多低優先級的進程就很可能餓死。而一個高優先級的IO密集型線程由於大部分時間都處於等待狀態,因此相對不容易造成其他線程餓死。爲了避免餓死現象,調度系統常常會逐步提升那些等待了過長時間的得不到執行的線程的優先級。在這樣的手段下,一個線程只要等待足夠長的時間,其優先級一定會提高到足夠讓它執行的程度。

因此,線程的優先級改變一般有三種方式:

  • 用戶指定優先級
  • 根據進入等待狀態的頻繁程度提升或降低優先級
  • 長時間得不到執行而被提升優先級
可搶佔線程和不可搶佔線程

上面討論的線程調度有一個特點,那就是線程在用盡時間片之後會被強制剝奪繼續執行的權利,而進入就緒狀態,這個過程叫做 搶佔(Preemption),即之後執行的別的線程搶佔了當前線程。

在早期的一些系統(例如 Windows 3.1)裏,線程是不可搶佔的。線程必須手動發出一個放棄執行的命令,才能讓其他的線程得到執行。在這樣的調度模型下,線程必須主動進入就緒狀態,而不是靠時間片用盡來被強制進入。如果線程始終拒絕進入就緒狀態,並且也不進行任何的等待操作,那麼其他的線程將永遠無法執行。

在不可搶佔線程中,線程主動放棄執行無非兩種情況:

  • 當線程試圖等待某事件時(I/O 等)
  • 線程主動放棄時間片

因此,在不可搶佔線程執行的時候,有一個顯著的特點,那就是線程調度的時機是確定的,線程調度只會發生在線程主動放棄執行或線程等待某事件的時候。這樣可以避免一些因爲搶佔式線程裏調度時機不確定而產生的問題。但即使如此,非搶佔式線程在今日已經十分少見。

線程安全

線程程序處於一個多變的環境當中,可訪問的全局變量和堆數據隨時都可能被其他的線程改變。因此多線程程序在併發時數據的一致性變得非常重要。

競爭與原子操作

多個線程同時訪問一個共享數據,可能造成很惡劣的後果。某些操作(如自增 ++)在多線程環境下會出現錯誤,因爲這個操作被編譯爲彙編代碼之後變成不止一條指令,因此在執行的時候可能執行了一半就被調度系統打斷,去執行別的代碼。我們把 單指令的操作 稱爲 原子的(Atomic),因爲無論如何,單條指令的執行是 不會被打斷 的。爲了避免出錯,很多體系結構都提供了一些常用操作的原子指令。

同步與鎖

儘管原子操作指令非常方便,但是它們僅適用於比較簡單特定的場合。在複雜的場合下,比如我們要保證一個複雜的數據結構更改的原子性,原子操作指令就力不從心了。這裏我們需要更加通用的手段:鎖。

爲了避免多個線程同時讀寫同一個數據而產生不可預料的後果,我們需要將各個線程對同一個數據的訪問同步(Synchronization)。

所謂 同步,既是指在一個線程訪問數據未結束的時候,其他線程不得對同一個數據進行訪問。如此,對數據的訪問被 原子化 了。

同步的最常見方法是使用 (Lock)。鎖是一種非強制機制,每一個線程在訪問數據或資源之前首先試圖 獲取(Acquire)鎖,並在訪問結束之後 釋放(Release)鎖。在鎖已經被 佔用 的時候試圖獲取鎖時,線程會 等待,直到鎖重新可用。

多線程內部情況

線程的併發執行是由多處理器或操作系統調度來實現的。但實際情況要更爲複雜一些:大多數操作系統,包括 Windows 和 Linux,都在內核裏提供線程的支持,內核線程由多處理器或調度來實現併發。然而用戶實際使用的線程並不是內核線程,而是存在於用戶態的用戶線程。用戶線程並不一定在操作系統內核裏對應同等數量的內核線程,例如某些輕量級的線程庫,對用戶來說如果有三個線程在同時執行,對內核來說很可能只有一個線程。

一對一模型

對於直接支持線程的系統,一對一模型始終是最爲簡單的模型。一個用戶線程唯一對應一個內核線程,但一個內核線程在用戶態不一定有對應的線程存在。線程之間的併發是真正的併發。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-pPYj78jk-1589783553282)(https://linotes.imliloli.com/assets/images/thread.11mdl.png)]

一般直接使用 API系統調用 創建的線程均爲一對一的線程。

優點:

  • 和內核線程一致。一個線程因爲某原因阻塞時,其他線程執行不會受到影響
  • 多線程程序在多處理器的系統上有更好的表現

缺點:

  • 由於許多操作系統限制了內核線程的數量,因此一對一線程會讓用戶的線程數量受到限制
  • 許多操作系統內核線程調度時,上下文切換的開銷較大,導致用戶線程的執行效率下降
多對一模型

多對一模型將多個用戶線程映射到一個內核線程上,線程之間的切換由用戶態的代碼來進行,因此相對於一對一模型,多對一模型的線程 切換快速 許多。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-0BEtuNjn-1589783553282)(https://linotes.imliloli.com/assets/images/thread.m1mdl.png)]

優點:

  • 高效的上下文切換和幾乎無限制的線程數量。

缺點:

  • 如果一個用戶線程阻塞,則所有的線程都將無法執行,因爲此時內核裏的線程也隨之阻塞了
  • 在多處理器系統上,處理器的增多對多對一模型的線程性能不會有明顯的幫助。
多對多模型

多對多模型結合了多對一模型和一對一模型的特點,將多個用戶線程映射到少數但不止一個內核線程上。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-scUSCm0F-1589783553284)(https://linotes.imliloli.com/assets/images/thread.mmmdl.png)]

在多對多模型中,一個用戶線程阻塞並不會使得所有的用戶線程阻塞,因爲此時還有別的線程可以被調度來執行。另外,多對多模型對用戶線程的數量也沒什麼限制,在多處理器系統上,多對多模型的線程也能得到一定的性能提升,不過提升的幅度不如一對一模型高。

11.3.3 多進程

多進程操作系統中,內存中同時保存着多個進程,力圖實現 CPU 的最大使用率。

內核通過 進程調度程序 分時調度各個進程運行:

一個進程運行一段時間後,往往需要暫停,等待特定的系統資源。當它得到這些資源以後,纔可以再次運行。一旦進程開始等待,進程調度程序就會把 CPU 移交給另一個更加需要的進程。Linux 會使用一些調度策略來保證所有進程公平使用系統資源。

而在單進程系統中,CPU 總是被閒置,等待的時間被白白浪費。

11.3.4 操作系統分類

根據進程與線程的設置,操作系統大致分爲如下類型:

  • 單進程、單線程:MS-DOS
  • 多進程、單線程:多數 UNIX,LINUX
  • 多進程、多線程:Win32(Windows NT/2000/XP/~),Solaris 2.x,OS/2
    對多模型中,一個用戶線程阻塞並不會使得所有的用戶線程阻塞,因爲此時還有別的線程可以被調度來執行。另外,多對多模型對用戶線程的數量也沒什麼限制,在多處理器系統上,多對多模型的線程也能得到一定的性能提升,不過提升的幅度不如一對一模型高。

11.3.3 多進程

多進程操作系統中,內存中同時保存着多個進程,力圖實現 CPU 的最大使用率。

內核通過 進程調度程序 分時調度各個進程運行:

一個進程運行一段時間後,往往需要暫停,等待特定的系統資源。當它得到這些資源以後,纔可以再次運行。一旦進程開始等待,進程調度程序就會把 CPU 移交給另一個更加需要的進程。Linux 會使用一些調度策略來保證所有進程公平使用系統資源。

而在單進程系統中,CPU 總是被閒置,等待的時間被白白浪費。

11.3.4 操作系統分類

根據進程與線程的設置,操作系統大致分爲如下類型:

  • 單進程、單線程:MS-DOS
  • 多進程、單線程:多數 UNIX,LINUX
  • 多進程、多線程:Win32(Windows NT/2000/XP/~),Solaris 2.x,OS/2
  • 單進程、多線程:VxWorks
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章