天天看點

如何了解高性能伺服器的高性能、高并發?

作者:深度學習GPU伺服器

線程 | 同步 | 異步 | 異構

協程 | 程序 | 同構 | 線程池

目前,随着“東數西算”政策的落地,算力時代正在全面開啟。随着機器學習、深度學習的快速發展,人們對高性能伺服器這一概念不再陌生。伴随着資料分析、資料挖掘數目的不斷增大,傳統的風冷散熱方式已經不足以滿足散熱需要,這就需要新興的液冷散熱技術以此滿足節能減排、靜音高效的需求。

作為國内品牌伺服器廠商,藍海大腦液冷GPU伺服器擁有大規模并行處理能力和無與倫比的靈活性。它主要用于為計算密集型應用程式提供足夠的處理能力。GPU的優勢在于可以由CPU運作應用程式代碼,同時圖形處理單元(GPU)可以處理大規模并行架構的計算密集型任務。GPU伺服器是遙感測繪、醫藥研發、生命科學和高性能計算的理想選擇。

本文将為大家全面介紹高性能GPU伺服器所涉及技術以及如何搭建。

如何了解高性能伺服器的高性能、高并發?

線程與線程池

下面将從CPU開始路來到常用的線程池,從底層到上層、從硬體到軟體。

一、CPU

對此大家可能會有疑問,講多線程為什麼要從CPU開始?實際上CPU并沒有線程、程序之類的概念。CPU所作的就是從記憶體中取出指令——執行指令,然後回到1。

如何了解高性能伺服器的高性能、高并發?

1、CPU從哪裡取出指令

就是我們熟知的程式計數器,在這裡大家不要把寄存器想的太神秘,可以簡單的将寄存器了解為記憶體,隻不過存取速度更快而已。

2、PC寄存器中存放的是什麼?

指令(CPU将要執行的下一條指令)在記憶體中的位址

如何了解高性能伺服器的高性能、高并發?

3、誰來改變PC寄存器中的指令位址?

由于大部分情況下CPU都是一條接一條順序執行,是以之前PC寄存器中的位址預設是自動加1。但當遇到if、else時,這種順序執行就被打破了,為了正确的跳轉到需要執行的指令,CPU在執行這類指令時會根據計算結果來動态改變PC寄存器中的值。

4、PC中的初始值是怎麼被設定的?

CPU執行的指令來自記憶體,記憶體中的指令來自于磁盤中儲存的可執行程式加載,磁盤中可執行程式是由編譯器生成的,編譯器從定義的函數生成的機器指令。

如何了解高性能伺服器的高性能、高并發?

二、從CPU到作業系統

從上面我們明白了CPU的工作原理,如果想讓CPU執行某個函數,隻需把函數對應的第一條機器執行裝入PC寄存器就可以了,這樣即使沒有作業系統也可以讓CPU執行程式,雖然可行但這是一個非常繁瑣的過程(1、在記憶體中找到一塊大小合适的區域裝入程式;2、找到函數入口,設定好PC寄存器讓CPU開始執行程式)。

機器指令由于需加載到記憶體中執行是以需要記錄下記憶體的起始位址和長度;同時要找到函數的入口位址并寫到PC寄存器中。

資料結構大緻如下:

1

2

3

4

5

6

7

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

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

如果一個程式需要充分利用多核就會遇到以下問題:

1、程序是需要占用記憶體空間的(從上一節到這一節),如果多個程序基于同一個可執行程式,那麼這些程序其記憶體區域中的内容幾乎完全相同,顯然會造成記憶體浪費;

2、當計算機處理的任務比較複雜時就會涉及到程序間通信,但是由于各個程序處于不同的記憶體位址空間,而程序間通信需要借助作業系統,在增大程式設計難度的同時也增加了系統開銷。

四、從程序到線程

程序到線程即記憶體中的一段區域,該區域儲存了CPU執行的機器指令以及函數運作時的堆棧資訊。要想讓程序運作,就把main函數的第一條機器指令位址寫入PC寄存器。

如何了解高性能伺服器的高性能、高并發?

程序的缺點在于隻有一個入口函數(main函數),程序中的機器指令隻能被一個CPU執行,那麼有沒有辦法讓多個CPU來執行同一個程序中的機器指令呢?可以将main函數的第一條指令位址寫入PC寄存器。main函數和其它函數沒什麼差別,其特殊之處無非在于是CPU執行的第一個函數。

當把PC寄存器指向非main函數時,線程就誕生了。

如何了解高性能伺服器的高性能、高并發?

至此一個程序内可以有多個入口函數,也就是說屬于同一個程序中的機器指令可以被多個CPU同時執行。

如何了解高性能伺服器的高性能、高并發?

多個CPU可以在同一個屋檐下(程序占用的記憶體區域)同時執行屬于該程序的多個入口函數。作業系統為每個程序維護一堆資訊,用來記錄程序所處的記憶體空間等,這堆資訊記為資料集A。同樣的,作業系統也為線程維護一堆資訊,用來記錄線程的入口函數或者棧資訊等,這堆資料記為資料集B。

顯然資料集B要比資料A的量要少,由于線程是運作在所處程序的位址空間在程式啟動時已經建立完畢,同時線程是程式在運作期間建立的(程序啟動後),是以當線程開始運作的時候這塊位址空間就已經存在了,線程可以直接使用。

值得一提的是,有了線程這個概念後,隻需要程序開啟後建立多個線程就可以讓所有CPU都忙起來,這就是所謂高性能、高并發的根本所在。

如何了解高性能伺服器的高性能、高并發?

另外值得注意的一點是:由于各個線程共享程序的記憶體位址空間,是以線程之間的通信無需借助作業系統,這給從業人員帶來了便利同時也有不足之處。多線程遇到的多數問題都出自于線程間通信太友善以至于非常容易出錯。出錯的根源在于CPU執行指令時沒有線程的概念,多線程程式設計面臨的互斥與同步問題需要解決。

最後需要注意的是:雖然前面關于線程講解使用的圖中用了多個CPU,但并不一定要有多核才能使用多線程,在單核的情況下一樣可以建立出多個線程,主要是由于線程是作業系統層面的實作,和有多少個核心是沒有關系的,CPU在執行機器指令時也意識不到執行的機器指令屬于哪個線程。即使在隻有一個CPU的情況下,作業系統也可以通過線程排程讓各個線程“同時”向前推進,即将CPU的時間片在各個線程之間來回配置設定,這樣多個線程看起來就是“同時”運作了,但實際上任意時刻還是隻有一個線程在運作。

五、線程與記憶體

前面介紹了線程和CPU的關系,也就是把CPU的PC寄存器指向線程的入口函數,這樣線程就可以運作起來了。

無論使用任何程式設計語言,建立一個線程大體相同:

1

2

3

4

5

// 設定線程入口函數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等。該場景有任務處理所需時間短和任務數量巨大的兩個特點。

如何了解高性能伺服器的高性能、高并發?

這種工作方法可對長任務來說很好,但是對于大量的短任務來說雖然實作簡單但卻有其缺點:

1)線程是作業系統中的概念,是以建立線程需要借助作業系統來完成,作業系統建立和銷毀線程是需要消耗時間的;

2)每個線程需要有自己獨立的棧,是以當建立大量線程時會消耗過多的記憶體等系統資源。

這就好比一個工廠老闆手裡有很多訂單,每來一批訂單就要招一批勞工,生産的産品非常簡單,勞工們很快就能處理完,處理完這批訂單後就把這些勞工辭掉,當有新的訂單時再招一遍勞工,幹活兒5分鐘招人10小時,如果你不是勵志要讓企業倒閉的話大概是不會這麼做到的。是以一個更好的政策就是招一批人後就地養着,有訂單時處理訂單,沒有訂單時大家可以待着。

這就是線程池的由來。

七、從多線程到線程池

線程池的無非就是建立一批線程之後就不再釋放,有任務就送出給線程處理,是以無需頻繁的建立、銷毀線程,同時由于線程池中的線程個數通常是固定的,也不會消耗過多的記憶體。

