【好文推薦】黑莓OS手冊是如何詳細闡述底層的程式和執行緒模型的?

我沒有三顆心臟發表於2020-07-31

  • 「MoreThanJava」 宣揚的是 「學習,不止 CODE」,本系列 Java 基礎教程是自己在結合各方面的知識之後,對 Java 基礎的一個總回顧,旨在 「幫助新朋友快速高質量的學習」
  • 當然 不論新老朋友 我相信您都可以 從中獲益。如果覺得 「不錯」 的朋友,歡迎 「關注 + 留言 + 分享」,文末有完整的獲取連結,您的支援是我前進的最大的動力!

寫在前面

該文章的大部分內容都是翻譯自是黑莓 10 實時作業系統 QNX Neutrino開發手冊,該手冊不僅詳細地闡述了 BlackBerry 10 OS 的原理以及 OS 的體系結構,還描述了其 QNX Neutrino 微核心的詳細資訊 (包括程式執行緒、多和處理、網路架構、檔案系統等...非常完整..)

我閱讀了其中「描述程式和執行緒」的精華部分,覺得寫的非常不錯,特意翻譯 (主要靠有道和 Google 翻譯) 跟大家分享一下 (部分內容有改動)

手冊中詳細描述了許多關於 Linux 函式呼叫涉及底層方面的細節,在本文中我大都沒有貼出來,感興趣的朋友強烈建議去拜讀一下原文!

Part 1. 程式和執行緒基礎

在我們開始討論執行緒、程式、時間片和所有其他的精彩的概念之前,讓我們先來建立一個類比。

我要做的首先是 說明執行緒和程式是如何工作的。我能想到的最好的方法 (不涉及實時系統的設計) 是在某種情況下想象我們的執行緒和程式。

程式就像一棟房子

房子實際上是 具有某些屬性的容器 (例如臥室數量、佔地面積、區域劃分等)

如果您以這個角度來看,房子實際上並不會主動做任何事情————它是一個 被動的物件。這實際上就是程式乾的事情了 (充當容器)

執行緒就像是居住者

居住在房子裡面的人是 活動的物件,他們可以使用各種房間,看電視、做飯、洗澡等等等...

單執行緒

如果您獨居過,那麼您就會知道————您可以在家裡的任何時間做您任何想做的事,因為家裡面沒有其他人,您只要遵從內心的規則就好。

多執行緒

如果在您的房子中再另外新增一個人,情況將發生鉅變。

假設您結婚了,那麼現在您的家裡住著您和您的配偶。因此您不能在 任何時間 都能夠使用廁所,因為您需要首先確保您的配合不在其中!

如果您有兩個 負責任的成年人 居住在房屋中,那麼通常您可以在 「安全性」 這一塊兒稍微放鬆一些 (因為您知道另一個成年人會尊重您的空間,不會試圖故意在廚房放火之類的..)

但是,如果把幾個 熊孩子 混在一起,事情就會變得更加有趣了...

說回程式和執行緒

就像是房屋佔用土地一樣,程式也要佔用記憶體。

也正如房屋擁有者可以隨意進入他們想去的任何房間一樣,程式中的執行緒也 都擁有 對該記憶體區域訪問的許可權。

如果某個執行緒被分配了某些東西 (例如哥哥程式出去買了遊戲機?回家),那麼其他所有執行緒都可以立即訪問它 (因為它存在於公共地址空間中————都在房子裡)

同樣,如果給程式分配額外多的一塊空間,則新的區域也能夠用於所有執行緒。

這裡的竅門在於識別記憶體是否應該對程式中的所有執行緒可用。

  • 如果是,那麼您將需要讓所有執行緒同步它們對其的訪問。

  • 如果不是,那麼我們將假定它只能用於某個特定的執行緒 (在這種情況下,因為只有特定執行緒能夠訪問它,我們可以在假設執行緒不會自己把自己玩兒壞的前提下,不需要同步操作)

從日常的生活中我們知道,事情並不是那麼簡單。

現在我們已經瞭解了基本特徵 (所有內容都是共享的),下面我們來深入探究一下事情變得有趣的地方以及原因。

下圖顯示了我們表示執行緒和程式的方式。程式是一個圓圈,代表“容器”概念(地址空間),三個長方形是執行緒。在本文中,您將看到類似這樣的圖。

