從根上理解高性能、高併發(一):深入計算機底層,理解線程與線程池

1、系列文章引言

1.1 文章目的

作爲即時通訊技術的開發者來說,高性能、高併發相關的技術概念早就瞭然與胸,什麼線程池、零拷貝、多路複用、事件驅動、epoll等等名詞信手拈來,又或許你對具有這些技術特徵的技術框架比如:Java的NettyPhpworkman、Go的nget等熟練掌握。但真正到了面視或者技術實踐過程中遇到無法釋懷的疑惑時,方知自已所掌握的不過是皮毛。

返璞歸真、迴歸本質,這些技術特徵背後的底層原理到底是什麼?如何能通俗易懂、毫不費力真正透徹理解這些技術背後的原理,正是《從根上理解高性能、高併發》系列文章所要分享的。

1.2 文章源起

我整理了相當多有關IM、消息推送等即時通訊技術相關的資源和文章,從最開始的開源IM框架MobileIMSDK,到網絡編程經典鉅著《TCP/IP詳解》的在線版本,再到IM開發綱領性文章《新手入門一篇就夠:從零開發移動端IM》,以及網絡編程由淺到深的《網絡編程懶人入門》、《腦殘式網絡編程入門》、《高性能網絡編程》、《不爲人知的網絡編程》系列文章。

越往知識的深處走,越覺得對即時通訊技術瞭解的太少。於是後來,爲了讓開發者門更好地從基礎電信技術的角度理解網絡(尤其移動網絡)特性,我跨專業收集整理了《IM開發者的零基礎通信技術入門》系列高階文章。這系列文章已然是普通即時通訊開發者的網絡通信技術知識邊界,加上之前這些網絡編程資料,解決網絡通信方面的知識盲點基本夠用了。

對於即時通訊IM這種系統的開發來說,網絡通信知識確實非常重要,但迴歸到技術本質,實現網絡通信本身的這些技術特徵:包括上面提到的線程池、零拷貝、多路複用、事件驅動等等,它們的本質是什麼?底層原理又是怎樣?這就是整理本系列文章的目的,希望對你有用。

1.3 文章目錄

從根上理解高性能、高併發(一):深入計算機底層,理解線程與線程池》(* 本文

從根上理解高性能、高併發(二):深入操作系統,理解I/O與零拷貝技術

從根上理解高性能、高併發(三):深入操作系統,徹底理解I/O多路複用

從根上理解高性能、高併發(四):深入操作系統,徹底理解同步與異步

從根上理解高性能、高併發(五):深入操作系統,理解高併發中的協程

從根上理解高性能、高併發(六):通俗易懂,高性能服務器到底是如何實現的

1.4 本篇概述

本篇是該系列文章的開篇,主要是從CPU這一層來講解多線程以及線程池原理,力求避免複雜的技術概念羅列,儘量做到通俗易懂、老少皆宜。

2、本文作者

應作者要求,不提供真名,也不提供個人照片。

本文作者主要技術方向爲互聯網後端、高併發高性能服務器、檢索引擎技術,網名是“碼農的荒島求生”,公衆號“碼農的荒島求生”。感謝作者的無私分享。

3、一切要從CPU說起

你可能會有疑問,講多線程爲什麼要從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執行起來啦。

你可能會有疑問,這和線程有什麼關係呢?

4、從CPU到操作系統

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

我們需要:

  • 1)在內存中找到一塊大小合適的區域裝入程序;
  • 2)找到函數入口,設置好PC寄存器讓CPU開始執行程序。

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

機器指令需要加載到內存中執行,因此需要記錄下內存的起始地址和長度;同時要找到函數的入口地址並寫到PC寄存器中,想一想這是不是需要一個數據結構來記錄下這些信息。

數據結構大致如下:

struct *** {

   void* start_addr;

   intlen;

   void* start_point;

   ...

};

接下來就是起名字時刻。

這個數據結構總要有個名字吧,這個結構體用來記錄什麼信息呢?記錄的是程序在被加載到內存中的運行狀態,程序從磁盤加載到內存跑起來叫什麼好呢?乾脆就叫進程(Process)好了,我們的指導原則就是一定要聽上去比較神祕,總之大家都不容易弄懂就對了,我將其稱爲“弄不懂原則”。