八、線程池是如何工作的?

一般來說送出給線程池的任務包含需要被處理的資料和處理資料的函數兩部分。

僞碼描述一下:

1

2

3

4

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

線程池中的線程會阻塞在隊列上,當從業人員向隊列中寫入資料後,線程池中的某個線程會被喚醒,該線程從隊列中取出上述結構體(或者對象),以結構體(或者對象)中的資料為參數并調用處理函數。

僞碼如下:

1

2

3

4

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數量以及打開的資料庫連結等等是都需要考慮的。是以沒有萬能公式,要具體情況具體分析。

九、使用線程前需要考慮的因素

1、充分了解任務是長任務還是短任務、是CPU密集型還是I/O密集型,如果兩種都有,那麼一種可能更好的辦法是把這兩類任務放到不同的線程池。

2、如果線程池中的任務有I/O操作,那麼務必對此任務設定逾時,否則處理該任務的線程可能會一直阻塞下去;

4、線程池中的任務不要同步等待其它任務的結果。

I/O與零拷貝技術

一、什麼是I/O?

I/O就是簡單的資料Copy,如果資料從外部裝置copy到記憶體中就是Input。如果資料是記憶體copy到外部裝置則是Output。記憶體與外部裝置之間不嫌麻煩的來回copy資料就是Input and Output,簡稱I/O(Input/Output)。

如何了解高性能伺服器的高性能、高并發?

二、I/O與CPU

簡單來說:CPU執行機器指令的速度是納秒級别的,而通常的I/O比如磁盤操作,一次磁盤seek大概在毫秒級别,是以如果我們把CPU的速度比作戰鬥機的話,那麼I/O操作的速度就是肯德雞。

如何了解高性能伺服器的高性能、高并發?

也就是說當程式跑起來時(CPU執行機器指令),其速度是要遠遠快于I/O速度。那麼接下來的問題就是二者速度相差這麼大,該如何設計、更加合理的高效利用系統資源呢?

既然有速度差異,程序在執行完I/O操作前不能繼續向前推進,那就隻有等待(wait)。

三、執行I/O時底層都發生了什麼

在支援線程的作業系統中,實際上被排程的是線程而不是程序,為了更加清晰的了解I/O過程,暫時假設作業系統隻有程序這樣的概念,先不去考慮線程。

如下圖所示,現在記憶體中有兩個程序,程序A和程序B,目前程序A正在運作。如下圖所示:

如何了解高性能伺服器的高性能、高并發?

程序A中有一段讀取檔案的代碼,不管在什麼語言中通常定義一個用來裝資料的buff,然後調用read之類的函數。

1 read(buff);

注意:與CPU執行指令的速度相比,I/O操作操作是非常慢的,是以作業系統是不可能把寶貴的CPU計算資源浪費在無謂的等待上的。由于外部裝置執行I/O操作是相當慢的,是以在I/O操作完成之前程序是無法繼續向前推進的,這就是所謂的阻塞,即block。

隻需記錄下目前程序的運作狀态并把CPU的PC寄存器指向其它程序的指令就作業系統檢測到程序向I/O裝置發起請求後就暫停程序的運作。程序有暫停就會有繼續執行,是以作業系統必須儲存被暫停的程序以備後續繼續執行,顯然我們可以用隊列來儲存被暫停執行的程序。

如何了解高性能伺服器的高性能、高并發?

如上圖所示,作業系統已經向磁盤發送I/O請求,是以磁盤driver開始将磁盤中的資料copy到程序A的buff中。雖然這時程序A已經被暫停執行了,但這并不妨礙磁盤向記憶體中copy資料。過程如下圖所示:

如何了解高性能伺服器的高性能、高并發?

作業系統中除了有阻塞隊列之外也有就緒隊列,所謂就緒隊列是指隊列裡的程序準備就緒可以被CPU執行了。在即使隻有1個核的機器上也可以建立出成千上萬個程序,CPU不可能同時執行這麼多的程序,是以必然存在這樣的程序,即使其一切準備就緒也不能被配置設定到計算資源,這樣的程序就被放到了就緒隊列。

如何了解高性能伺服器的高性能、高并發?

由于就緒隊列中還有嗷嗷待哺的程序B,是以當程序A被暫停執行後CPU是不可以閑下來的。這時作業系統開始在就緒隊列中找下一個可以執行的程序,也就是這裡的程序B。此時作業系統将程序B從就緒隊列中取出,找出程序B被暫停時執行到的機器指令的位置,然後将CPU的PC寄存器指向該位置,這樣程序B就開始運作啦。

如何了解高性能伺服器的高性能、高并發?

如上圖所示,程序B在被CPU執行,磁盤在向程序A的記憶體空間中copy資料,資料copy和指令執行在同時進行,在作業系統的排程下,CPU、磁盤都得到了充分的利用。此後磁盤将全部資料都copy到了程序A的記憶體中,作業系統接收到磁盤中斷後發現資料copy完畢,程序A重新獲得繼續運作的資格,作業系統把程序A從阻塞隊列放到了就緒隊列當中。

如何了解高性能伺服器的高性能、高并發?

此後程序B繼續執行,程序A繼續等待,程序B執行了一會兒後作業系統認為程序B執行的時間夠長了,是以把程序B放到就緒隊列,把程序A取出并繼續執行。作業系統把程序B放到的是就緒隊列,是以程序B被暫停運作僅僅是因為時間片到了而不是因為發起I/O請求被阻塞。

如何了解高性能伺服器的高性能、高并發?

四、零拷貝(Zero-copy)

值得注意的一點是:上面的講解中直接把磁盤資料copy到了程序空間中,但實際上一般情況下I/O資料是要首先copy到作業系統内部,然後作業系統再copy到程序空間中。性能要求很高的場景其實也是可以繞過作業系統直接進行資料copy,這種繞過作業系統直接進行資料copy的技術被稱為零拷貝(Zero-copy)。

I/O多路複用

本文我們詳細講解什麼是I/O多路複用以及使用方法,這其中以epoll為代表的I/O多路複用(基于事件驅動)技術使用非常廣泛,實際上你會發現但凡涉及到高并發、高性能的場景基本上都能見到事件驅動的程式設計方法。

一、什麼是檔案?

在Linux世界中檔案是一個很簡單的概念,隻需要将其了解為一個N byte的序列就可以了:

b1, b2, b3, b4, ....... bN

實際上所有的I/O裝置都被抽象了,一切皆檔案(Everything is File),磁盤、網絡資料、終端,甚至程序間通信工具管道pipe等都被當做檔案對待。

如何了解高性能伺服器的高性能、高并發?

常用的I/O操作接口一般有以下幾類:

1、打開檔案,open;

2、改變讀寫位置,seek;

3、檔案讀寫,read、write;

4、關閉檔案,close。

二、什麼是檔案描述符?

在上文中我們講到:要想進行I/O讀操作,像磁盤資料,需要指定一個buff用來裝入資料。在Linux世界要想使用檔案,需要借助一個号碼,根據“弄不懂原則”,這個号碼就被稱為了檔案描述符(file descriptors),在Linux世界中鼎鼎大名,其道理和上面那個排隊号碼一樣。檔案描述僅僅就是一個數字而已,但是通過這個數字我們可以操作一個打開的檔案。

如何了解高性能伺服器的高性能、高并發?

有了檔案描述符,程序可以對檔案一無所知,比如檔案在磁盤的什麼位置、加載到記憶體中又是怎樣管理的等等,這些資訊統統交由作業系統打理,程序無需關心,作業系統隻需要給程序一個檔案描述符就足夠了。

三、檔案描述符太多了怎麼辦?

從上文中我們知道,所有I/O操作都可以通過檔案樣的概念來進行,這當然包括網絡通信。

如果你有一個IM伺服器,當三次握手建議長連接配接成功以後,我們會調用accept來擷取一個連結,調用該函數我們同樣會得到一個檔案描述符,通過這個檔案描述符就可以處理用戶端發送的聊天消息并且把消息轉發給接收者。