互斥

在生活中,如果您想洗個澡,並且已經有人在洗手間了,您將不得不等待。執行緒又是如何處理的呢?

這是通過一種叫做 互斥 (mutual exclusion) 的操作完成的。和你想的差不多——當涉及到 特定資源 時,許多執行緒是互斥的。

當你想要獨佔浴室洗澡時,你通常會走進浴室,從裡面鎖上門。任何想要上廁所的人都會被鎖擋住。當你洗完之後,你會開啟門,允許其他人進入。

這就是執行緒的作用。一個執行緒使用一個叫做 互斥鎖 (mutex) 的物件 (互斥鎖 MUTual EXclusion 的首字母縮寫)。這個物件就像門上的鎖 —— 一旦一個執行緒鎖定了互斥鎖,其他執行緒就不能獲得該互斥鎖,直到擁有它的執行緒釋放它。就像門鎖一樣,等待獲得互斥鎖的執行緒將被阻擋。

互斥鎖和門鎖的另一個有趣的相似之處是,互斥鎖實際上是一種 “建議” 鎖 (“advisory” lock)。如果一個執行緒不遵守使用互斥鎖的約定,那麼保護就沒有用了。在我們的房子比喻中,這就像有人不顧門和鎖的慣例,從一堵牆闖進盥洗室。

優先順序

如果衛生間現在上鎖了,有很多人在等著使用怎麼辦?顯然,所有人都坐在外面,等著洗手間裡的人出來。

真正的問題是:當門開啟時會發生什麼?誰下一個去?

你會想,讓等待時間最長的人下一個走或許是 “公平的”。又或者,讓年齡最大的人排在第二位也可能是 “公平的”。有很多方法可以確定什麼是“公平”。

我們通過兩個因素來解決這個問題:優先順序等待的時長

假設兩個人同時出現在鎖著的浴室門口。其中一個有一個有急事 (例如:他們開會已經遲到了...),而另一個沒有。讓那個有急事的人去做下一個,不是很有意義嗎?

當然!問題的關鍵是你如何決定誰更 “重要”。

這可以通過分配優先順序來實現 (我們可以使用數字,例如 1 是最低的可用優先順序,255 是這個版本的最高優先順序)。房子中那些有急事的人將被給予更高的優先權,而那些沒有急事的人將被給予較低的優先權。

執行緒也是一樣。執行緒從父執行緒繼承自己的排程演算法,但是可以呼叫 Linux 系統函式 pthread_setschedparam() 來更改自己的排程策略和優先順序 (如果它有許可權這麼做的話)

如果有很多執行緒在等待,並且互斥鎖被解鎖,我們將把互斥鎖給優先順序最高的等待執行緒。但是,假設兩個人有相同的優先順序。現在該做什麼呢?

好吧,在這種情況下,讓等待時間最長的人下一個去才是 “公平的”。這不僅是“公平的”,也是核心實際所做的。在有一堆執行緒等待的情況下,我們主要根據優先順序,其次是等待的長度。

互斥鎖當然不是我們將遇到的唯一 同步物件。讓我們看看其他一些例子。

訊號量

讓我們從浴室轉到廚房,因為那裡是一個社會認可的可以同時容納一個人以上的地方。在廚房裡,你可能不想讓每個人都同時待在那裡。事實上,你可能想要限制廚房裡的人數 (廚師太多等等...)

假設你不希望同時有 超過兩個人 在裡面,這可以使用互斥鎖來實現嗎?這對於我們的類比來說是一個非常有趣的問題。讓我們稍微討論一下。

計數為 1 的訊號量

在上面類比的衛生間的例子中,可以有兩種情況,並且兩種狀態相互關聯:

  • 門沒鎖,房間裡沒人;
  • 門鎖了,房間裡有人;

這裡並沒有其他可能的組合 —— 房間裡沒有人的時候門不能鎖上 (我們怎麼能開啟它?),房間裡有人的時候門也不能開啟 (他們怎麼能保證自己的隱私呢?)。這是一個計數為 1 的訊號量示例:房間中最多隻能有一個人,或者有一個執行緒使用該訊號量。

這裡的關鍵是我們描述鎖的方式。