就這樣進程誕生了。

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

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

就這樣操作系統誕生了,程序員要想運行程序再也不用自己手動加載一遍了。

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

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

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

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

有的同學可能會說不是有進程嗎,多開幾個進程不就可以了?

聽上去似乎很有道理,但是主要存在這樣幾個問題:

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

該怎麼辦呢?

6、從進程到線程

讓我再來仔細的想一想這個問題,所謂進程無非就是內存中的一段區域,這段區域中保存了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的時間片在各個線程之間來回分配,這樣多個線程看起來就是“同時”運行了,但實際上任意時刻還是隻有一個線程在運行。

7、線程與內存

在前面的討論中我們知道了線程和CPU的關係,也就是把CPU的PC寄存器指向線程的入口函數,這樣線程就可以運行起來了,這就是爲什麼我們創建線程時必須指定一個入口函數的原因。

無論使用任何編程語言,創建一個線程大體相同:

// 設置線程入口函數DoSomething

thread = CreateThread(DoSomething);

// 讓線程運行起來

thread.Run();

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

我們知道函數在被執行的時產生的數據包括:函數參數、局部變量、返回地址等信息。這些信息是保存在棧中的,線程這個概念還沒有出現時進程中只有一個執行流,因此只有一個棧,這個棧的棧底就是進程的入口函數,也就是main函數。

假設main函數調用了funA,funcA又調用了funcB,如圖所示:

那麼有了線程以後了呢?

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

同時我們也可以看到,創建線程是要消耗進程內存空間的,這一點也值得注意。

8、線程的使用

現在有了線程的概念,那麼接下來作爲程序員我們該如何使用線程呢?

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

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小時,如果你不是勵志要讓企業倒閉的話大概是不會這麼做到的。

因此一個更好的策略就是招一批人後就地養着,有訂單時處理訂單,沒有訂單時大家可以閒待著。

這就是線程池的由來。

9、從多線程到線程池

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

10、線程池是如何工作的

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

很顯然,數據結構中的隊列天然適合這種場景,提交任務的就是生產者,消費任務的線程就是消費者,實際上這就是經典的生產者-消費者問題。

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

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

一般來說提交給線程池的任務包含兩部分:

  • 1) 需要被處理的數據;
  • 2) 處理數據的函數。

僞碼描述一下:

struct task {

    void* data;     // 任務所攜帶的數據

    handler handle; // 處理數據的方法

}

(注意:你也可以把代碼中的struct理解成class,也就是對象)

線程池中的線程會阻塞在隊列上,當生產者向隊列中寫入數據後,線程池中的某個線程會被喚醒,該線程從隊列中取出上述結構體(或者對象),以結構體(或者對象)中的數據爲參數並調用處理函數。

僞碼如下:

while(true) {

  struct task = GetFromQueue(); // 從隊列中取出數據

  task->handle(task->data);     // 處理數據

}

以上就是線程池最核心的部分。

理解這些你就能明白線程池是如何工作的了。

11、線程池中線程的數量

現在線程池有了,那麼線程池中線程的數量該是多少呢?

在接着往下看前先自己想一想這個問題。如果你能看到這裏說明還沒有睡着。

要知道線程池的線程過少就不能充分利用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數量以及打開的數據庫鏈接等等是都需要考慮的。

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

12、線程池不是萬能的

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

13、線程池使用的最佳實踐

線程池是程序員手中強大的武器,互聯網公司的各個server上幾乎都能見到線程池的身影。

但使用線程池前你需要考慮:

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

14、本文小結

本文我們從CPU開始一路來到常用的線程池,從底層到上層、從硬件到軟件。

注意:這裏通篇沒有出現任何特定的編程語言,線程不是語言層面的概念(依然不考慮用戶態線程),但是當你真正理解了線程後,相信你可以在任何一門語言下用好多線程,你需要理解的是道,此後纔是術。

希望這篇文章對大家理解線程以及線程池有所幫助。

接下的一篇將是與線程池密切配合實現高性能、高併發的又一關鍵技術《從根上理解高性能、高併發(二):深入操作系統,理解I/O與零拷貝技術》,敬請期待。(本文已同步發佈於:http://www.52im.net/thread-3272-1-1.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章