高效能服務端漫談

foreach_break發表於2015-05-13

一、背景

進入多核時代已經很久了,大資料概念也吵得沸沸揚揚,不管你喜歡不喜歡,不管你遇到沒遇到,big-data或bigger-data都必須正視.

處理大資料,基本都離不開分散式計算和分散式儲存,這其中以hadoop最為使用廣泛和經典。

分散式系統,就離不開計算系統、網路系統、檔案系統和資料庫系統。

這麼多系統,之間又是如何協作的呢?

通訊過程又是如何保障高效能的呢?

1.單處理器

在以前的單核心cpu下,我們要實現檔案I/O、網路I/O,可以妥妥的使用單執行緒迴圈處理任務。

但是如果想“同時”做點其它事情,就得多開執行緒,比如:

在下載遠端檔案的同時,顯示下載進度。

2.多執行緒

我們會用主執行緒a來更新介面元素,這裡是更新下載進度條,同時用一個額外的執行緒b去下載遠端檔案。

3.阻塞

當需要下載的時候,我們必須使a阻塞,否則,我們的下載執行緒b將無法獲得cpu時間。

而當需要更新介面時,我們必須使b阻塞,原因也是為了獲得cpu時間。

阻塞:使一個執行緒進入阻塞或等待的狀態,釋放它所佔有的cpu時間.

阻塞的原因,是因為任何操作,無論是更新介面還是下載檔案(網路I/O+磁碟I/O),都會轉化成為一條一條cpu可以執行的指令,而這些指令的讀取、執行都需要消耗cpu的時鐘週期。

事實上,我們可以使用類似wait、await、sleep、read、write等操作使當前呼叫的執行緒進入阻塞。

顯然

阻塞的目的是

1.當前有更重要的事情要交給別的執行緒來做.

2.呼叫執行緒可能面臨一個長時間的I/O操作,佔據cpu時間顯然是一種浪費.

3.同步需求.

雖然使用者看起來是下載檔案和更新介面“同時”執行,但實際上,任何一個時刻,在單核心cpu環境下,都只有一個執行緒會真正的執行,所以多執行緒之間是“併發”而非真正的“並行”。

4.多處理器

單核心時代早已過去,多核、多處理無論在企業級伺服器還是家用桌面電腦、平板和智慧手機上都已經是主流。

注:圖片來源intel

如果多處理器的核心是真實的而非虛擬化的,那麼多執行緒就可以真正的並行。

可以看到,t1、t2、t3的執行時間可以出現重疊.

但實際上,操作中執行的程式遠不止幾個,那麼相應的執行緒數會遠大於cpu的核心數,所以即使上圖中假設是4核心處理器,那麼真正能同時執行的執行緒也只有4個,所以也會出現執行的中斷,即阻塞。

二、高效能通訊

在瞭解了多核、多處理器、多執行緒、阻塞的概念後,我們來看看通訊,顯然,任何一個通訊框架都試圖追求高效能、高吞吐、高tps能力。

但是,任何一個來自使用者或其它伺服器的請求都不可能只是要求一個簡單的echo返回,所以請求執行的任務幾乎都會包含:

1.計算 比如MapReduce、SQL、座標計算等等.

2.I/O 訪問資料庫、磁碟、快取、記憶體或者其它裝置.

站在使用者的角度,總是希望自己的請求會被優先、快速的響應,而站在伺服器的角度,總是希望所有的請求同時能夠被處理.

1.同步/非同步

同步的意思如字面一般簡單:

同步就是多個物件的步調一致,這種步調是一種約定。

比如,時間上約定10點同時到達,先到達的就會等待。

比如,邏輯上約定必須取得結果,呼叫才能返回。

比如,資源上約定read和write不可同時進行,但read之間可同時執行。

下面的圖顯示了時間上的同步約定:

而非同步,就是步調無須一致:

非同步,就是多個物件之間的行為無須遵守顯式或隱式的約定。

比如,老婆沒到,你也可以進場看電影。

比如,可以不必等結果真正出現,就立即返回。

比如,read和read之間可以亂序訪問檔案或資源。

2.同步與阻塞的關係

伺服器的能力是有限的,為了能夠滿足所有使用者的請求,伺服器必須能夠進行高併發的處理。這一點可以通過兩種方式達到:

1.單執行緒 + 非同步I/O. (node.js)

多執行緒的建立是需要開銷的,執行緒數越多,執行緒上下文的切換就會越頻繁,而非同步I/O在“理想”情況下不會阻塞,呼叫完畢即返回,通過回撥callback或事件通知來處理結果.

2.多執行緒 + 非同步或同步I/O. (nginx)

單執行緒的一個缺點就是無法充分利用多處理器的並行能力,同時非同步I/O不是在任何情況下都是真正非同步的。

比如檔案在快取中(通過對映到記憶體)、檔案壓縮、擴充套件、緩衝區拷貝等操作,會使得非同步I/O被作業系統偷偷地轉換為同步。