在你典型的浴室鎖裡,你只能從裡面上鎖和解鎖 (沒有可以從外部訪問的鎖)。實際上,這意味著互斥鎖的所有權是一個原子操作 —— 在你獲得互斥鎖的過程中,其他執行緒不可能獲得它,結果就是一個執行緒進入 "廚房" 上鎖,導致另一個執行緒將無法進入。在我們用房子來比喻的故事裡,這一點就不那麼明顯了,因為人類比 10 聰明得多。

很顯然,我們廚房需要的是一種不同型別的鎖。

計數大於 1 的訊號量

假設我們在廚房安裝了傳統的基於鑰匙的鎖。這把鎖的工作原理是,如果你有一把鑰匙,你就可以開門進去。任何使用這把鎖的人都同意,當他們進入內部時,他們將立即從內部鎖門,這樣,任何在外部的人都將始終需要一把鑰匙。

好了,現在控制我們想要多少人在廚房就變成一件簡單的事情了 —— 把兩把鑰匙掛在門外!廚房總是鎖著。當有人想進廚房時,他們要看是否有鑰匙掛在門外。如果是的話,他們就把它帶在身邊,開啟廚房的門,走進去,用鑰匙鎖門。

因為進入廚房的人必須在他們進入廚房時帶著鑰匙,所以我們通過限制門外掛鉤上可用的鑰匙數量來直接控制允許進入廚房的人數。

對於執行緒,這是通過 訊號量 來完成的。“普通” 訊號量就像互斥鎖一樣工作 —— 你要麼擁有互斥鎖,在這種情況下你可以訪問資源,要麼不擁有,在這種情況下你不能訪問資源。我們剛才在廚房中描述的訊號量是一個計數訊號量 —— 它跟蹤計數 (根據執行緒可用的 "鑰匙" 的數量)

作為互斥鎖的訊號量

我們剛才問了這樣一個問題:“可以用互斥鎖來實現嗎?” 對於使用互斥鎖實現計數,答案是否定的。反過來怎麼樣?我們可以使用訊號量作為互斥鎖嗎?

當然可以!事實上,在某些作業系統中,這正是它們所做的 —— 它們沒有互斥鎖,只有訊號量!那麼,為什麼要為互斥鎖費心呢?

要回答這個問題,先看看你的洗手間。你房子的建造者是如何實現 “互斥鎖” 的?我敢肯定你家的廁所外面並沒有掛在牆上的鑰匙!

互斥鎖是一種 “特殊用途” 的訊號量。如果您希望在程式碼的特定部分中執行一個執行緒,那麼互斥是目前為止最有效的實現。

(事實上文章的後續介紹了其他同步方案——稱為 condvarsbarriersleepons,這些就是 Java 中同步方案的作業系統層面的原型,感興趣的可以自行去閱讀一下.. 差別不大,只不過使用的是作業系統層面的描述...)

為了避免混淆,請認識到 互斥鎖還有其他屬性,比如 優先順序繼承,這是它與 訊號量 的區別。

Part 2. 核心的作用

房子的類比很好地解釋了同步的概念,但是它在另一個主要領域是解釋不通的。在我們家裡,有許多 "執行緒" 是同時執行的。然而,在一個真實的實時系統中,通常只有一個 CPU,所以一次只能執行一個 “東西”。

單核 CPU

讓我們看看在現實情況中會發生什麼,特別是在系統中 只有一個 CPU 的情況下。在這種情況下,由於只有一個 CPU,因此在 任何給定的時間點只能執行一個執行緒。核心決定 (使用一些規則,我們將很快看到) 到底該執行哪個執行緒。

多核CPU (SMP)

如果您購買的系統具有多個相同的 CPU,它們都共享記憶體和裝置,那麼您將擁有一個 SMP box (SMP 代表對稱的多處理器,“對稱的” 部分表示系統中的所有 CPU 都是相同的)。在這種情況下,可以併發 (同時) 執行的執行緒數量受到 CPU 數量的限制。(實際上,單處理器機箱也是如此!) 由於每個處理器一次只能執行一個執行緒,因此如果有多個處理器,多個執行緒就可以同時執行。

現在讓我們先忽略 CPU 的數量 —— 一個有用的抽象是把系統設計成多執行緒同時執行的樣子,即使事實並非如此。(稍後,在 “使用 SMP 時要注意的事情” 一節中,我們將看到 SMP 的一些非直觀影響...)

