首頁>技術>
一切要從CPU說起

你可能會有疑問,講多執行緒為什麼要從CPU說起呢?原因很簡單,在這裡沒有那些時髦的概念,你可以更加清晰的看清問題的本質

CPU並不知道執行緒、程序之類的概念。

CPU只知道兩件事:

從記憶體中取出指令執行指令,然後回到1

你看,在這裡CPU確實是不知道什麼程序、執行緒之類的概念。

接下來的問題就是CPU從哪裡取出指令呢?答案是來自一個被稱為Program Counter(簡稱PC)的暫存器,也就是我們熟知的程式計數器,在這裡大家不要把暫存器想的太神秘,你可以簡單的把暫存器理解為記憶體,只不過存取速度更快而已。

PC暫存器中存放的是什麼呢?這裡存放的是指令在記憶體中的地址,什麼指令呢?是CPU將要執行的下一條指令。

那麼是誰來設定PC暫存器中的指令地址呢?

原來PC暫存器中的地址預設是自動加1的,這當然是有道理的,因為大部分情況下CPU都是一條接一條順序執行,當遇到if、else時,這種順序執行就被打破了,CPU在執行這類指令時會根據計算結果來動態改變PC暫存器中的值,這樣CPU就可以正確的跳轉到需要執行的指令了。

聰明的你一定會問,那麼PC中的初始值是怎麼被設定的呢?

在回答這個問題之前我們需要知道CPU執行的指令來自哪裡?是來自記憶體,廢話,記憶體中的指令是從磁碟中儲存的可執行程式載入過來的,磁碟中可執行程式是編譯器生成的,編譯器又是從哪裡生成的機器指令呢?答案就是我們定義的函式

注意是函式,函式被編譯後才會形成CPU執行的指令,那麼很自然的,我們該如何讓CPU執行一個函式呢?顯然我們只需要找到函式被編譯後形成的第一條指令就可以了,第一條指令就是函式入口。

現在你應該知道了吧,我們想要CPU執行一個函式,那麼只需要把該函式對應的第一條機器指令的地址寫入PC暫存器就可以了,這樣我們寫的函式就開始被CPU執行起來啦。

你可能會有疑問,這和執行緒有什麼關係呢?

從CPU到作業系統

上一小節中我們明白了CPU的工作原理,我們想讓CPU執行某個函式,那麼只需要把函式對應的第一條機器執行裝入PC暫存器就可以了,這樣即使沒有作業系統我們也可以讓CPU執行程式,雖然可行但這是一個非常繁瑣的過程,我們需要:

在記憶體中找到一塊大小合適的區域裝入程式找到函式入口,設定好PC暫存器讓CPU開始執行程式

這兩個步驟絕不是那麼容易的事情,如果每次在執行程式時程式設計師自己手動實現上述兩個過程會瘋掉的,因此聰明的程式設計師就會想幹脆直接寫個程式來自動完成上面兩個步驟吧。

機器指令需要載入到記憶體中執行,因此需要記錄下記憶體的起始地址和長度;同時要找到函式的入口地址並寫到PC暫存器中,想一想這是不是需要一個數據結構來記錄下這些資訊:

struct *** {   void* start_addr;   int len;      void* start_point;   ...};

接下來就是起名字時刻。

這個資料結構總要有個名字吧,這個結構體用來記錄什麼資訊呢?記錄的是程式在被載入到記憶體中的執行狀態,程式從磁碟載入到記憶體跑起來叫什麼好呢?乾脆就叫程序(Process)好了,我們的指導原則就是一定要聽上去比較神秘,總之大家都不容易弄懂就對了,我將其稱為“弄不懂原則”。

就這樣程序誕生了。

CPU執行的第一個函式也起個名字,第一個要被執行的函式聽起來比較重要,乾脆就叫main函式吧。

完成上述兩個步驟的程式也要起個名字,根據“弄不懂原則”這個“簡單”的程式就叫作業系統(Operating System)好啦。

就這樣作業系統誕生了,程式設計師要想執行程式再也不用自己手動載入一遍了。

現在程序和作業系統都有了,一切看上去都很完美。

從單核到多核,如何充分利用多核

人類的一大特點就是生命不息折騰不止,從單核折騰到了多核。

這時,假設我們想寫一個程式並且要分利用多核該怎麼辦呢?

有的同學可能會說不是有程序嗎,多開幾個程序不就可以了?聽上去似乎很有道理,但是主要存在這樣幾個問題:

程序是需要佔用記憶體空間的(從上一節能看到這一點),如果多個程序基於同一個可執行程式,那麼這些程序其記憶體區域中的內容幾乎完全相同,這顯然會造成記憶體的浪費計算機處理的任務可能是比較複雜的,這就涉及到了程序間通訊,由於各個程序處於不同的記憶體地址空間,程序間通訊天然需要藉助作業系統,這就在增大程式設計難度的同時也增加了系統開銷

該怎麼辦呢?

從程序到執行緒

讓我再來仔細的想一想這個問題,所謂程序無非就是記憶體中的一段區域,這段區域中儲存了CPU執行的機器指令以及函式執行時的堆疊資訊,要想讓程序執行,就把main函式的第一條機器指令地址寫入PC暫存器,這樣程序就執行起來了。

程序的缺點在於只有一個入口函式,也就是main函式,因此程序中的機器指令只能被一個CPU執行,那麼有沒有辦法讓多個CPU來執行同一個程序中的機器指令呢?

聰明的你應該能想到,既然我們可以把main函式的第一條指令地址寫入PC暫存器,那麼其它函式和main函式又有什麼區別呢?

答案是沒什麼區別,main函式的特殊之處無非就在於是CPU執行的第一個函式,除此之外再無特別之處,我們可以把PC暫存器指向main函式,就可以把PC暫存器指向任何一個函式

當我們把PC暫存器指向非main函式時,執行緒就誕生了

至此我們解放了思想,一個程序內可以有多個入口函式,也就是說屬於同一個程序中的機器指令可以被多個CPU同時執行

注意,這是一個和程序不同的概念,建立程序時我們需要在記憶體中找到一塊合適的區域以裝入程序,然後把CPU的PC暫存器指向main函式,也就是說程序中只有一個執行流

但是現在不一樣了,多個CPU可以在同一個屋簷下(程序佔用的記憶體區域)同時執行屬於該程序的多個入口函式,也就是說現在一個程序內可以有多個執行流了。

總是叫執行流好像有點太容易理解了,再次祭出”弄不懂原則“,起個不容易懂的名字,就叫執行緒吧。

這就是執行緒的由來。

作業系統為每個程序維護了一堆資訊,用來記錄程序所處的記憶體空間等,這堆資訊記為資料集A。

同樣的,作業系統也需要為執行緒維護一堆資訊,用來記錄執行緒的入口函式或者棧資訊等,這堆資料記為資料集B。

顯然資料集B要比資料A的量要少,同時不像程序,建立一個執行緒時無需去記憶體中找一段記憶體空間,因為執行緒是執行在所處程序的地址空間的,這塊地址空間在程式啟動時已經建立完畢,同時執行緒是程式在執行期間建立的(程序啟動後),因此當執行緒開始執行的時候這塊地址空間就已經存在了,執行緒可以直接使用。這就是為什麼各種教材上提的建立執行緒要比建立程序快的原因(當然還有其它原因)。

值得注意的是,有了執行緒這個概念後,我們只需要程序開啟後建立多個執行緒就可以讓所有CPU都忙起來,這就是所謂高效能、高併發的根本所在

很簡單,只需要創建出數量合適的執行緒就可以了。

另外值得注意的一點是,由於各個執行緒共享程序的記憶體地址空間,因此執行緒之間的通訊無需藉助作業系統,這給程式設計師帶來極大方便的同時也帶來了無盡的麻煩,多執行緒遇到的多數問題都出自於執行緒間通訊簡直太方便了以至於非常容易出錯。出錯的根源在於CPU執行指令時根本沒有執行緒的概念,多執行緒程式設計面臨的互斥同步問題需要程式設計師自己解決,關於互斥與同步問題限於篇幅就不詳細展開了,大部分的作業系統資料都有詳細講解。

最後需要提醒的是,雖然前面關於執行緒講解使用的圖中用了多個CPU,但不是說一定要有多核才能使用多執行緒,在單核的情況下一樣可以創建出多個執行緒,原因在於執行緒是作業系統層面的實現,和有多少個核心是沒有關係的,CPU在執行機器指令時也意識不到執行的機器指令屬於哪個執行緒。即使在只有一個CPU的情況下,作業系統也可以透過執行緒排程讓各個執行緒“同時”向前推進,方法就是將CPU的時間片在各個執行緒之間來回分配,這樣多個執行緒看起來就是“同時”運行了,但實際上任意時刻還是隻有一個執行緒在執行。

執行緒與記憶體

在前面的討論中我們知道了執行緒和CPU的關係,也就是把CPU的PC暫存器指向執行緒的入口函式,這樣執行緒就可以執行起來了,這就是為什麼我們建立執行緒時必須指定一個入口函式的原因。無論使用任何程式語言,建立一個執行緒大體相同:

// 設定執行緒入口函式DoSomethingthread = CreateThread(DoSomething);// 讓執行緒執行起來thread.Run();

那麼執行緒和記憶體又有什麼關聯呢?

我們知道函式在被執行的時產生的資料包括函式引數區域性變數返回地址等資訊,這些資訊是儲存在棧中的,執行緒這個概念還沒有出現時程序中只有一個執行流,因此只有一個棧,這個棧的棧底就是程序的入口函式,也就是main函式,假設main函式呼叫了funA,funcA又呼叫了funcB,如圖所示:

那麼有了執行緒以後了呢?

有了執行緒以後一個程序中就存在多個執行入口,即同時存在多個執行流,那麼只有一個執行流的程序需要一個棧來儲存執行時資訊,那麼很顯然有多個執行流時就需要有多個棧來儲存各個執行流的資訊,也就是說作業系統要為每個執行緒在程序的地址空間中分配一個棧,即每個執行緒都有獨屬於自己的棧,能意識到這一點是極其關鍵的。

同時我們也可以看到,建立執行緒是要消耗程序記憶體空間的,這一點也值得注意。

執行緒的使用

現在有了執行緒的概念,那麼接下來作為程式設計師我們該如何使用執行緒呢?

從生命週期的角度講,執行緒要處理的任務有兩類:長任務和短任務。

1、長任務,long-lived tasks

顧名思義,就是任務存活的時間很長,比如以我們常用的word為例,我們在word中編輯的文字需要儲存在磁碟上,往磁碟上寫資料就是一個任務,那麼這時一個比較好的方法就是專門建立一個寫磁碟的執行緒,該寫執行緒的生命週期和word程序是一樣的,只要開啟word就要創建出該寫執行緒,當用戶關閉word時該執行緒才會被銷燬,這就是長任務。

這種場景非常適合建立專用的執行緒來處理某些特定任務,這種情況比較簡單。

有長任務,相應的就有短任務。

2、短任務,short-lived tasks

這個概念也很簡單,那就是任務的處理時間很短,比如一次網路請求、一次資料庫查詢等,這種任務可以在短時間內快速處理完成。因此短任務多見於各種Server,像web server、database server、file server、mail server等,這也是網際網路行業的同學最常見的場景,這種場景是我們要重點討論的。

這種場景有兩個特點:一個是任務處理所需時間短;另一個是任務數量巨大

如果讓你來處理這種型別的任務該怎麼辦呢?

你可能會想,這很簡單啊,當server接收到一個請求後就建立一個執行緒來處理任務,處理完成後銷燬該執行緒即可,So easy。

這種方法通常被稱為thread-per-request,也就是說來一個請求就建立一個執行緒:

如果是長任務,那麼這種方法可以工作的很好,但是對於大量的短任務這種方法雖然實現簡單但是有這樣幾個缺點:

從前幾節我們能看到,執行緒是作業系統中的概念(這裡不討論使用者態執行緒實現、協程之類),因此建立執行緒天然需要藉助作業系統來完成,作業系統建立和銷燬執行緒是需要消耗時間的每個執行緒需要有自己獨立的棧,因此當建立大量執行緒時會消耗過多的記憶體等系統資源

這就好比你是一個工廠老闆(想想都很開心有沒有),手裡有很多訂單,每來一批訂單就要招一批工人,生產的產品非常簡單,工人們很快就能處理完,處理完這批訂單後就把這些千辛萬苦招過來的工人辭退掉,當有新的訂單時你再千辛萬苦的招一遍工人,幹活兒5分鐘招人10小時,如果你不是勵志要讓企業倒閉的話大概是不會這麼做到的,因此一個更好的策略就是招一批人後就地養著,有訂單時處理訂單,沒有訂單時大家可以閒待著。

這就是執行緒池的由來。

從多執行緒到執行緒池

執行緒池的概念是非常簡單的,無非就是建立一批執行緒,之後就不再釋放了,有任務就提交給這些執行緒處理,因此無需頻繁的建立、銷燬執行緒,同時由於執行緒池中的執行緒個數通常是固定的,也不會消耗過多的記憶體,因此這裡的思想就是複用、可控

執行緒池是如何工作的

可能有的同學會問,該怎麼給執行緒池提交任務呢?這些任務又是怎麼給到執行緒池中執行緒呢?

很顯然,資料結構中的佇列天然適合這種場景,提交任務的就是生產者,消費任務的執行緒就是消費者,實際上這就是經典的生產者-消費者問題