假如檔案已經在快取中,使用同步I/O的結果會更快。

這裡你可能會疑惑,同步看起來很像“阻塞”,但仔細看本篇中對它們的說明,就會發現:

阻塞是呼叫執行緒的一種狀態或行為,它的作用是放棄佔用的cpu.

同步是多個執行緒之間的協調機制,它的作用是為了保證操作的順序是正確可預期。

同步可以使用阻塞來實現,也可以使用非阻塞來實現。

而有的情況下,因為同步是不得已的行為,比如要hold住一個來自其他伺服器的session,以防止立即返回後的上下文失效,我們往往會這樣:

//還沒有結果
bool haveResponse = false;
//呼叫非同步I/O,從遠端資料庫執行sql,並返回結果
rpc.callAsync(database,sql,
 function(resp){ 
 response = resp;
 haveResponse = true;
 });
//通過迴圈阻塞來hold住這個執行緒的上下文和session
while(!response){
 //這裡將阻塞100毫秒
 if(!response){
 await(100);
 }else{
 break;
 }
}
//通過請求的session返回結果
httpContext.currentSession.Respond(response);

這是一種 多執行緒 + 非同步 轉為了 多執行緒 + 同步的方式,因為Web應用伺服器處理session時採用的往往是執行緒池技術,而我們又沒有伺服器推(server push)或者使用者的呼叫請求一直在等待結果,所以,即使訪問資料庫採用的是非同步I/O,也不得不通過這種方法來變成同步。

與其如此,還不如:

//呼叫同步I/O,從遠端資料庫執行sql,並返回結果
//呼叫時,此執行緒阻塞
response = rpc.callSync(database,sql);
//通過請求的session返回結果
httpContext.currentSession.Respond(response);

上面的程式碼,使用了簡單的同步I/O模型,因為一般的訪問資料庫操作是很費時的操作,所以處理當前session的執行緒符合被阻塞的目的,那麼同步呼叫就被實現為阻塞的方式。

事實上,從使用者的角度來看,使用者發出請求後總是期待會返回一個確定的結果,無論服務端如何處理使用者的請求,都必須將結果返回給使用者,所以採用非同步I/O雖然是最理想的狀態,但必須考慮整個應用的設計,即使你這裡使用了非同步,別的地方也可能需要同步,如果這種“額外”同步的設計複雜性遠高於使用非同步帶來的好處,那麼請考慮“同步/阻塞式”設計。

如果業務邏輯上,要求依賴性呼叫,比如DAG,那麼同步也是必須的。

三、IOCP和epoll

1. IOCP(完成埠)

windows提供了高效的非同步I/O的執行緒模型,完成埠:

完成埠可以關聯很多的檔案控制程式碼(這裡的檔案是廣義的,檔案、socket或者命名管道都可以是)到一個完成埠上,稱為關聯完成埠的引用,,這些引用都必須支援(Overlapped I/O,重疊式I/O)。

重疊式I/O是非同步I/O的基石,通過進行重疊I/O,可以讓呼叫I/O操作的執行緒與I/O操作執行緒並行執行而無須阻塞。

多執行緒雖然可以充分發揮多處理器的並行優勢,但卻不是銀彈。

當執行緒數增加,可“同時”處理的請求量上去了,這樣吞吐量會很高,但可用於每個使用者請求的時間變少,每個使用者請求的響應時間隨之下降,最後吞吐率下降。

同時,執行緒的啟動和銷燬是有開銷的,雖然可以通過執行緒池(ThreadPool)來預先分配一定量的活動執行緒,但執行緒越多,其上下文切換(Context Switch)的次數就越頻繁。

考慮一種情況:

當執行緒的棧很大而執行緒被阻塞的時間很長,作業系統可能會將此執行緒的堆疊資訊置換到硬碟上以節約記憶體給其它執行緒使用,這增加了磁碟I/O,而磁碟I/O的速度是非常慢的。

而且,執行緒的頻繁切換也會降低指令和資料的locality(區域性性),cpu的快取命中率會下降,這又加劇了效能的下降。

完成埠的設計目標是:

1.任一給定時刻,對於任一處理器,都有一個活動執行緒可用。

2.控制活動執行緒的數量,儘量減少執行緒上下文的切換。

可以看出,IOCP主要是針對執行緒模型的優化。

建立完成埠時,需要指定一個Concurrent Value = c的值,來指示:

當活動執行緒的數量 v >= c,就將其它關聯在完成埠上的執行緒阻塞,直到活動執行緒的數量 v < c.

當一個活動執行緒進行I/O時,會阻塞,活動執行緒數量v就會下降.

這一點是IOCP的精髓。

完成埠的原理是:

在建立了完成埠後,將socket關聯到這個埠上,一旦socket上的I/O操作完成,作業系統的I/O管理模組會傳送一個通知(Notification)給完成埠,並將I/O操作的完成結果(completion packet)送入完成埠的等待佇列WQ,這個佇列的順序是先入先出(FIFO)。

也就是說,呼叫執行緒可不比等待socket的I/O操作完成,就立即返回做其它的事情。

而當活動執行緒的數量下降,小於指定的併發約束(concurrent value)時,作業系統將會喚醒最近被加入阻塞佇列BQ的執行緒,讓它從完成包的等待佇列中取出一個最老的I/O結果進行處理。這裡可以看出,BQ的順序是後入先出(LIFO)。

IOCP所謂的非同步是:

與完成埠關聯的檔案(file、socket、named pipeline)控制程式碼上的I/O操作是非同步的。

呼叫執行緒只負責將socket I/O丟給完成埠,然後就可以做其它事。而無需向同步那樣等待。

但是,如果一個呼叫執行緒在處理這個從完成佇列取出的資料後,又在當前執行緒進行了其它I/O操作,比如讀取檔案、訪問資料庫,那麼這個呼叫執行緒同樣會阻塞,但不是阻塞到完成埠的佇列上。

這一點,對資料的處理就涉及不同的業務邏輯需求,I/O執行緒是否應該與邏輯執行緒分開,分開後,邏輯執行緒應該是如何控制數量,如果分開,就要求在拿到資料後,要麼另起執行緒處理資料,要麼將資料扔進執行緒池(Threadpool)。無論是何種方式,都會增加執行緒上下文切換的次數,反過來影響IOCP的可用資源。

所以,要從應用的實際需求出發,來總體控制整個伺服器的併發執行緒數量,否則,無論多麼高效的通訊模型,都會被業務模型(往往需要對檔案或資料庫的訪問)所拖累,那麼整個應用的效能就會下降。

2. epoll

linux上的高效I/O模型則是epoll.

epoll是對select/poll模型的一種改進.

1.既然是對select/poll的改進,就是一種I/O多路複用模型。

2.支援的檔案(同樣是廣義)描述符fileDescriptor巨大,具體多大與記憶體大小直接相關。

3.wait呼叫在活躍socket數目較少時,可高效返回。

在傳統的select/poll模型中,核心會遍歷所有的fileDescriptor(這裡只說socket),而不管socket是否活躍,這樣,隨著socket數目的增加,效能會很快下降。

而epoll模型,採用了向核心中斷處理註冊回撥的方式,當某個socket上的I/O就緒,中斷就會發出,接著就會將這個結果推入一個就緒佇列Q中,Q採用單連結串列實現,所以擴充套件性是天生的。

同時,由於採用了適宜頻繁寫的平衡樹-紅黑樹的結構來儲存fileDescriptors,所以當需要向fileDescriptors中加入、刪除、查詢socket時,就會非常高效,另外還有一層核心級頁快取記憶體。

最後,由於活動的socket比較少時,I/O就緒的中斷次數相應減少,所以向就緒佇列Q中插入資料的次數相應減少,當wait操作被呼叫時,核心會考察Q,如果不空就立即返回,同時通過記憶體對映來講就緒的I/O資料從核心態拷貝到使用者態,達到少而快的效果。

epoll的主要呼叫介面如下:

/* 建立可保證size個效率epoll,返回epfd*/
int epoll_create(int size); 
/* 設定應當註冊的事件型別IN/OUT/ET/LT,並設定用於返回事件通知的events */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 
/* epoll進入阻塞,events用於設定返回事件通知的events */
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

邊沿觸發(ET)

考慮上面的圖,隨著時間的增加,高低電平交替變化。

所謂邊沿觸發,就是當達到邊沿(一個臨界條件)時觸發,如同0到1.

epoll中的邊沿觸發,是指當I/O就緒,中斷到達時,執行對應的回撥,將結果推入`等待佇列Q`中,之後便不再關心這個結果。

這樣導致的結果是,當wait呼叫返回時,如果對應的事件沒有被處理完,比如讀操作沒有將buffer中的資料讀取完,就返回,將沒有機會再處理剩餘的資料。

水平觸發(LT)

所謂水平觸發,就是每到上邊沿時就觸發,比如每次到1.

epoll中的邊沿觸發,是指當I/O就緒,中斷到達時,執行對應的回撥,將結果推入`等待佇列Q`中,當佇列被清空後,再次將結果推入佇列。

這樣的結果是,當wait呼叫返回時,如果對應的時間沒有處理完,比如寫資料,寫了一部分,就返回,也會在下次wait中收到通知,從而得以繼續處理剩餘資料。

水平觸發流程簡單穩定,需要考慮的事情少,且支援阻塞/非阻塞的socket I/O。

而邊沿觸發,在大併發情況下,更加高效,因為通知只發一次,但只支援非阻塞的socket I/O。

下圖是ET方式的epoll簡略流程:

相關文章