核心作為仲裁程式

那麼,在任何給定的時刻,誰來決定哪個執行緒將執行?這是核心的工作。

核心確定在特定時刻應該使用 CPU 的執行緒,並將上下文切換到該執行緒。讓我們研究一下核心對 CPU 做了什麼。

CPU 有許多暫存器 (確切的數目取決於處理器家族,例如,x86MIPS,以及特定的家族成員,例如,80486 對 奔騰)。當執行緒執行時,資訊儲存在這些暫存器中 (例如,當前程式位置)

當核心決定另一個執行緒應該執行時,它需要:

  1. 儲存當前執行執行緒的暫存器和其他上下文資訊;
  2. 將新執行緒的暫存器和上下文載入到 CPU 中;

但是核心如何決定應該執行另一個執行緒呢? 它會檢視某個特定執行緒此時是否能夠使用該 CPU。例如,當我們談到互斥鎖時,我們引入了一種 阻塞狀態 (當一個執行緒擁有互斥鎖,另一個執行緒也想獲得它時,就會發生這種情況;第二個執行緒將被阻塞)

因此,從核心的角度來看,我們有一個執行緒可以消耗 CPU,而另一個執行緒由於被阻塞而不能等待互斥鎖。在這種情況下,核心讓可以執行的執行緒消耗 CPU,並將另一個執行緒放入一個內部列表 (以便核心可以跟蹤它對互斥鎖的請求)

顯然,這不是一個很有趣的情況。假設有許多執行緒可以使用該 CPU。還記得我們基於優先順序和等待長度來委託對互斥的訪問嗎?核心使用類似的方案來確定下一個將執行哪個執行緒。有兩個因素:優先順序排程演算法,基於此順序評估。

優先順序

考慮兩個能夠使用 CPU 的執行緒。如果這些執行緒具有不同的優先順序,那麼答案真的很簡單 —— 核心將 CPU 分配給優先順序最高的執行緒。正如我們在討論獲得互斥鎖時提到的,優先順序是從 1 (可用性最低的) 開始的。

注意,優先順序為零是為空閒執行緒保留的 —— 您不能使用它。 (如果您想知道您的系統的最小值和最大值,可以使用 Linux 的系統函式 sched_get_priority_min()sched_get_priority_max() —— 它們的原型被定義在 <sched.h> 中。我們假設 1 是最低可用性,255 是最高可用性。)

如果另一個具有更高優先順序的執行緒突然能夠使用 CPU,核心將立即上下文切換到更高優先順序的執行緒。我們稱之為 搶佔 —— 高優先順序執行緒搶佔了低優先順序執行緒。當高優先順序執行緒完成時,核心上下文切換回之前執行的低優先順序執行緒,我們稱之為 恢復 —— 核心繼續執行前一個執行緒。

現在,假設有兩個執行緒能夠使用 CPU,並且具有完全相同的優先順序。

排程演算法

讓我們假設其中一個執行緒當前正在使用該 CPU。在本例中,我們將研究核心用於決定何時進行上下文切換的規則。(當然,這整個討論實際上只適用於具有相同優先順序的執行緒)

核心有兩種主要排程演算法 (策略):輪詢排程 (或簡稱“RR”) 和 FIFO (先入先出)

FIFO(First In First Out,先進先出)

在 FIFO 排程演算法中,允許執行緒一直使用 CPU (只要它想要使用)。這意味著,如果該執行緒正在進行非常長的數學計算,並且沒有其他具有更高優先順序的執行緒準備好,那麼該執行緒可能會永遠執行下去。那麼具有相同優先順序的執行緒呢?它們也被鎖在外面了。(顯然,此時低優先順序的執行緒也被鎖定。)

如果正在執行的執行緒放棄或自願放棄 CPU,那麼核心將查詢 具有相同優先順序、能夠使用該 CPU 的其他執行緒。如果沒有這樣的執行緒,那麼核心將尋找能夠使用 CPU 的低優先順序執行緒。

注意,術語 “自願放棄CPU” 可能意味著兩種情況。

如果執行緒進入睡眠狀態,或者在訊號量上阻塞,那麼是的,一個低優先順序的執行緒可以執行 (如上所述)