也就是說,通過這個描述符就可以和用戶端進行通信了:

// 通過accept擷取用戶端的檔案描述符

int conn_fd = accept(...);

Server端的處理邏輯通常是接收用戶端消息資料,然後執行轉發(給接收者)邏輯:

if(read(conn_fd, msg_buff) > 0) {

do_transfer(msg_buff);

}

既然主題是高并發,那麼Server端就不可能隻和一個用戶端通信,而是可能會同時和成千上萬個用戶端進行通信。這時需要處理不再是一個描述符這麼簡單,而是有可能要處理成千上萬個描述符。為了不讓問題一上來就過于複雜先簡單化,假設隻同時處理兩個用戶端的請求。

有的同學可能會說,這還不簡單,這樣寫不就行了:

if(read(socket_fd1, buff) > 0) { // 處理第一個

do_transfer();

}

if(read(socket_fd2, buff) > 0) { // 處理第二個

do_transfer();

如果此時沒有資料可讀那麼程序會被阻塞而暫停運作。這時我們就無法處理第二個請求了,即使第二個請求的資料已經就位,這也就意味着處理某一個用戶端時由于程序被阻塞導緻剩下的所有其它用戶端必須等待,在同時處理幾萬用戶端的server上。這顯然是不能容忍的。

聰明的你一定會想到使用多線程:為每個用戶端請求開啟一個線程,這樣一個用戶端被阻塞就不會影響到處理其它用戶端的線程了。注意:既然是高并發,那麼我們要為成千上萬個請求開啟成千上萬個線程嗎,大量建立銷毀線程會嚴重影響系統性能。

那麼這個問題該怎麼解決呢?

這裡的關鍵點在于:我們事先并不知道一個檔案描述對應的I/O裝置是否是可讀的、是否是可寫的,在外設的不可讀或不可寫的狀态下進行I/O隻會導緻程序阻塞被暫停運作。

三、I/O多路複用(I/O multiplexing)

multiplexing一詞多用于通信領域,為了充分利用通信線路,希望在一個信道中傳輸多路信号,要想在一個信道中傳輸多路信号就需要把這多路信号結合為一路,将多路信号組合成一個信号的裝置被稱為Multiplexer(多路複用器),顯然接收方接收到這一路組合後的信号後要恢複原先的多路信号,這個裝置被稱為Demultiplexer(多路分用器)。

如下圖所示:

如何了解高性能伺服器的高性能、高并發?

所謂I/O多路複用指的是這樣一個過程:

1、拿到一堆檔案描述符(不管是網絡相關的、還是磁盤檔案相關等等,任何檔案描述符都可以);

2、通過調用某個函數告訴核心:“這個函數你先不要傳回,你替我監視着這些描述符,當這堆檔案描述符中有可以進行I/O讀寫操作的時候你再傳回”;

3、當調用的這個函數傳回後就能知道哪些檔案描述符可以進行I/O操作了。

三、I/O多路複用三劍客

由于調用這些I/O多路複用函數時如果任何一個需要監視的檔案描述符都不可讀或者可寫那麼程序會被阻塞暫停執行,直到有檔案描述符可讀或者可寫才繼續運作。是以Linux上的select、poll、epoll都是阻塞式I/O,也就是同步I/O。

1、select:初出茅廬

在select I/O多路複用機制下,需要把想監控的檔案描述集合通過函數參數的形式告訴select,然後select将這些檔案描述符集合拷貝到核心中。為了減少這種資料拷貝帶來的性能損耗,Linux核心對集合的大小做了限制,并規定使用者監控的檔案描述集合不能超過1024個,同時當select傳回後,僅僅能知道有些檔案描述符可以讀寫了。

select的特點

1、能照看的檔案描述符數量有限,不能超過1024個;

2、使用者給檔案描述符需要拷貝的核心中;

3、隻能告訴有檔案描述符滿足要求但不知道是哪個。

2、poll:小有所成

poll和select是非常相似,相對于select的優化僅僅在于解決檔案描述符不能超過1024個的限制,select和poll都會随着監控的檔案描述數量增加而性能下降,是以不适合高并發場景。

3、epoll:獨步天下

在select面臨的三個問題中,檔案描述數量限制已經在poll中解決了,剩下的兩個問題呢?

針對拷貝問題epoll使用的政策是各個擊破與共享記憶體。檔案描述符集合的變化頻率比較低,select和poll頻繁的拷貝整個集合,epoll通過引入epoll_ctl很體貼的做到了隻操作那些有變化的檔案描述符。同時epoll和核心還成為了好朋友,共享了同一塊記憶體,這塊記憶體中儲存的就是那些已經可讀或者可寫的的檔案描述符集合,這樣就減少了核心和程式的拷貝開銷。

針對需要周遊檔案描述符才能知道哪個可讀可寫的問題,epoll使用的政策是在select和poll機制下:程序要親自下場去各個檔案描述符上等待,任何一個檔案描述可讀或者可寫就喚醒程序,但是程序被喚醒後也是一臉懵逼并不知道到底是哪個檔案描述符可讀或可寫,還要再從頭到尾檢查一遍。在epoll機制下程序不需要親自下場了,程序隻要等待在epoll上,epoll代替程序去各個檔案描述符上等待,當哪個檔案描述符可讀或者可寫的時候就告訴epoll,由epoll記錄。

在epoll這種機制下,實際上利用的就是“不要打電話給我,有需要我會打給你”這種政策,程序不需要一遍一遍麻煩的問各個檔案描述符,而是翻身做主人了——“你們這些檔案描述符有哪個可讀或者可寫了主動報上來”。

同步與異步

一、同步與異步場景:打電話與發郵件

1、同步

通常打電話時都是一個人在說另一個人聽,一個人在說的時候另一個人等待,等另一個人說完後再接着說,是以在這個場景中你可以看到,“依賴”、“關聯”、“等待”這些關鍵詞出現了,是以打電話這種溝通方式就是所謂的同步。

如何了解高性能伺服器的高性能、高并發?

2、異步

另一種常用的溝通方式是郵件,因為沒有人傻等着你寫郵件什麼都不做,是以你可以慢慢悠悠的寫,當你在寫郵件時收件人可以去做一些像摸摸魚啊、上個廁所、和同時抱怨一下為什麼十一假期不放兩周之類有意義的事情。同時當你寫完郵件發出去後也不需要幹巴巴的等着對方回複什麼都不做,你也可以做一些像摸魚之類這樣有意義的事情。

如何了解高性能伺服器的高性能、高并發?

在這裡,你寫郵件别人摸魚,這兩件事又在同時進行,收件人和發件人都不需要互相等待,發件人寫完郵件的時候簡單的點個發送就可以了,收件人收到後就可以閱讀啦,收件人和發件人不需要互相依賴、不需要互相等待。是以郵件這種溝通方式就是異步的。

二、程式設計中的同步調用

一般的函數調用都是同步的,就像這樣:

1

2

3

4

5

6

funcA() { // 等待函數funcB執行完成 funcB(); // 繼續接下來的流程}

funcA調用funcB,那麼在funcB執行完前,funcA中的後續代碼都不會被執行,也就是說funcA必須等待funcB執行完成,如下圖所示。

如何了解高性能伺服器的高性能、高并發?

從上圖中可以看出,在funcB運作期間funcA什麼都做不了,這就是典型的同步。一般來說,像這種同步調用,funcA和funcB是運作在同一個線程中的,但值得注意的是即使運作在兩個不能線程中的函數也可以進行同步調用,像我們進行IO操作時實際上底層是通過系統調用的方式向作業系統送出請求。

如何了解高性能伺服器的高性能、高并發?

如上圖所示,隻有當read函數傳回後程式才可以被繼續執行。和上面的同步調用不同的是,函數和被調函數運作在不同的線程中。由此我們可以得出結論,同步調用和函數與被調函數是否運作在同一個線程是沒有關系的。在這裡需要再次強調同步方式下函數和被調函數無法同時進行。

三、程式設計中的異步調用

有同步調用就有異步調用。一般來說異步調用總是和I/O操作等耗時較高的任務如影随形,像磁盤檔案讀寫、網絡資料的收發、資料庫操作等。

在這裡以磁盤檔案讀取為例,在read函數的同步調用方式下,檔案讀取完之前調用方是無法繼續向前推進的,但如果read函數可以異步調用情況就不一樣了。假如read函數可以異步調用的話,即使檔案還沒有讀取完成,read函數也可以立即傳回。

如何了解高性能伺服器的高性能、高并發?

如上圖所示,在異步調用方式下,調用方不會被阻塞,函數調用完成後可以立即執行接下來的程式。這時異步的重點在于調用方接下來的程式執行可以和檔案讀取同時進行。值得注意的是異步調用對于程式員來說在了解上是一種負擔,代碼編寫上更是一種負擔,總的來說,上帝在為你打開一扇門的時候會适當的關上一扇窗戶。

有的同學可能會問,在同步調用下,調用方不再繼續執行而是暫停等待,被調函數執行完後很自然的就是調用方繼續執行,那麼異步調用下調用方怎知道被調函數是否執行完成呢?這就分為調用方根本就不關心執行結果和調用方需要知道執行結果兩種情況。

第一種情況比較簡單,無需讨論。

第二種情況下就比較有趣了,通常有兩種實作方式:

1、通知機制

當任務執行完成後發送信号來通知調用方任務完成(這裡的信号有很多實作方式:Linux中的signal,或使用信号量等機制都可實作);

2、回調機制:

也就是常說的callback。

四、具體的程式設計例子中了解同步和異步

以常見Web服務為例來說明這個問題。一般來說Web Server接收到使用者請求後會有一些典型的處理邏輯,最常見的就是資料庫查詢(當然,你也可以把這裡的資料庫查詢換成其它I/O操作,比如磁盤讀取、網絡通信等),在這裡假定處理一次使用者請求需要經過步驟A、B、C,然後讀取資料庫,資料庫讀取完成後需要經過步驟D、E、F。

其中步驟A、B、C和D、E、F不需要任何I/O,也就是說這六個步驟不需要讀取檔案、網絡通信等,涉及到I/O操作的隻有資料庫查詢這一步。一般來說Web Server有主線程和資料庫處理線程兩個典型的線程。

首先我們來看下最簡單的實作方式,也就是同步。

這種方式最為自然也最為容易了解:

01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

17

// 主線程main_thread() { A; B; C; 發送資料庫查詢請求; D; E; F;}// 資料庫線程DataBase_thread() { while(1) { 處理資料庫讀取請求; 傳回結果; }}

主線程在發出資料庫查詢請求後就會被阻塞而暫停運作,直到資料庫查詢完畢後面的D、E、F才可以繼續運作,這就是最為典型的同步方法。

如何了解高性能伺服器的高性能、高并發?

如上圖所示,主線程中會有“空隙”,這個空隙就是主線程的“休閑時光”,主線程在這段休閑時光中需要等待資料庫查詢完成才能繼續後續處理流程。在這裡主線程就好比監工的老闆,資料庫線程就好比苦逼搬磚的程式員,在搬完磚前老闆什麼都不做隻是緊緊的盯着你,等你搬完磚後才去忙其它事情。

1、異步情況:主線程不關心資料庫操作結果

如下圖所示,主線程根本就不關心資料庫是否查詢完畢,資料庫查詢完畢後自行處理接下來的D、E、F三個步驟。

如何了解高性能伺服器的高性能、高并發?

一個請求通常需要經過七個步驟,其中前三個是在主線程中完成的,後四個是在資料庫線程中完成的,資料庫線程通過回調函數查完資料庫後處理D、E、F幾個步驟。

僞碼如下:

1

2

3

4

5

void handle_DEF_after_DB_query () { D; E; F;}

主線程處理請求和資料庫處理查詢請求可以同時進行,從系統性能上看能更加充分的利用系統資源,更加快速的處理請求;從使用者的角度看,系統的響應也會更加迅速。這就是異步的高效之處。但可以看出,異步程式設計并不如同步來的容易了解,系統可維護性上也不如同步模式。

2、異步情況:主線程關心資料庫操作結果

如下圖所示,資料庫線程需要将查詢結果利用通知機制發送給主線程,主線程在接收到消息後繼續處理上一個請求的後半部分。

如何了解高性能伺服器的高性能、高并發?

由此我們可以看到:ABCDEF幾個步驟全部在主線中處理,同時主線程同樣也沒有了“休閑時光”,隻不過在這種情況下資料庫線程是比較清閑的,從這裡并沒有上一種方法高效,但是依然要比同步模式下要高效。但是要注意的是并不是所有的情況下異步都一定比同步高效,還需要結合具體業務以及IO的複雜度具體情況具體分析。

高并發中的協程

協程是高性能高并發程式設計中不可或缺的技術,包括即時通訊(IM系統)在内的網際網路産品應用産品中應用廣泛,比如号稱支撐微信海量使用者的背景架構就是基于協程打造的。而且越來越多的現代程式設計語言都将協程視為最重要的語言技術特征,已知的包括:Go、Python、Kotlin等。

一、從普通函數到協程

1

2

3

4

5

6

7

void func() { print("a") 暫停并傳回 print("b") 暫停并傳回 print("c")}

普通函數下,隻有當執行完print("c")這句話後函數才會傳回,但是在協程下當執行完print("a")後func就會因“暫停并傳回”這段代碼傳回到調用函數。

我寫一個return也能傳回,就像這樣:

1

2

3

4

5

6

7

void func() { print("a") return print("b") 暫停并傳回 print("c")}

直接寫一個return語句确實也能傳回,但這樣寫的話return後面的代碼都不會被執行到了。

協程之是以神奇就神奇在當我們從協程傳回後還能繼續調用該協程,并且是從該協程的上一個傳回點後繼續執行。

就好比孫悟空說一聲“定”,函數就被暫停了:

1

2

3

4

5

6

7

void func() { print("a") 定 print("b") 定 print("c")}

這時我們就可以傳回到調用函數,當調用函數什麼時候想起該協程後可以再次調用該協程,該協程會從上一個傳回點繼續執行。值得注意的是當普通函數傳回後,程序的位址空間中不會再儲存該函數運作時的任何資訊,而協程傳回後,函數的運作時資訊是需要儲存下來的。

二、“Talk is cheap,show me the code”

在python語言中,這個“定”字同樣使用關鍵詞yield。這樣我們的func函數就變成了:

1

2

3

4

5

6

7

void func() { print("a") yield print("b") yield print("c")}

這時我們的func就不再是簡簡單單的函數了,而是更新成為了協程,那麼我們該怎麼使用呢?

很簡單:

1

2

3

4

5

def A(): co = func() # 得到該協程 next(co) # 調用協程 print("in function A") # do something next(co) # 再次調用該協程

雖然func函數沒有return語句,也就是說雖然沒有傳回任何值,但是我們依然可以寫co = func()這樣的代碼,意思是說co就是拿到的協程了。

接下來調用該協程,使用next(co),運作函數A看看執行到第3行的結果是什麼:

1 a

顯然,和預期一樣協程func在print("a")後因執行yield而暫停并傳回函數A。

接下來是第4行,這個毫無疑問,A函數在做一些自己的事情,是以會列印:

1

2

ain function A

接下來是重點的一行,當執行第5行再次調用協程時該列印什麼呢?

如果func是普通函數,那麼會執行func的第一行代碼,也就是列印a。

但func不是普通函數,而是協程,我們之前說過,協程會在上一個傳回點繼續運作,是以這裡應該執行的是func函數第一個yield之後的代碼,也就是 print("b")。

1

2

3

ain function Ab

三、圖形化解釋

為了更加徹底的了解協程,我們使用圖形化的方式再看一遍。

首先是普通的函數調用:

如何了解高性能伺服器的高性能、高并發?

在該圖中方框内表示該函數的指令序列,如果該函數不調用任何其它函數,那麼應該從上到下依次執行,但函數中可以調用其它函數,是以其執行并不是簡單的從上到下,箭頭線表示執行流的方向。

從上圖中可以看到:首先來到funcA函數,執行一段時間後發現調用了另一個函數funcB,這時控制轉移到該函數,執行完成後回到main函數的調用點繼續執行。這是普通的函數調用。

接下來是協程:

如何了解高性能伺服器的高性能、高并發?

在這裡依然首先在funcA函數中執行,運作一段時間後調用協程,協程開始執行,直到第一個挂起點,此後就像普通函數一樣傳回funcA函數,funcA函數執行一些代碼後再次調用該協程。

三、函數隻是協程的一種特例

和普通函數不同的是,協程能知道自己上一次執行到了哪裡。協程會在函數被暫停運作時儲存函數的運作狀态,并可以從儲存的狀态中恢複并繼續運作。

四、協程的曆史

協程這種概念早在1958年就已經提出來了,要知道這時線程的概念都還沒有提出來。到了1972年,終于有程式設計語言實作了這個概念,這兩門程式設計語言就是Simula 67 以及Scheme。但協程這個概念始終沒有流行起來,甚至在1993年還有人考古一樣專門寫論文挖出協程這種古老的技術。

因為這一時期還沒有線程,如果你想在作業系統寫出并發程式那麼你将不得不使用類似協程這樣的技術,後來線程開始出現,作業系統終于開始原生支援程式的并發執行,就這樣,協程逐漸淡出了程式員的視線。直到近些年,随着網際網路的發展,尤其是移動網際網路時代的到來,服務端對高并發的要求越來越高,協程再一次重回技術主流,各大程式設計語言都已經支援或計劃開始支援協程。

五、協程到底如何實作?

讓我們從問題的本質出發來思考這個問題協程的本質是什麼呢?協程之是以可以被暫停也可以繼續,那麼一定要記錄下被暫停時的狀态,也就是上下文,當繼續運作的時候要恢複其上下文(狀态)函數運作時所有的狀态資訊都位于函數運作時棧中。如下圖所示,函數運作時棧就是需要儲存的狀态,也就是所謂的上下文。

如何了解高性能伺服器的高性能、高并發?

從上圖中可以看出,該程序中隻有一個線程,棧區中有四個棧幀,main函數調用A函數,A函數調用B函數,B函數調用C函數,當C函數在運作時整個程序的狀态就如圖所示。

再仔細想一想,為什麼我們要這麼麻煩的來回copy資料呢?我們需要做的是直接把協程的運作需要的棧幀空間直接開辟在堆區中,這樣都不用來回copy資料了,如下圖所示。

如何了解高性能伺服器的高性能、高并發?

從上圖中可以看到該程式中開啟了兩個協程,這兩個協程的棧區都是在堆上配置設定的,這樣我們就可以随時中斷或者恢複協程的執行了。程序位址空間最上層的棧區現在的作用是用來儲存函數棧幀的,隻不過這些函數并不是運作在協程而是普通線程中的。

在上圖中實際上共有一個普通線程和兩個協程3個執行流。雖然有3個執行流但我們建立了幾個線程呢?答案是:一個線程。

使用協程理論上我們可以開啟無數并發執行流,隻要堆區空間足夠,同時還沒有建立線程的開銷,所有協程的排程、切換都發生在使用者态,這就是為什麼協程也被稱作使用者态線程的原因所在。是以即使建立了N多協程,但在作業系統看來依然隻有一個線程,也就是說協程對作業系統來說是不可見的。

這也許是為什麼協程這個概念比線程提出的要早的原因,可能是寫普通應用的程式員比寫作業系統的程式員最先遇到需要多個并行流的需求,那時可能都還沒有作業系統的概念,或者作業系統沒有并行這種需求,是以非作業系統程式員隻能自己動手實作執行流,也就是協程。

六、協程技術概念小結

1、協程是比線程更小的執行單元

協程是比線程更小的一種執行單元可以認為是輕量級的線程。之是以說輕的其中一方面的原因是協程所持有的棧比線程要小很多,java當中會為每個線程配置設定1M左右的棧空間,而協程可能隻有幾十或者幾百K,棧主要用來儲存函數參數、局部變量和傳回位址等資訊。

我們知道而線程的排程是在作業系統中進行的,而協程排程則是在使用者空間進行的,是開發人員通過調用系統底層的執行上下文相關api來完成的。有些語言,比如nodejs、go在語言層面支援了協程,而有些語言,比如C,需要使用第三方庫才可以擁有協程的能力。

由于線程是作業系統的最小執行單元,是以也可以得出,協程是基于線程實作的,協程的建立、切換、銷毀都是在某個線程中來進行的。使用協程是因為線程的切換成本比較高,而協程在這方面很有優勢。

2、協程的切換到底為什麼很廉價?

關于這個問題,回顧一下線程切換的過程:

1)線程在進行切換的時候,需要将CPU中的寄存器的資訊存儲起來,然後讀入另外一個線程的資料,這個會花費一些時間;

2)CPU的高速緩存中的資料,也可能失效,需要重新加載;

