看完這篇還不懂線程與線程池你來打我

2021-02-24 菜鳥教程

你可能會有疑問,講多線程為什麼要從 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 -

相關焦點

  • 圖解|看完這篇還不懂高並發中的線程與線程池,你來打我!
    乾脆就叫進程(Process)好了,我們的指導原則就是一定要聽上去比較神秘,總之大家都不容易弄懂就對了,我將其稱為「弄不懂原則」。就這樣進程誕生了。CPU執行的第一個函數也起個名字,第一個要被執行的函數聽起來比較重要,乾脆就叫main函數吧。
  • 看完這篇還不懂高並發中的線程與線程池你來打我(內含20張圖)
    從這篇開始將會開啟高性能、高並發系列,本篇是該系列的開篇,主要關注多線程以及線程池。你可能會有疑問,講多線程為什麼要從CPU說起呢?原因很簡單,在這裡沒有那些時髦的概念,你可以更加清晰的看清問題的本質。CPU並不知道線程、進程之類的概念。
  • 面試官問我:線程池中多餘的線程是如何回收的?
    到達條件1處,符合條件,減少工作線程數量,並返回null,由外層結束這條線程。這裡的decrementWorkerCount()是自旋式的,一定會減1。3.2.2 任務還沒有完全執行完調用shutdown()之後,未執行完的任務要執行完畢,池子才能結束。
  • 1000個並發線程,10臺機器,每臺機器4核,設計線程池大小
    這個 javadoc 是 Doug Lea 老爺子親自寫的,你都不拜讀拜讀?為了防止你偷懶,我把老爺子寫的粘下來,我們一句句的看。關於這幾個參數,我通過這篇文章再說最後一次。如果以後的文章我要是再講這幾個參數,我就不叫 why 哥,以後你們就叫我小王吧。
  • 阿里P8大佬總結:Java線程池詳解,看了你就懂
    本篇文章主要介紹了線程池作用、如何創建線程池、自定義線程工廠和拒絕策略以及深入分析不推薦直接使用Executors靜態工廠直接創建線程池的緣由,讓大家可以對線程池有個更深刻的認識,而不是只停留在盲目去用的層面。
  • C++11實現的100行線程池
    以下是正文線程池C++帶有線程操作,異步操作,就是沒有線程池,至於線程池的概念,我先搜一下別人的解釋:一般而言,線程池有以下幾個部分:1. 完成主要任務的一個或多個線程。2. 用於調度管理的管理線程。我來講講人話:你的函數需要在多線程中運行,但是你又不能每來一個函數就開啟一個線程,所以你就需要固定的N個線程來跑執行,但是有的線程還沒有執行完,有的又在空閒,如何分配任務呢,你就需要封裝一個線程池來完成這些操作,有了線程池這層封裝,你就只需要告訴它開啟幾個線程,然後直接塞任務就行了,然後通過一定的機制獲取執行結果。
  • 七個方面帶你玩轉Java線程池
    所以,如果任務很多,並且每個任務執行的時間比較短,可以調大時間,提高線程的利用率,不然線程剛執行完一個任務,還沒來得及處理下一個任務,線程就被終止,而需要線程的時候又再次創建,剛創建完不久執行任務後,沒多少時間又終止,會導致資源浪費。  注意:這裡指的是核心線程池以外的線程。
  • C 11實現的100行線程池
    以下是正文線程池C 帶有線程操作,異步操作,就是沒有線程池,至於線程池的概念,我先搜一下別人的解釋:一般而言,線程池有以下幾個部分:1. 完成主要任務的一個或多個線程。2. 用於調度管理的管理線程。我來講講人話:你的函數需要在多線程中運行,但是你又不能每來一個函數就開啟一個線程,所以你就需要固定的N個線程來跑執行,但是有的線程還沒有執行完,有的又在空閒,如何分配任務呢,你就需要封裝一個線程池來完成這些操作,有了線程池這層封裝,你就只需要告訴它開啟幾個線程,然後直接塞任務就行了,然後通過一定的機制獲取執行結果。
  • Java之線程池的簡單介紹
    通過使用線程池,可以使線程復用,就是執行完一個任務,不銷毀,繼續執行其他任務。其實線程池就相當於一個容器(集合),裡面有很多線程。就像上面這張圖中,線程1執行任務1,線程2執行任務2......但任務4沒有對應的線程,這時候我們沒有必要創建新的線程,只需要等待其它任務執行完,將線程歸還到線程池中然後調用線程就可以了。這樣的話,可以降低資源消耗,減少了創建和銷毀線程的次數,每一個線程都可以被重複利用。
  • 【線程池】java線程池ThreadPoolExecutor
    對於一般的應用,我們不用通過構造函數來創建線程池,而是用一些封裝過的工具方法,這些方法設置了大多數參數的預設值。只有對線程池的特性有更高的要求時,才直接使用構造函數。    · 關於線程創建    線程池中的線程是由ThreadFactory創建。如果不特別指定,會使用Executors.defaultThreadFactory創建位於同一個線程組,相同優先級(NORM_PRIORITY)的非守護線程。如果由你來指定ThreadFactory,你可以定製線程名字,線程組,優先級,是否為守護線程等屬性。
  • 講真 這次絕對讓你輕鬆學習線程池
    經理直接跟老王 說誰讓你來的你找誰去我這辦理不了。5分鐘線程池的核心工作流程講解完畢,更細節的知識看下面。什麼是線程池簡單理解就是 預先創建好一定數量的線程對象,存入緩衝池中,需要用的時候直接從緩衝池中取出,用完之後不要銷毀,還回到緩衝池中。
  • Java線程池詳解及常用方法
    前言最近被問到了線程池的相關問題。於是準備開始寫一些多線程相關的文章。這篇將介紹一下線程池的基本使用。(4)newSingleThreadExecutor 創建一個單線程化的線程池執行任務。Executors的壞處正常來說,我們不應該使用這種方式創建線程池,應該使用ThreadPoolExecutor來創建線程池。
  • Java線程池其實看懂了也很簡單
    所以我很建議任何理論我們都需要自己去探究一下才好,自己實踐過的才有自己的理解而不是死記硬背,這樣才會經久不忘。線程池屬於開發中常見的一種池化技術,這類的池化技術的目的都是為了提高資源的利用率和提高效率,類似的HttpClient連接池,資料庫連接池等。
  • 10分鐘了解線程池,阿里再也不擔心我線程池資源耗盡了
    線程池:避免了創建線程和銷毀線程的資源損耗。Executors提供四種線程池:newCachedThreadPool :緩存線程池,如果線程池長度超過處理需要,可回收空閒線程,若無可回收,則新建線程。newFixedThreadPool :定長線程池,可控制線程最大並發數,超出的線程會在隊列中等待。newScheduledThreadPool :計劃線程池,支持定時及周期性任務執行。
  • 從使用到原理,探究Java線程池
    當我們往線程池裡提交任務時,如果線程池內的線程數少於corePoolSize,則會直接創建新的線程處理任務;如果線程池的線程數達到了corePoolSize,並且存儲隊列沒滿,則會把任務放到workQueue任務存儲隊列裡;如果存儲隊列也滿了,但是線程數還沒有達到maxPoolSize,這個時候就會繼續創建線程執行任務
  • JAVA 線程池ThreadPoolExcutor原理探究
    概論線程池(英語:thread pool):一種線程使用模式。線程過多會帶來調度開銷,進而影響緩存局部性和整體性能。而線程池維護著多個線程,等待著監督管理者分配可並發執行的任務。這避免了在處理短時間任務時創建與銷毀線程的代價。線程池不僅能夠保證內核的充分利用,還能防止過分調度。
  • 阿里面試官鬼得很,問我為什麼他們阿里要禁用Executors創建線程池?
    作者:何甜甜在嗎來源:http://rrd.me/eUh6V看阿里巴巴開發手冊並發編程這塊有一條:線程池不允許使用
  • Java中線程池的簡單使用
    (這個例子的前提是這5個人都不趕時間的前提下)這裡的共享電源出租點就相當於線程池。線程池就是用來管理線程的在沒有接觸線程池之前,我們使用線程的時候就去創建一個線程,然後startup()就可以假設,你現在有一個while n(n=10000)的循環,每一次循環都要啟動一個線程去計算n的因數,這樣頻繁而且大量的創建線程,系統的效率會大幅下降
  • Java中線程池,你真的會用嗎?
    線程池的實現原理》這篇文章中,我們介紹過了Java中線程池的常見用法以及基本原理。在文中有這樣一段描述:可以通過Executors靜態工廠構建線程池,但一般不建議這樣使用。關於這個問題,在那篇文章中並沒有深入的展開。作者之所以這麼說,是因為這種創建線程池的方式有很大的隱患,稍有不慎就有可能導致線上故障。本文我們就來圍繞這個問題來分析一下為什麼JDK自身提供的構建線程池的方式並不建議使用?
  • java線程池核心類ThreadPoolExecutor概述
    我們使用線程池來解決這個問題,讓線程運行完不立即銷毀,並且重複使用,繼續執行其他的任務。使用線程池來管理線程,一方面使線程的創建更加規範,可以合理控制開闢線程的數量;另一方面線程的細節管理交給線程池處理,優化了資源的開銷。