但是還有一個 “特殊” 呼叫,sched_yield() (基於核心呼叫 SchedYield()),它僅將 CPU 讓給另一個具有相同優先順序的執行緒 —— 如果高優先順序的執行緒已經準備好執行,那麼低優先順序的執行緒將永遠不會有機會執行。如果一個執行緒確實呼叫了 sched_yield(),並且沒有其他具有相同優先順序的執行緒準備執行,那麼原始執行緒將繼續執行。實際上,sched_yield() 用於在 CPU 上對另一個具有相同優先順序的執行緒進行訪問。

在下面的圖中,我們看到三個執行緒在兩個不同的程式中執行:

如果我們假設執行緒“A”和“B”已經準備好了,執行緒“C”被阻塞了(可能在等待互斥),執行緒“D”(沒有顯示)正在執行,那麼核心維護的就緒佇列的一部分看起來是這樣的:

這顯示了核心的 內部就緒佇列,核心使用它來決定下一步排程誰。注意,執行緒“C”沒有在就緒佇列中,因為它被阻塞了;執行緒“D”也沒有在就緒佇列中,因為它正在執行。

輪循(Round Robin)

RR 排程演算法 與 FIFO 相同,只是如果有另一個具有相同優先順序的執行緒,則該執行緒不會永遠執行。它只對系統定義的時間片執行,您可以使用函式 sched_rr_get_interval() 來確定時間片的值。時間片通常為 4 毫秒,但實際上是 ticksize4 倍,您可以查詢或使用 ClockPeriod() 設定 ticksize

實際發生的情況是,核心啟動一個 RR 執行緒,並記錄時間。如果 RR 執行緒執行了一段時間,分配給它的時間就會超時 (時間片已經過期)。核心檢視是否有另一個具有相同優先順序的執行緒已經準備好了。如果有,核心執行它。如果沒有,那麼核心將繼續執行 RR 執行緒 (核心授予執行緒另一個時間片)

讓我們總結一下 排程規則 (對於單個CPU),按重要性排序:

  • 一次只能執行一個執行緒。
  • 最高優先順序的就緒執行緒將執行。
  • 執行緒將一直執行,直到阻塞或退出。
  • RR 執行緒將執行它的時間片,然後核心將重新安排它 (如果需要)

下面的流程圖顯示了核心做出的決策:

對於多 CPU 系統,規則是相同的,除了多個 CPU 可以併發地執行多個執行緒。執行緒執行的順序 (在多個 CPU 上執行哪些執行緒) 的確定方法與在單個 CPU 上完全相同 —— 最高優先順序的就緒執行緒將在一個 CPU 上執行。對於優先順序較低或等待時間較長的執行緒,核心在安排它們以避免快取使用效率低下方面具有一定的靈活性。

核心狀態

我們一直在鬆散地討論 “執行”、“就緒” 和 “阻塞” —— 現在讓我們更加深入地來討論這些執行緒狀態。

執行態(RUNNING)

執行狀態僅僅意味著執行緒正在積極地消耗 CPU。在一個 SMP 系統上,會有多個執行緒在執行;在單處理器系統中,只有一個執行緒在執行。

就緒態(READY)

就緒狀態意味著這個執行緒現在就可以執行 —— 但它不會立刻執行,因為另一個執行緒 (具有相同或更高優先順序) 正在執行。如果兩個執行緒能夠使用 CPU,一個執行緒優先順序為 10,一個執行緒優先順序為 7,那麼優先順序為 10 的執行緒將正在執行,優先順序為 7 的執行緒將準備就緒。

阻塞狀態(BLOCKED)

我們把阻塞狀態稱為什麼?問題是,阻塞狀態並不只有一個。在核心的作用下,實際上有超過 12 種阻塞狀態。

為什麼那麼多?因為核心會跟蹤執行緒被阻塞的原因。

我們已經看到了兩種阻塞狀態 —— 當一個執行緒被阻塞等待互斥鎖時,這個執行緒處於互斥鎖狀態;當執行緒被阻塞等待訊號量時,它處於 SEM 狀態。這些狀態只是表明執行緒阻塞在哪個佇列 (和哪個資源) 上。