3)線程的切換會涉及到使用者模式到核心模式的切換,據說每次模式切換都需要執行上千條指令,很耗時。

實際上協程的切換之是以快的原因主要是:

1)在切換的時候,寄存器需要儲存和加載的資料量比較小;

2)高速緩存可以有效利用;

3)沒有使用者模式到核心模式的切換操作;

4)更有效率的排程,因為協程是非搶占式的,前一個協程執行完畢或者堵塞,才會讓出CPU,而線程則一般使用了時間片的算法,會進行很多沒有必要的切換。

高性能伺服器到底是如何實作的?

當你在閱讀文章的時候,有沒有想過,伺服器是怎麼把這篇文章發送給你的呢?說起來很簡單不就是一個使用者請求嗎?伺服器根據請求從資料庫中撈出這篇文章,然後通過網絡發回去嗎。其實有點複雜伺服器端到底是如何并行處理成千上萬個使用者請求的呢?這裡面又涉及到哪些技術呢?

一、多程序

曆史上最早出現也是最簡單的一種并行處理多個請求的方法就是利用多程序。比如在Linux世界中,可以使用fork、exec等系統調用建立多個程序,可以在父程序中接收使用者的連接配接請求,然後建立子程序去處理使用者請求。

如何了解高性能伺服器的高性能、高并發?

