你可能會有疑問,講多線程為什麼要從 CPU 說起呢?原因很簡單,在這裡沒有那些時髦的概念,你可以更加清晰地看清問題的本質。
CPU 並不知道線程、進程之類的概念。
CPU 只知道兩件事:
1. 從內存中取出指令;
2. 執行指令,然後回到 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 執行某個函數,那麼只需要把函數對應的第一條機器執行裝入 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,funA 又調用了 funB,如圖所示:
那麼有了線程以後呢?
有了線程以後,一個進程中就存在多個執行入口,即同時存在多個執行流,那麼只有一個執行流的進程需要一個棧來保存運行時信息,那麼很顯然有多個執行流時就需要有多個棧來保存各個執行流的信息,也就是說作業系統要為每個線程在進程的地址空間中分配一個棧,即每個線程都有獨屬於自己的棧,能意識到這一點是極其關鍵的。
同時我們也可以看到,創建線程是要消耗進程內存空間的,這一點也值得注意。
現在有了線程的概念,那麼接下來作為程式設計師的我們該如何使用線程呢?
從生命周期的角度講,線程要處理的任務有兩類:長任務和短任務。
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,也就是說來一個請求就創建一個線程:
如果是長任務,那麼這種方法可以工作得很好,但是對於大量的短任務,這種方法雖然實現簡單但是有這樣幾個缺點:
1. 從前幾節我們能看到,線程是作業系統中的概念(這裡不討論用戶態線程實現、協程之類),因此創建線程天然需要藉助作業系統來完成,作業系統創建和銷毀線程是需要消耗時間的。
2. 每個線程需要有自己獨立的棧,因此當創建大量線程時會消耗過多的內存等系統資源。
這就好比你是一個工廠老闆(想想都很開心有沒有),手裡有很多訂單,每來一批訂單就要招一批工人,生產的產品非常簡單,工人們很快就能處理完,處理完這批訂單後就把這些千辛萬苦招過來的工人辭退掉,當有新的訂單時你再千辛萬苦地招一遍工人,幹活兒 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 開始一路來到常用的線程池,從底層到上層、從硬體到軟體。注意,這裡通篇沒有出現任何特定的程式語言,線程不是語言層面的概念(依然不考慮用戶態線程),但是當你真正理解了線程後,相信你可以在任何一門語言下用好多線程,你需要理解的是道,此後才是術。
- END -