18張圖讓你搞懂高併發中的線程與線程池,看完還不會你來打我!

一切要從CPU提及

你可能會有疑問,講多線程爲何要從CPU提及呢?緣由很簡單,在這裏沒有那些時髦的概念,你能夠更加清晰的看清問題的本質程序員

CPU並不知道線程、進程之類的概念。web

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的工做原理,咱們想讓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寄存器指向線程的入口函數,這樣線程就能夠運行起來了,這就是爲何咱們建立線程時必須指定一個入口函數的緣由。不管使用任何編程語言,建立一個線程大致相同:

// 設置線程入口函數DoSomething
thread = CreateThread(DoSomething);

// 讓線程運行起來
thread.Run();

那麼線程和內存又有什麼關聯呢?

咱們知道函數在被執行的時產生的數據包括函數參數局部變量返回地址等信息,這些信息是保存在棧中的,線程這個概念尚未出現時進程中只有一個執行流,所以只有一個棧,這個棧的棧底就是進程的入口函數,也就是main函數,假設main函數調用了funA,funcA又調用了funcB,如圖所示:

那麼有了線程之後了呢?

有了線程之後一個進程中就存在多個執行入口,即同時存在多個執行流,那麼只有一個執行流的進程須要一個棧來保存運行時信息,那麼很顯然有多個執行流時就須要有多個棧來保存各個執行流的信息,也就是說操做系統要爲每一個線程在進程的地址空間中分配一個棧,即每一個線程都有獨屬於本身的棧,能意識到這一點是極其關鍵的。

同時咱們也能夠看到,建立線程是要消耗進程內存空間的,這一點也值得注意。

線程的使用

如今有了線程的概念,那麼接下來做爲程序員咱們該如何使用線程呢?

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

一、長任務,long-lived tasks

顧名思義,就是任務存活的時間很長,好比以咱們經常使用的word爲例,咱們在word中編輯的文字須要保存在磁盤上,往磁盤上寫數據就是一個任務,那麼這時一個比較好的方法就是專門建立一個寫磁盤的線程,該寫線程的生命週期和word進程是同樣的,只要打開word就要建立出該寫線程,當用戶關閉word時該線程纔會被銷燬,這就是長任務。

這種場景很是適合建立專用的線程來處理某些特定任務,這種狀況比較簡單。

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

二、短任務,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資源。

二、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開始一路來到經常使用的線程池,從底層到上層、從硬件到軟件。注意,這裏通篇沒有出現任何特定的編程語言,線程不是語言層面的概念(依然不考慮用戶態線程),可是當你真正理解了線程後,相信你能夠在任何一門語言下用好多線程,你須要理解的是道,此後纔是術。

寫在最後

歡迎你們關注個人公衆號【風平浪靜如碼】,海量Java相關文章,學習資料都會在裏面更新,整理的資料也會放在裏面。

以爲寫的還不錯的就點個贊,加個關注唄!點關注,不迷路,持續更新!!!

相關文章
相關標籤/搜索