1、多程序并行處理的優點

1)程式設計簡單,非常容易了解;

2)由于各個程序的位址空間是互相隔離的,是以一個程序崩潰後并不會影響其它程序;

3)充分利用多核資源。

2、多程序并行處理的缺點

1)各個程序位址空間互相隔離,這一優點也會變成缺點,那就是程序間要想通信就會變得比較困難,你需要借助程序間通信機制,想一想你現在知道哪些程序間通信機制,然後讓你用代碼實作呢?顯然,程序間通信程式設計相對複雜,而且性能也是一大問題;

2)建立程序開銷是比線程要大的,頻繁的建立銷毀程序無疑會加重系統負擔。

二、多線程

由于線程共享程序位址空間,是以線程間通信天然不需要借助任何通信機制,直接讀取記憶體就好了。線程建立銷毀的開銷也變小了,要知道線程就像寄居蟹一樣,房子(位址空間)都是程序的,自己隻是一個租客,是以非常的輕量級,建立銷毀的開銷也非常小。

我們可以為每個請求建立一個線程,即使一個線程因執行I/O操作——比如讀取資料庫等——被阻塞暫停運作也不會影響到其它線程。

如何了解高性能伺服器的高性能、高并發?

由于線程共享程序位址空間,這在為線程間通信帶來便利的同時也帶來了無盡的麻煩。正是由于線程間共享位址空間,是以一個線程崩潰會導緻整個程序崩潰退出,同時線程間通信簡直太簡單了,簡單到線程間通信隻需要直接讀取記憶體就可以了,也簡單到出現問題也極其容易,死鎖、線程間的同步互斥、等等,這些極容易産生bug,無數程式員寶貴的時間就有相當一部分用來解決多線程帶來的無盡問題。