現在你應該知道為什麼作業系統課程要講、面試要問這個問題了吧,因為如果你對生產者-消費者問題不理解的話,本質上你是無法正確的寫出執行緒池的。

限於篇幅在這裡博主不打算詳細的講解生產者消費者問題,參考作業系統相關資料就能獲取答案。這裡博主打算講一講一般提交給執行緒池的任務是什麼樣子的。

一般來說提交給執行緒池的任務包含兩部分:1) 需要被處理的資料;2) 處理資料的函式

struct task {    void* data;     // 任務所攜帶的資料    handler handle; // 處理資料的方法}

(注意,你也可以把程式碼中的struct理解成class,也就是物件。)

執行緒池中的執行緒會阻塞在佇列上,當生產者向佇列中寫入資料後,執行緒池中的某個執行緒會被喚醒,該執行緒從佇列中取出上述結構體(或者物件),以結構體(或者物件)中的資料為引數並呼叫處理函式:

while(true) {  struct task = GetFromQueue(); // 從佇列中取出資料  task->handle(task->data);     // 處理資料}

以上就是執行緒池最核心的部分。

理解這些你就能明白執行緒池是如何工作的了。

執行緒池中執行緒的數量

現線上程池有了,那麼執行緒池中執行緒的數量該是多少呢?

在接著往下看前先自己想一想這個問題。

如果你能看到這裡說明還沒有睡著。

要知道執行緒池的執行緒過少就不能充分利用CPU,執行緒建立的過多反而會造成系統性能下降,記憶體佔用過多,執行緒切換造成的消耗等等。因此執行緒的數量既不能太多也不能太少,那到底該是多少呢?

回答這個問題,你需要知道執行緒池處理的任務有哪幾類,有的同學可能會說你不是說有兩類嗎?長任務和短任務,這個是從生命週期的角度來看的,那麼從處理任務所需要的資源角度看也有兩種型別,這就是沒事兒找抽型和。。啊不,是CPU密集型和I/O密集型。

1。CPU密集型

所謂CPU密集型就是說處理任務不需要依賴外部I/O,比如科學計算、矩陣運算等等。在這種情況下只要執行緒的數量和核數基本相同就可以充分利用CPU資源。

2、I/O密集型

這一類任務可能計算部分所佔用時間不多,大部分時間都用在了比如磁碟I/O、網路I/O等。

這種情況下就稍微複雜一些了,你需要利用效能測試工具評估出用在I/O等待上的時間,這裡記為WT(wait time),以及CPU計算所需要的時間,這裡記為CT(computing time),那麼對於一個N核的系統,合適的執行緒數大概是N * (1 + WT/CT),假設I/O等待時間和計算時間相同,那麼你大概需要2N個執行緒才能充分利用CPU資源,注意這只是一個理論值,具體設定多少需要根據真實的業務場景進行測試。

當然充分利用CPU不是唯一需要考慮的點,隨著執行緒數量的增多,記憶體佔用、系統排程、開啟的檔案數量、開啟的socker數量以及開啟的資料庫連結等等是都需要考慮的。

因此這裡沒有萬能公式,要具體情況具體分析

執行緒池不是萬能的

執行緒池僅僅是多執行緒的一種使用形式,因此多執行緒面臨的問題執行緒池同樣不能避免,像死鎖問題、race condition問題等等,關於這一部分同樣可以參考作業系統相關資料就能得到答案,所以基礎很重要呀老鐵們。

執行緒池使用的最佳實踐

執行緒池是程式設計師手中強大的武器,網際網路公司的各個server上幾乎都能見到執行緒池的身影,使用執行緒池前你需要考慮:

充分理解你的任務,是長任務還是短任務、是CPU密集型還是I/O密集型,如果兩種都有,那麼一種可能更好的辦法是把這兩類任務放到不同的執行緒池中,這樣也許可以更好的確定執行緒數量如果執行緒池中的任務有I/O操作,那麼務必對此任務設定超時,否則處理該任務的執行緒可能會一直阻塞下去執行緒池中的任務最好不要同步等待其它任務的結果總結

本節我們從CPU開始一路來到常用的執行緒池,從底層到上層、從硬體到軟體。注意,這裡通篇沒有出現任何特定的程式語言,執行緒不是語言層面的概念(依然不考慮使用者態執行緒),但是當你真正理解了執行緒後,相信你可以在任何一門語言下用好多執行緒,你需要理解的是道,此後才是術。

42
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 如何成為一名Windows高階工程師,那就從選單程式設計開始吧