如果在一個互斥鎖上阻塞了許多執行緒 (處於互斥鎖阻塞狀態),核心不會注意到它們,直到擁有該互斥鎖的執行緒釋放它。此時,阻塞的一個執行緒已經準備就緒,核心將做出重新排程決策 (如果需要)

為什麼說 “如果需要” 呢?剛剛釋放互斥鎖的執行緒可能還有其他事情要做,並且比等待的執行緒有更高的優先順序。在本例中,我們使用第二個規則,即 “最高優先順序的就緒執行緒將執行”,這意味著排程順序沒有改變 —— 高優先順序執行緒繼續執行。

核心狀態,完整的列表

下面是核心阻塞狀態的完整列表,並簡要說明了每個狀態。順便說一下,這個列表可以在 \<sys/neutrino.h> —— 你會注意到所有的狀態都以 STATE_ 作為字首 (例如,這個表中的 “READY” 在標頭檔案中以 STATE_READY 的形式列出)

狀態標識 當前執行緒動作
CONDVAR 等待一個條件變數被通知。
DEAD 執行緒死亡。核心正在等待釋放執行緒的資源。
INTR 等待中斷。
JOIN 等待另一個執行緒的完成。
MUTEX 等待獲取互斥鎖。
NANOSLEEP 睡一段時間 (當前的執行緒將暫停執行,直到 rqtp 引數所指定的時間間隔)
NET_REPLY 等待通過網路傳送的回覆。
NET_SEND 等待一個脈衝或訊息通過網路傳送。
READY 不在 CPU 上執行,但已準備執行 (一個或多個更高或同等優先順序的執行緒正在執行)
RECEIVE 等待客戶端傳送訊息。
REPLY 等待伺服器回覆訊息。
RUNNING 正在 CPU 上積極地執行。
SEM 等待獲取訊號量。
SEND 等待伺服器接收訊息。
SIGSUSPEND 等待訊號。
SIGWAITINFO 等待訊號。
STACK 等待分配更多堆疊。
STOPPED 暫停(SIGSTOP訊號)。
WAITCTX 等待暫存器上下文 (通常是浮點) 可用 (僅在SMP系統上)
WAITPAGE 等待程式管理者解決頁面上的一個錯誤。
WAITTHREAD 等待建立執行緒。

需要記住的重要一點是,當一個執行緒被阻塞時,無論它處於何種狀態,它都不會消耗CPU。相反,執行緒消耗 CPU 的唯一狀態是執行狀態。

Part 3. 執行緒和程式

讓我們從真實的實時系統的角度回到對執行緒和程式的討論。

我們知道一個程式可以有一個或多個執行緒。(一個沒有執行緒的程式將不能做任何事情 —— 也就是說,沒有人在家執行任何有用的工作。) 一個系統可以有一個或多個程式。(同樣的討論也適用於 —— 一個沒有任何程式的系統不會有任何作用。)

那麼這些程式和執行緒是做什麼的呢?最終,它們形成一個系統 —— 執行某個目標的執行緒和程式的集合。

在最高層次上,系統由許多程式組成。每個程式都負責提供某種性質的服務 —— 無論是檔案系統、顯示驅動程式、資料採集模組、控制模組,還是其他什麼。

在每個程式中,可能有許多執行緒。執行緒的數量是不同的。一個只使用一個執行緒的程式可以完成與另一個使用五個執行緒的程式相同的功能。有些問題本身是多執行緒的,實際上解決起來相對簡單,而其他程式本身是單執行緒的,很難實現多執行緒。

如何用執行緒進行設計的話題很容易就會被另一本書佔用 —— 在這裡我們只關注基礎知識。

為什麼需要多個程式?

那麼為什麼不讓一個程式擁有無數執行緒呢?雖然有些作業系統強迫你這樣編碼,但將事情分解成多個程式的好處有很多:

  • 分離和模組化;
  • 可維護性;
  • 可靠性;

將問題 “分解” 成幾個獨立問題的能力是一個強大的概念。它也是系統的核心。一個系統由許多獨立的模組組成,每個模組都有一定的職責。這些獨立的模組是不同的程式。QSS 的人員使用這個技巧來開發獨立的模組,而不需要模組相互依賴。模組之間唯一的 “依賴” 是通過少量定義良好的介面實現的。