雖然線程也有缺點,但是相比多程序來說,線程更有優勢,但想單純的利用多線程就能解決高并發問題也是不切實際的。因為雖然線程建立開銷相比程序小,但依然也是有開銷的,對于動辄數萬數十萬的連結的高并發伺服器來說,建立數萬個線程會有性能問題,這包括記憶體占用、線程間切換,也就是排程的開銷。

三、事件驅動:Event Loop

到目前為止,提到“并行”二字就會想到程序、線程。但是并行程式設計隻能依賴這兩項技術嗎?并不是這樣的!還有另一項并行技術廣泛應用在GUI程式設計以及伺服器程式設計中,這就是近幾年非常流行的事件驅動程式設計:event-based concurrency。

大家不要覺得這是一項很難懂的技術,實際上事件驅動程式設計原理上非常簡單。

這一技術需要兩種原料:

1)event;

2)處理event的函數,這一函數通常被稱為event handler;

如何了解高性能伺服器的高性能、高并發?

由于對于網絡通信伺服器來說,處理一個使用者請求時大部分時間其實都用在了I/O操作上,像資料庫讀寫、檔案讀寫、網絡讀寫等。當一個請求到來,簡單處理之後可能就需要查詢資料庫等I/O操作,我們知道I/O是非常慢的,當發起I/O後我們大可以不用等待該I/O操作完成就可以繼續處理接下來的使用者請求。是以一個event loop可以同時處理多個請求。

如何了解高性能伺服器的高性能、高并發?

四、事件來源:IO多路複用

IO多路複用技術通過一次監控多個檔案描述,當某個“檔案”(實際可能是im網絡通信中socket)可讀或者可寫的時候我們就能同時處理多個檔案描述符啦。

這樣IO多路複用技術就成了event loop的原材料供應商,源源不斷的給我們提供各種event,這樣關于event來源的問題就解決了。

如何了解高性能伺服器的高性能、高并發?

五、問題:阻塞式IO

當我們進行IO操作,比如讀取檔案時,如果檔案沒有讀取完成,那麼我們的程式(線程)會被阻塞而暫停執行,這在多線程中不是問題,因為作業系統還可以排程其它線程。但是在單線程的event loop中是有問題的,原因就在于當我們在event loop中執行阻塞式IO操作時整個線程(event loop)會被暫停運作,這時作業系統将沒有其它線程可以排程,因為系統中隻有一個event loop在處理使用者請求,這樣當event loop線程被阻塞暫停運作時所有使用者請求都沒有辦法被處理。你能想象當伺服器在處理其它使用者請求讀取資料庫導緻你的請求被暫停嗎?

如何了解高性能伺服器的高性能、高并發?

是以:在基于事件驅動程式設計時有一條注意事項,那就是不允許發起阻塞式IO。有的同學可能會問,如果不能發起阻塞式IO的話,那麼該怎樣進行IO操作呢?

六、解決方法:非阻塞式IO

為克服阻塞式IO所帶來的問題,現代作業系統開始提供一種新的發起IO請求的方法,這種方法就是異步IO。對應的,阻塞式IO就是同步IO,關于同步和異步詳見上文。

異步IO時,假設調用aio_read函數(具體的異步IO API請參考具體的作業系統平台),也就是異步讀取,當我們調用該函數後可以立即傳回,并繼續其它事情,雖然此時該檔案可能還沒有被讀取,這樣就不會阻塞調用線程了。此外,作業系統還會提供其它方法供調用線程來檢測IO操作是否完成。

七、基于事件驅動并行程式設計的難點

雖然有異步IO來解決event loop可能被阻塞的問題,但是基于事件程式設計依然是困難的。

首先event loop是運作在一個線程中的,顯然一個線程是沒有辦法充分利用多核資源的,有的同學可能會說那就建立多個event loop執行個體不就可以了,這樣就有多個event loop線程了,但是這樣一來多線程問題又會出現。

其次在于程式設計方面,異步程式設計需要結合回調函數(這種程式設計方式需要把處理邏輯分為兩部分:一部分調用方自己處理,另一部分在回調函數中處理),這一程式設計方式的改變加重了程式員在了解上的負擔,基于事件程式設計的項目後期會很難擴充以及維護。

八、更好的方法

有沒有一種方法既能結合同步IO的簡單了解又不會因同步調用導緻線程被阻塞呢?答案是肯定的,這就是使用者态線程(user level thread),也就是大名鼎鼎的協程。

雖然基于事件程式設計有這樣那樣的缺點,但是在當今的高性能高并發伺服器上基于事件程式設計方式依然非常流行,但已經不是純粹的基于單一線程的事件驅動了,而是 event loop + multi thread + user level thread。

程序、線程、協程

一、什麼是程序?

1、基本常識

計算機的核心是CPU,它承擔了所有的計算任務;作業系統是計算機的管理者,它負責任務的排程、資源的配置設定和管理,統領整個計算機硬體;應用程式則是具有某種功能的程式,程式是運作于作業系統之上的。

程序是一個具有一定獨立功能的程式在一個資料集上的一次動态執行的過程,是作業系統進行資源配置設定和排程的一個獨立機關,是應用程式運作的載體。程序是一種抽象的概念,從來沒有統一的标準定義。

程序一般由程式、資料集合和程序控制塊三部分組成:

  • 程式用于描述程序要完成的功能,是控制程序執行的指令集;
  • 資料集合是程式在執行時所需要的資料和工作區;
  • 程式控制塊(Program Control Block,簡稱PCB),包含程序的描述資訊和控制資訊,是程序存在的唯一标志。

程序的特點:

  • 動态性:程序是程式的一次執行過程,是臨時的,有生命期的,是動态産生,動态消亡的;
  • 并發性:任何程序都可以同其他程序一起并發執行;
  • 獨立性:程序是系統進行資源配置設定和排程的一個獨立機關;
  • 結構性:程序由程式、資料和程序控制塊三部分組成。

2、為什麼要有多程序?

多程序目的是提高cpu的使用率。假設隻有一個程序(先不談多線程),從作業系統的層面看,我們使用列印機的步驟有如下:

1)使用CPU執行程式,去硬碟讀取需要列印的檔案,然後CPU會長時間的等待,直到硬碟讀寫完成;

2)使用CPU執行程式,讓列印機列印這些内容,然後CPU會長時間的等待,等待列印結束。

在這樣的情況下:其實CPU的使用率其實非常的低。

列印一個檔案從頭到尾需要的時間可能是1分鐘,而cpu使用的時間總和可能加起來隻有幾秒鐘。而後面如果單程序執行遊戲的程式的時候,CPU也同樣會有大量的空閑時間。

使用多程序後:

當CPU在等待硬碟讀寫檔案,或者在等待列印機列印的時候,CPU可以去執行遊戲的程式,這樣CPU就能盡可能高的提高使用率。

再具體一點說,其實也提高了效率。因為在等待列印機的時候,這時候顯示卡也是閑置的,如果用多程序并行的話,遊戲程序完全可以并行使用顯示卡,并且與列印機之間也不會互相影響。

3、總結

程序直覺點說是儲存在硬碟上的程式運作以後,會在記憶體空間裡形成一個獨立的記憶體體,這個記憶體體有自己獨立的位址空間,有自己的堆,上級挂靠機關是作業系統。作業系統會程序為機關,配置設定系統資源(CPU時間片、記憶體等資源),程序是資源配置設定的最小機關。

二、什麼是線程?

1、基本常識

早期作業系統中并沒有線程的概念,程序是能擁有資源和獨立運作的最小機關,也是程式執行的最小機關。任務排程采用的是時間片輪轉的搶占式排程方式,而程序是任務排程的最小機關,每個程序有各自獨立的一塊記憶體,使得各個程序之間記憶體位址互相隔離。後來随着計算機的發展,對CPU的要求越來越高,程序之間的切換開銷較大,已經無法滿足越來越複雜的程式的要求了。于是就發明了線程。

線程是程式執行中一個單一的順序控制流程:

1)程式執行流的最小單元

2)處理器排程和分派的基本機關

一個程序可以有一個或多個線程,各個線程之間共享程式的記憶體空間(也就是所在程序的記憶體空間)。一個标準的線程由線程ID、目前指令指針(PC)、寄存器和堆棧組成。而程序由記憶體空間(代碼、資料、程序空間、打開的檔案)和一個或多個線程組成。

如何了解高性能伺服器的高性能、高并發?

如上圖所示,在任務管理器的程序一欄裡,有道詞典和有道雲筆記就是程序,而在程序下又有着多個執行不同任務的線程。

2、任務排程

線程是什麼?要了解這個概念,需要先了解一下作業系統的一些相關概念。大部分作業系統(如Windows、Linux)的任務排程是采用時間片輪轉的搶占式排程方式。在一個程序中:當一個線程任務執行幾毫秒後,會由作業系統的核心(負責管理各個任務)進行排程,通過硬體的計數器中斷處理器,讓該線程強制暫停并将該線程的寄存器放入記憶體中,通過檢視線程清單決定接下來執行哪一個線程,并從記憶體中恢複該線程的寄存器,最後恢複該線程的執行,進而去執行下一個任務。

上述過程中任務執行的那一小段時間叫做時間片,任務正在執行時的狀态叫運作狀态,被暫停的線程任務狀态叫做就緒狀态,意為等待下一個屬于它的時間片的到來。

這種方式保證了每個線程輪流執行,由于CPU的執行效率非常高,時間片非常短,在各個任務之間快速地切換,給人的感覺就是多個任務在“同時進行”,這也就是我們所說的并發(别覺得并發有多高深,它的實作很複雜,但它的概念很簡單,就是一句話:多個任務同時執行)。

如何了解高性能伺服器的高性能、高并發?

3、程序與線程的差別

程序與線程的關系

1)線程是程式執行的最小機關,而程序是作業系統配置設定資源的最小機關;

2)一個程序由一個或多個線程組成,線程是一個程序中代碼的不同執行路線;

3)程序之間互相獨立,但同一程序下的各個線程之間共享程式的記憶體空間(包括代碼段、資料集、堆等)及一些程序級的資源(如打開檔案和信号),某程序内的線程在其它程序不可見;

4)線程上下文切換比程序上下文切換要快得多。

如何了解高性能伺服器的高性能、高并發?

▲ 程序與線程的資源共享關系

如何了解高性能伺服器的高性能、高并發?

▲ 單線程與多線程的關系

總之線程和程序都是一種抽象的概念,線程是一種比程序更小的抽象,線程和程序都可用于實作并發。

在早期的作業系統中并沒有線程的概念,程序是能擁有資源和獨立運作的最小機關,也是程式執行的最小機關。它相當于一個程序裡隻有一個線程,程序本身就是線程。是以線程有時被稱為輕量級程序。

後來随着計算機的發展,對多個任務之間上下文切換的效率要求越來越高,就抽象出一個更小的概念——線程,一般一個程序會有多個(也可以是一個)線程。

  

如何了解高性能伺服器的高性能、高并發?

4、多線程與多核

上面提到的時間片輪轉的排程方式說一個任務執行一小段時間後強制暫停去執行下一個任務,每個任務輪流執行。很多作業系統的書都說“同一時間點隻有一個任務在執行”。其實“同一時間點隻有一個任務在執行”這句話是不準确的,至少它是不全面的。那多核處理器的情況下,線程是怎樣執行呢?這就需要了解核心線程。

多核(心)處理器是指在一個處理器上內建多個運算核心進而提高計算能力,也就是有多個真正并行計算的處理核心,每一個處理核心對應一個核心線程。核心線程(Kernel Thread,KLT)就是直接由作業系統核心支援的線程,這種線程由核心來完成線程切換,核心通過操作排程器對線程進行排程,并負責将線程的任務映射到各個處理器上。

一般一個處理核心對應一個核心線程,比如單核處理器對應一個核心線程,雙核處理器對應兩個核心線程,四核處理器對應四個核心線程。

現在的電腦一般是雙核四線程、四核八線程,是采用超線程技術将一個實體處理核心模拟成兩個邏輯處理核心,對應兩個核心線程,是以在作業系統中看到的CPU數量是實際實體CPU數量的兩倍,如你的電腦是雙核四線程,打開“任務管理器 -> 性能”可以看到4個CPU的螢幕,四核八線程可以看到8個CPU的螢幕。

超線程技術就是利用特殊的硬體指令,把一個實體晶片模拟成兩個邏輯處理核心,讓單個處理器都能使用線程級并行計算,進而相容多線程作業系統和軟體,減少了CPU的閑置時間,提高的CPU的運作效率。這種超線程技術(如雙核四線程)由處理器硬體的決定,同時也需要作業系統的支援才能在計算機中表現出來。

程式一般不會直接去使用核心線程,而是去使用核心線程的一種進階接口——輕量級程序(Lightweight Process,LWP),輕量級程序就是通常意義上所講的線程,也被叫做使用者線程。

由于每個輕量級程序都由一個核心線程支援,是以隻有先支援核心線程,才能有輕量級程序。

使用者線程與核心線程的對應關系有三種模型:

1)一對一模型;

2)多對一模型;

3)多對多模型。

5、一對一模型

對于一對一模型來說:一個使用者線程就唯一地對應一個核心線程(反過來不一定成立,一個核心線程不一定有對應的使用者線程)。這樣如果CPU沒有采用超線程技術(如四核四線程的計算機),一個使用者線程就唯一地映射到一個實體CPU的核心線程,線程之間的并發是真正的并發。

一對一模型優點

使使用者線程具有與核心線程一樣的優點一個線程因某種原因阻塞時其他線程的執行不受影響(此處,一對一模型也可以讓多線程程式在多處理器的系統上有更好的表現)。

一對一模型缺點

1)許多作業系統限制了核心線程的數量,是以一對一模型會使使用者線程的數量受到限制;

2)許多作業系統核心線程排程時,上下文切換的開銷較大,導緻使用者線程的執行效率下降。

▲ 一對一模型

6、多對一模型

多對一模型将多個使用者線程映射到一個核心線程上,線程之間的切換由使用者态的代碼來進行,系統核心感受不到線程的實作方式。使用者線程的建立、同步、銷毀等都在使用者态中完成,不需要核心的介入。

多對一模型優點

1)多對一模型的線程上下文切換速度要快許多;

2)多對一模型對使用者線程的數量幾乎無限制。

多對一模型缺點

1)如果其中一個使用者線程阻塞,那麼其它所有線程都将無法執行,因為此時核心線程也随之阻塞了;

2)在多處理器系統上,處理器數量的增加對多對一模型的線程性能不會有明顯的增加,因為所有的使用者線程都映射到一個處理器上了。

如何了解高性能伺服器的高性能、高并發?

▲ 多對一模型

7、多對多模型

多對多模型結合了一對一模型和多對一模型的優點将多個使用者線程映射到多個核心線程上,由線程庫負責在可用的可排程實體上排程使用者線程。

這使得線程的上下文切換非常快,因為它避免了系統調用。但是增加了複雜性和優先級倒置的可能性,以及在使用者态排程程式和核心排程程式之間沒有廣泛(且高昂)協調的次優排程。

多對多模型的優點

1)一個使用者線程的阻塞不會導緻所有線程的阻塞,因為此時還有别的核心線程被排程來執行;

2)多對多模型對使用者線程的數量沒有限制;

3)在多處理器的作業系統中,多對多模型的線程也能得到一定的性能提升,但提升的幅度不如一對一模型的高。

如何了解高性能伺服器的高性能、高并發?