由於缺乏相互依賴關係,這自然會增強可維護性。因為每個模組都有自己的特定定義,所以修復一個模組相當容易 —— 尤其是在它不繫結到任何其他模組的情況下。

然而,可靠性可能是最重要的一點。程式就像房子一樣,有一些定義明確的 “邊界”。住在房子裡的人很清楚自己什麼時候在房子裡,什麼時候不在房子裡。一個執行緒有一個很好的想法 —— 如果它在程式中訪問記憶體,它可以存活。如果它超出了程式地址空間的範圍,它就會被殺死。這意味著執行在不同程式中的兩個執行緒可以有效地相互隔離。

程式地址空間由系統的程式管理器模組維護和執行。當一個程式啟動時,程式管理器會分配一些記憶體給它,並啟動一個正在執行的執行緒。記憶體被標記為該程式所擁有。

這意味著如果程式中有多個執行緒,核心需要在它們之間進行上下文切換,這是一個非常高效的操作 —— 我們不需要改變地址空間,只需要改變哪個執行緒在執行。但是,如果我們必須切換到另一個程式中的另一個執行緒,那麼程式管理器就會介入並導致地址空間的切換。不用擔心,雖然在這個額外的步驟中會有一些額外的開銷,但是在系統的作用下仍然是非常快的。

如何啟動一個程式

現在讓我們將注意力稍微轉向可用於處理執行緒和程式的函式呼叫 。任何執行緒都可以啟動一個程式;唯一施加的限制是那些來自基本安全性的限制 (檔案訪問、特許可權制等)

想要啟動一個程式大概有以下幾種方法 (具體的細節我們在這裡不介紹)

  1. 命令列啟動
  2. 使用 system() 函式呼叫啟動:這是最簡單的,直接在命令列輸入就可以。實際上,system() 啟動了一個外殼程式來處理您要執行的命令;
  3. 使用 exec() 系列函式spawn() 函式呼叫啟動:當一個程式發出 exec() 函式,則該程式停止執行當前程式,並開始執行另一個程式;程式 ID 沒有改變 ——— 程式變成了另一個程式;而呼叫 spwan() 函式則會建立另一個程式 (帶有新的程式 ID),該程式與函式的引數中指定的程式相對應。
  4. 使用 fork() 呼叫啟動:完全複製當前程式,所有程式碼都是相同的,資料也與建立 (或父) 程式的資料相同。有意思的是,當您呼叫 fork() 時,您建立了另一個程式,該程式在相同的位置執行相同的程式碼,兩個程式都將從 fork() 呼叫中作為父程式返回。(感興趣的同學可以自行搜尋一下...)
  5. 使用 vfork() 呼叫啟動:與普通 fork() 函式相比,vfork() 函式的資源消耗要少得多,因為它共享父執行緒的地址空間。vfork() 函式建立一個子執行緒,然後掛起父執行緒,直到子執行緒呼叫 exec() 或退出。另外,vfork() 可以在實體記憶體模型系統上工作,而 fork() 不能 —— fork() 需要建立相同的地址空間,這在實體記憶體模型中是不可能的。

如何啟動執行緒

現在我們已經瞭解瞭如何啟動另一個程式,讓我們看看如何啟動另一個執行緒。

任何執行緒都可以在同一程式中建立另一個執行緒。沒有任何限制 (當然,記憶體空間不足除外!)。最常見的方法是通過 POSIX pthread_create() 呼叫:

#include <pthread.h>

int
pthread_create (pthread_t *thread,
                const pthread_attr_t *attr,
                void *(*start_routine) (void *),
                void *arg);
  • 引數細節和更多執行緒管理的內容這裡就不討論了..

執行緒是個好主意

有兩類問題,執行緒的應用是一個好主意。

  • 並行化操作,如計算許多數學問題 (圖形、數字訊號處理等...)
  • 共享資料執行幾個獨立的功能,如伺服器同時服務多個客戶;

第一類問題很容易理解,把同一個問題分成四份並讓四個 CPU 同時計算,這當然感覺會快上不少。

第二類問題稍微麻煩一些,我們藉助網路中一臺「計算圖形結果併發往遠端的節點」用來演示,並進一步說明「為什麼單核 CPU 系統使用多執行緒仍然是一個好主意」。