▲ 多對多模型

在現在流行的作業系統中,大都采用多對多的模型。

8、檢視程序與線程

一個應用程式可能是多線程的,也可能是多程序的,如何檢視呢?

在Windows下我們隻須打開任務管理器就能檢視一個應用程式的程序和線程數。按“Ctrl+Alt+Del”或右鍵快捷工具欄打開任務管理器。

在“程序”頁籤下,我們可以看到一個應用程式包含的線程數。

如果一個應用程式有多個程序,我們能看到每一個程序,如在上圖中,Google的Chrome浏覽器就有多個程序。

同時,如果打開了一個應用程式的多個執行個體也會有多個程序,如上圖中我打開了兩個cmd視窗,就有兩個cmd程序。如果看不到線程數這一列,可以再點選“檢視 -> 選擇列”菜單,增加監聽的列。

檢視CPU和記憶體的使用率:在性能頁籤中,我們可以檢視CPU和記憶體的使用率,根據CPU使用記錄的螢幕的個數還能看出邏輯處理核心的個數,如我的雙核四線程的計算機就有四個螢幕。

如何了解高性能伺服器的高性能、高并發?

▲ 檢視CPU和記憶體的使用率

9、線程的生命周期

當線程的數量小于處理器的數量時,線程的并發是真正的并發,不同的線程運作在不同的處理器上。但當線程的數量大于處理器的數量時,線程的并發會受到一些阻礙,此時并不是真正的并發,因為此時至少有一個處理器會運作多個線程。

在單個處理器運作多個線程時,并發是一種模拟出來的狀态。作業系統采用時間片輪轉的方式輪流執行每一個線程。現在,幾乎所有的現代作業系統采用的都是時間片輪轉的搶占式排程方式,如我們熟悉的Unix、Linux、Windows及macOS等流行的作業系統。

我們知道線程是程式執行的最小機關,也是任務執行的最小機關。在早期隻有程序的作業系統中,程序有五種狀态,建立、就緒、運作、阻塞(等待)、退出。早期的程序相當于現在的隻有單個線程的程序,那麼現在的多線程也有五種狀态,現在的多線程的生命周期與早期程序的生命周期類似。

如何了解高性能伺服器的高性能、高并發?

▲ 早期程序的生命周期

程序在運作過程有三種狀态:就緒、運作、阻塞,建立和退出狀态描述的是程序的建立過程和退出過程。

早期程序的生命周期:

建立:程序正在建立,還不能運作。作業系統在建立程序時要進行的工作包括配置設定和建立程序控制塊表項、建立資源表格并配置設定資源、加載程式并建立位址空間;

就緒:時間片已用完,此線程被強制暫停,等待下一個屬于它的時間片到來;

運作:此線程正在執行,正在占用時間片;

阻塞:也叫等待狀态,等待某一事件(如IO或另一個線程)執行完;

退出:程序已結束,是以也稱結束狀态,釋放作業系統配置設定的資源。

如何了解高性能伺服器的高性能、高并發?

▲ 線程的生命周期

線程的生命周期跟程序很類似:

建立:一個新的線程被建立,等待該線程被調用執行;

就緒:時間片已用完,此線程被強制暫停,等待下一個屬于它的時間片到來;

運作:此線程正在執行,正在占用時間片;

阻塞:也叫等待狀态,等待某一事件(如IO或另一個線程)執行完;

退出:一個線程完成任務或者其他終止條件發生,該線程終止進入退出狀态,退出狀态釋放該線程所配置設定的資源。

五、什麼是協程?

1、基本常識

協程是一種基于線程之上,但又比線程更加輕量級的存在,這種由程式員自己寫程式來管理的輕量級線程叫做“使用者空間線程”,具有對核心來說不可見的特性。由于是自主開辟的異步任務,是以很多人也更喜歡叫它們纖程(Fiber),或者綠色線程(GreenThread)。正如一個程序可以擁有多個線程一樣,一個線程也可以擁有多個協程。

如何了解高性能伺服器的高性能、高并發?

2、協程的目的

對于Java程式員來說,在傳統的J2EE系統中都是基于每個請求占用一個線程去完成完整的業務邏輯(包括事務)。是以系統的吞吐能力取決于每個線程的操作耗時。

如果遇到很耗時的I/O行為,則整個系統的吞吐立刻下降,因為這個時候線程一直處于阻塞狀态,如果線程很多的時候,會存在很多線程處于空閑狀态(等待該線程執行完才能執行),造成了資源應用不徹底。

最常見的例子就是JDBC(它是同步阻塞的),這也是為什麼很多人都說資料庫是瓶頸的原因。這裡的耗時其實是讓CPU一直在等待I/O傳回,說白了線程根本沒有利用CPU去做運算,而是處于空轉狀态。而另外過多的線程,也會帶來更多的ContextSwitch開銷。

對于上述問題:現階段行業裡的比較流行的解決方案之一就是單線程加上異步回調。其代表派是 node.js 以及 Java 裡的新秀 Vert.x 。

而協程的目的就是當出現長時間的I/O操作時,通過讓出目前的協程排程,執行下一個任務的方式,來消除ContextSwitch上的開銷。

3、協程的特點

協程的特點總結一下就是:

1)線程的切換由作業系統負責排程,協程由使用者自己進行排程,是以減少了上下文切換,提高了效率;

2)線程的預設Stack大小是1M,而協程更輕量,接近1K。是以可以在相同的記憶體中開啟更多的協程;

3)由于在同一個線程上,是以可以避免競争關系而使用鎖;

4)适用于被阻塞的,且需要大量并發的場景。但不适用于大量計算的多線程,遇到此種情況,更好實用線程去解決。

4、協程的原理

當出現IO阻塞的時候,由協程的排程器進行排程,通過将資料流立刻yield掉(主動讓出),并且記錄目前棧上的資料,阻塞完後立刻再通過線程恢複棧,并把阻塞的結果放到這個線程上去跑。

這樣看上去好像跟寫同步代碼沒有任何差别,這整個流程可以稱為coroutine,而跑在由coroutine負責排程的線程稱為Fiber。比如Golang裡的 go關鍵字其實就是負責開啟一個Fiber,讓func邏輯跑在上面。

由于協程的暫停完全由程式控制,發生在使用者态上;而線程的阻塞狀态是由作業系統核心來進行切換,發生在核心态上。是以協程的開銷遠遠小于線程的開銷,也就沒有了ContextSwitch上的開銷。

5、協程和線程的比較

如何了解高性能伺服器的高性能、高并發?

六、總結

1、程序和線程的差別

1)排程:線程作為排程和配置設定的基本機關,程序作為擁有資源的基本機關;

2)并發性:不僅程序之間可以并發執行,同一個程序的多個線程之間也可并發執行;

3)擁有資源:程序是擁有資源的一個獨立機關,線程不擁有系統資源,但可以通路隸屬于程序的資源;

4)系統開銷:在建立或撤消程序時,由于系統都要為之配置設定和回收資源,導緻系統的開銷明顯大于建立或撤消線程時的開銷。

2、程序和線程的聯系

1)一個線程隻能屬于一個程序,而一個程序可以有多個線程,但至少有一個線程;

2)資源配置設定給程序,同一程序的所有線程共享該程序的所有資源;

3)處理機分給線程,即真正在處理機上運作的是線程;

4)線程在執行過程中,需要協作同步。不同程序的線程間要利用消息通信的辦法實作同步。

開發者在每個線程中隻做非常輕量的操作,比如通路一個極小的檔案,下載下傳一張極小的圖檔,加載一段極小的文本等。但是,這樣”輕量的操作“的量卻非常多。

在有大量這樣的輕量操作的場景下,即使可以通過使用線程池來避免建立與銷毀的開銷,但是線程切換的開銷也會非常大,甚至于接近操作本身的開銷。對于這些場景,就非常需要一種可以減少這些開銷的方式。于是,協程就應景而出,非常适合這樣的場景。

繼續閱讀