假設我們有一個程式,需要先計算圖形的部分 (假設使用 "C" 表示),然後需要傳輸到遠端 (假設使用 "X" 表示),並最終等待遠端回應做下一步操作 (假設使用 "W" 表示),以下就是該程式在單核 CPU 下的使用情況:

序列化的單核 CPU

等一下!這浪費了我們許多寶貴的可用於計算的時間!因為等待遠端回覆的過程,CPU 做的僅僅是 "等待" 而已..

如果我們使用多執行緒,應該可以更好地利用我們的 CPU,對嗎?

多執行緒單 CPU

這樣好得多,因為現在,即使第二個執行緒花了一些時間等待,我們也減少了計算所需的總時間。

我們可以來簡單計算一下。假設我們計算的時間記為 T計算,傳送和等待的時間分別記為:T傳送T等待。那麼在第一種情況下,我們的總執行時間為:

T計算 + T傳送 + T等待) x 任務數量

而使用兩個執行緒:

(T計算 + T傳送) x 任務數量 + T等待

直接減少了:

T等待 x (任務數量 - 1)

請注意,我們速度最終還是受到以下因素的決定:

T計算 + T傳送 x 任務數量

因為我們必須至少進行一次完整的計算,並且必須將資料傳輸到硬體之外 —— 儘管我們可以使用多執行緒覆蓋計算週期,但只有一個硬體資源可以傳輸。

現在,我們建立一個四執行緒的版本,並且在 4 核的系統上執行它,那麼我們最終將得到如下圖示的結果:

4 核 4 CPU 圖示

請注意,四個 CPU 的每個利用率均未得到充分利用 (如圖示 "利用率" 中的灰色矩形所示)。上圖中有兩個有趣的區域。當四個執行緒啟動時,它們各自進行計算。不過,當執行緒在每次計算完成時,它們都在爭奪傳輸硬體 (圖中的 "X" 部分 —— 一次只能進行一次傳輸)。這算是在啟動部分給了我們一個小異常。一旦執行緒經過此階段,由於傳輸時間比計算週期的 1/4 小得多,因此它們自然會與傳輸硬體同步。首先,忽略小異常,該系統的特徵在於以下公式:

T計算 + T傳送 + T等待) x 任務數量 / CPU 數量

該公式指出,在四個 CPU 上使用四個執行緒將比我們剛開始使用的單執行緒模型快大約 4 倍。

通過結合從簡單擁有多執行緒單處理器版本中學到的知識,我們理所當然地希望擁有更多的 CPU 執行更多的執行緒,以便多餘的執行緒可以 “吸收” 傳送確認等待 (和傳送資訊) 中的空閒的 CPU 時間。

但是你嘗試像我這樣畫一下 88 執行緒的情況,你就會注意到一些神奇的事情:我們仍然會遭遇利用率不足的情況,並且會發現 CPU 處於 "停滯等待" 狀態的可能同時會有很多個。

這給了我們一個很重要的教訓 —— 我們不能簡單的增加 CPU 以倍速我們的運算速度。

我們希望越來越快,但存在許多限制因素。在某些情況下,這些限制因素僅受多 CPU 主機板的設計 —— 當許多 CPU 嘗試訪問相同的記憶體區域時,會發生多少記憶體和裝置爭用。

儘管多執行緒多核給我們帶來了許多好處,但 "更加複雜的環境" 也讓我們面臨更多的困難和挑戰。可以參考我們類比的例子,在生活中,你面臨的情況如果仔細思考和羅列的話,那將會是多麼複雜.... 這裡也不展開講了

總結

相信看完的朋友都能夠對程式和執行緒進一步加深印象.. 程式像房子,執行緒像住戶,非常深入人心!

原文章的後半程還詳細地介紹了很多執行緒同步的更多內容,涉及 Linux 底層的函式呼叫,再後面一個章節還詳細地介紹了程式間的通訊相關的內容,感興趣的童鞋,請移步原文!

  • 本文已收錄至我的 Github 程式設計師成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
  • 個人公眾號 :wmyskxz,個人獨立域名部落格:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!

非常感謝各位人才能 看到這裡,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!

創作不易,各位的支援和認可,就是我創作的最大動力,我們下篇文章見!

相關文章