淺談server端基本的設計模型及部分問題
用了大概一個半月的時間都在做OS相關的實驗感覺作業系統的東西自己還是瞭解適可而止,當然OS中包含了太多的設計模式以及底層相關的東西都會對自己在server端處理起到指引的作用,但是目前自己還是還是感覺自己還是對server端的處理比較感興趣,固不再廢話,進入正題--server端基本的設計模式。
[注]:所有東西基於Linux環境,並且部分設計模型在Linux下有良好的表現,不一定在Windows下適用。
說起服務端程式設計,自己算是瞭解一些基本的概念,當然在這方面就裝逼的逼格來說還是不夠的,說不了太深層次的東西,只是簡單的想提及一些我們會常用到的概念以及模型:阻塞與非阻塞、同步非同步、IO多路複用以及多執行緒、多程序併發、事件輪詢驅動等服務端模型。
我們的目的就是將Linux網路程式設計和多執行緒、多程序以及Linux底層的設計機制結合起來組建高效能的服務端程式碼(老裝逼了),也算是網路端的業務應用程式。基本的Linux程序執行緒的概念不是我們討論的東西,當然Linux網路程式設計的基本TCP socket流程也不是這裡要說的,我假設這些內容已經是基本的概念。關於Linux網路程式設計我之前的博文連線:http://blog.csdn.net/sim_szm/article/details/9569607 這裡不再贅述。
下面開始我們的正文:
[一]、 一些概念(算是贅述)
[阻塞]是指呼叫結果返回之前,當前執行緒會被掛起(執行緒進入非可執行狀態,在這個狀態下,cpu不會給執行緒分配時間片,即執行緒暫停執行)。函式只有在得到結果之後才會返回。
[非阻塞]和阻塞的概念相對應,指在不能立刻得到結果之前,該函式不會阻塞當前執行緒,而會立刻返回。
[同步]所謂同步,就是在發出一個功能呼叫時,在沒有得到結果之前,該呼叫就不返回。也就是必須一件一件事做,等前一件做完了才能做下一件事。
[非同步]非同步的概念和同步相對。當一個非同步過程呼叫發出後,呼叫者不能立刻得到結果。實際處理這個呼叫的部件在完成後,通過狀態、通知和回撥來通知呼叫者。
IO多路複用(I/O multiplexing),是一個重要的概念,簡單來說就是系統核心緩衝I/O資料,當某個I/O準備好後,系統通知應用程式該I/O可讀或可寫,這樣應用程式可以馬上完成相應的I/O操作,而不需要等待系統完成相應I/O操作,從而應用程式不必因等待I/O操作而阻塞,並且系統開銷小,系統不必花費多餘的程序、執行緒建立以及維護的開銷成本,當然不可避免的會提升系統性能。所有可支援海量連結的系統大多都是基於IO多路複用IPC事件驅動模型的服務端架構(non-blocking IO + IO multiplexing),基本都是一個事件迴圈(event
loop)以及事件驅動(event-driven)和事件回撥的方式實現業務邏輯。在Linux中的即是我們常說的select( ) / poll( )呼叫,但在Linux2.6(實際是2.5.5)中實現的epoll( )(具體下面會闡述)方式才是最為強大的高效能替代產品。
關於I/O模型在《unix網路程式設計》中提及了以下幾種:
- blocking I/O
- nonblocking I/O
- I/O multiplexing (select and poll)
- signal driven I/O (SIGIO)
- asynchronous I/O (the POSIX aio_functions)
I/O多路複用即使上述第三種,其他I/O模型這裡不再贅述。
【注】:這裡我必須插點東西,上面的I/O模型中我們看到了最後一種,asynchronous I/O,即非同步I/O,我們常說的 I/O複用實現的同步和非同步其實都是針對事件響應來說的,不論是select、poll、epoll實現的都是事件的非同步,而這裡的I/O操作其實都只是簡單地同步,也就是說,我們藉助I/O複用實現了事件響應的非同步,但在真實的I/O操作上都是同步的,POSIX和glibc都有對應的非同步I/O,實現的都是真實的I/O操作的非同步,對於同步、非同步,阻塞IO、非阻塞IO最大的區別是:
同步IO和非同步IO的區別就在於:資料拷貝的時候程序是否阻塞!
阻塞IO和非阻塞IO的區別就在於:應用程式的呼叫是否立即返回!
同步和非同步,阻塞和非阻塞,之前其實有些混用,其實它們完全不是一回事,而且它們修飾的物件也不相同。阻塞和非阻塞是指當程序訪問的資料如果尚未就緒,程序是否需要等待,簡單說這相當於函式內部的實現區別,也就是未就緒時是直接返回還是等待就緒 ; 而同步和非同步是指訪問資料的機制,同步一般指主動請求並等待I/O操作完畢的方式,當資料就緒後在讀寫的時候必須阻塞(區別就緒與讀寫二個階段,同步的讀寫必須阻塞),非同步則指主動請求資料後便可以繼續處理其它任務,隨後等待I/O,操作完畢的通知,這可以使程序在資料讀寫時也不阻塞(等待"通知")。對於非同步IO,舉個例子假設有一些需要處理的資料可能放在磁碟上。預先知道這些資料的位置,所以預先發起非同步IO讀請求。等到真正需要用到這些資料的時候,再等待非同步IO完成。使用了非同步IO,在發起IO請求到實際使用資料這段時間內,程式還可以繼續做其他事情。在Linux下有對應的AIO函式,當然還有對應的glibc版本。
[二] 幾種服務端的程式設計模型
對於一個較為實用的服務端業務邏輯實現來說,好的程式碼架構直接決定了其真實的處理能力,而好的架構必定是最能有效並且充分地利用系統資源,我們可以從下面的幾種server模型中體會其不同的處理方式帶來的系統性能的差異。
·1· 基本的socket建立TCP連結,從開始的socket( )到最後的accept( )之後哦的send( )/recv( )操作,即實現了最為間的單程序模型,很明顯其不具備併發的能力,這種模型下server只能簡單的處理一個客戶端的請求,所以這種最為簡單的模型只是適合一對一下基於嚴格時序邏輯的網路業務。
·2·在1的基礎上我們如果需要支援併發,最為簡單的操作便是為每個請求的任務fork( )出一個子程序來處理。這也是最簡單的迭代式併發模型,從某種角度來說,程序是可以併發執行的,所以程序的併發促進了server的併發邏輯,並且程序具有很好的隔離性,模個任務出現問題一般不會影響到其他的業務。這種模型看起來簡單,但是在對效能要求不是很高並且連結數較低的場景中還是應用蠻多的。因為太簡單,所以就不用說為什麼簡單了。
·3·preforking
Preforking即是預先建立一定數量的程序/執行緒來提高請求的速度,相對於2中接受一個請求去fork必定是有效能和效率上的提升,但也帶來了一個問題,那就是需要對空閒的程序/執行緒進行管理,即要求有一定的空閒程序/執行緒來處理業務請求,又不能使空閒的程序/執行緒過多造成對系統資源的浪費(注意這裡需要做的有兩點,1 程序空閒檢查 2 資源釋放)。但是一旦當需要處理的連線較多時,就會有嚴重的系統性能消耗,比如我們需要預先建立1000個程序,那時作業系統對程序的切換帶來的開銷都已經夠了,還別談什麼業務處理效能。
·4· 當然preforking這種概念即可引申為我們所謂的程序/執行緒池,但在其具體的實現策略上還是會產生不同的結果。比如在實際應用時,我們做TCP連線處理,我們假設程序池(或執行緒)內的每一個程序都在做accept( ),當程序池內無業務處理時,所有的業務程序都會轉到休眠狀態,一旦有任務投放進來,就會產生引發所有空閒程序的爭奪,這就是所謂的“驚群”現象,因為到底哪個程序去優先處理不是我們可控的(當然這裡所有執行緒的業務邏輯是一樣的),就會產生業務競爭的現象。這樣的結果必然引發的是對系統性能的白白損耗。解決的辦法是我們需要將原來的競爭策略轉為分配,我們可以讓父程序統一做accept(
)操作,然後將所有的socket描述符作為資源統一分配給空閒子程序,可以的操作方式為IPC管道或是socketpair( )來實現。
這裡插一點內容,對於程序間通訊,管道的方式是單向的,程序間需要有父子關係才可以,如要雙向通訊必須開兩個描述符,很不方便。大牛陳碩的推薦是隻用TCP,因為tcp協議可以跨主機,具有較強的架構伸縮性,我們的任務可以通過這種方式分散在不同的主機上(真實的物理機,不牽扯虛擬的概念),當然IPC有很多方式,但就叢集的方式而言,這種業務的伸縮性是必需的。
·5·重點的多執行緒處理
對於多執行緒來實現服務端的業務邏輯,我算是較為重視的一個,因為其中牽扯的問題是最多的,在邏輯架構上的設計也是多樣性的,陳碩大牛的那本《Linux多執行緒服務端程式設計》中較為推崇的一中策略就是“ one loop per thread + thread pool ”,即為每一個IO執行緒中有一個event loop(無論是週期性還是單次的),它代表了執行緒的主迴圈,當前我需要讓那個執行緒來處理業務我就只需要將對應的timer或是tcp連線註冊到對應執行緒的事件輪詢中(當然這裡我們並不考慮同一個TCP連線的事件併發),關於事件輪詢在Linux下我們常用的就是epoll,因為它的確是一種高效能的機制,對因還有FreeBSD的kqueue,但目前主流的服務端都是Linux,所以epoll的學習價效比還是蠻高的。
對於thread pool , 它的作用更多的是計算和實際業務處理,當然這裡在管理方面需要對應的條件變數和mutex來管理,對於互斥鎖這種東西,很多人都在排斥,如果在服務端程式碼出現了大量的鎖必定是低效的,但我認為引起低效的更多是鎖間引發的競爭關係,而不是單純的互斥。
對於用不用多執行緒,這個問題並不是一定的,因為對於不同的情況而言,具體的問題要結合業務的複雜性和時序邏輯來分析。但是對於提升響應速度,讓IO操作和任務計算處理並行或是從降低延時來說,多執行緒是不錯的選擇。
[三]、epoll的部分內容
對於epoll這個東西本來不打算再說,因為之前有過一篇Blog來說它的簡單實用,這裡再贅述一下吧,因為的確是蠻重要的東西,它是Linux下最經典的非同步IO框架(這裡真實的IO操作不一定是非同步的,用AIO_Function才可以實現真實的IO非同步)。
Epoll的函式呼叫主要有:epoll_create( )、 epoll_wait( )、 epoll_ctl( )、close( ).
根據man手冊介紹, epoll_create(int size) 用來建立一個epoll例項,向核心申請支援size個控制代碼的資源(儲存)。Size的大小不代表epoll支援的最大控制代碼個數,而隱射了核心擴充套件控制代碼儲存的尺寸,也就是說當後面需要再向epoll中新增控制代碼遇到儲存不夠的時候,核心會按照size追加分配。在2.6以後的核心中,該值失去了意義,但必須大於0。epoll_create執行成功,返回一個非負的epoll描述控制代碼,用來指定該資源,否則返回-1。
對於事件的輪詢控制主要通過epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)完成。控制物件是使用者申請的控制代碼,即fd;Epfd指定所控制的epoll資源;op指對fd的動作,包括向epoll中新增一個控制代碼EPOLL_CTL_ADD,刪除一個控制代碼EPOLL_CTL_DEL,修改epoll對一個存在控制代碼的監控模式EPOLL_CTL_MOD;event指出需要讓epoll對fd的監控模式(收、發、觸發方式等)。epoll_ctl執行成功返回0,
否則返回-1。
關於epoll的事件型別定義如下:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
該結構中我們主要看epoll_event。epoll_event->data涵蓋了呼叫epoll_ctl增加或者修改某指定控制代碼時寫入的資訊,epoll_event->event,則包含了返回事件的位域。具體的新增控制代碼操作,限於篇幅就不再多說,seracher一下一大堆介紹,我的之前一篇Blog也有簡單的介紹:
http://blog.csdn.net/sim_szm/article/details/8860803 具體可以參照。
當向epoll中新增若干控制代碼後,就要進入監控狀態,此時通過系統呼叫epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)完成。epoll_wait在執行的時候,在timeout內,將有動作的控制代碼的資訊填充到event,event和maxevents決定了epoll監控控制代碼的上限。timeout的單位是微妙級別,當為-1時,除非內部控制代碼有動作,否則持續等待。epoll_wait執行成功返回有動作的控制代碼的總數,控制代碼資訊在events中包含;如果在超時timeout內返回零,表示沒有io請求的控制代碼;否則返回-1。
對於epoll的使用有兩種重要的事件觸發方式,邊沿觸發(Edge Triggered)和水平觸發(Level Triggered),邊沿觸發,效率較高,只在Socket傳送緩衝區由滿變成不滿和接收緩衝區由空變成非空的瞬間,EPOLL會分別檢測到EPOLLOUT和EPOLLIN事件,其它時候,沒有任何事件可被檢測到,為確保Socket上的收、發正常,應用程式必需確保“發則發到發不出,收必收至收不到”。在邊沿觸發下,正確的讀寫操作便是:
· 讀:只要可讀,就一直讀,直到返回0,或者 errno = EAGAIN ·
· 寫:只要可寫,就一直寫,直到資料傳送完,或者 errno = EAGAIN ·
至於水平觸發方式,只要傳送緩衝區不為滿,即可檢測到EPOLLOUT,只要接收緩衝區不為空,即可檢測到EPOLLIN,效率不如前者高,但程式設計更容易,不容易出錯。
給一段epoll的使用程式碼示例:
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <openssl/ssl.h>
#include <openssl/err.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/time.h>
#include <sys/resource.h>
#define MAXBUF 1024
#define MAXEPOLLSIZE 10000
#define BACKLOG 1
/*
setnonblocking - 設定控制代碼為非阻塞方式
*/
int setnonblocking(int sockfd)
{
if (fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0)|O_NONBLOCK) == -1) {
return -1;
}
return 0;
}
/*
handle_message - 處理每個 socket 上的訊息收發
*/
int handle_message(int new_fd)
{
char buf[MAXBUF + 1];
int len;
bzero(buf, MAXBUF + 1);
len = recv(new_fd, buf, MAXBUF, 0);
if (len > 0)
printf
("socket %d recv message :'%s',size as \n",
new_fd, buf, len);
else {
if (len < 0)
printf
("訊息接收失敗!錯誤程式碼是%d,with error code '%s'\n",
errno, strerror(errno));
close(new_fd);
return -1;
}
return len;
}
int main(int argc, char **argv){
int listener, new_fd, kdpfd, nfds, n, ret, curfds,opt;
socklen_t len;
struct sockaddr_in my_addr, their_addr;
struct epoll_event ev;
struct epoll_event events[MAXEPOLLSIZE];
struct rlimit rt;
/* 設定每個程序允許開啟的最大檔案數 */
rt.rlim_max = rt.rlim_cur = MAXEPOLLSIZE;
if (setrlimit(RLIMIT_NOFILE, &rt) == -1) {
perror("setrlimit");
exit(1);
}
else printf("設定系統資源引數成功!\n");
if ((listener = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
perror("socket error ");
exit(1);
} else
printf("socket init succeed !\n");
setnonblocking(listener);
opt=1;
setsockopt(listener,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
bzero(&my_addr, sizeof(my_addr));
my_addr.sin_family = AF_INET;
my_addr.sin_port = htons(8080);
my_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(listener, (struct sockaddr *) &my_addr, sizeof(struct sockaddr))== -1) {
perror("bind");
exit(1);
}
if (listen(listener,BACKLOG) == -1) {
perror("listen");
exit(1);
} else
printf("our service start !\n");
/* 建立 epoll 控制代碼,把監聽 socket 加入到 epoll 集合裡 */
kdpfd = epoll_create(MAXEPOLLSIZE);
len = sizeof(struct sockaddr_in);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listener;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev) < 0) {
fprintf(stderr, "epoll set insertion error: fd=%d\n", listener);
return -1;
} else
printf("監聽 socket 加入 epoll 成功!\n");
curfds = 1;
while (1) {
nfds = epoll_wait(kdpfd, events, curfds, -1);
if (nfds == -1) {
perror("epoll_wait");
break;
}
/* 處理所有事件 */
for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listener) {
new_fd = accept(listener, (struct sockaddr *) &their_addr,
&len);
if (new_fd < 0) {
perror("accept");
continue;
} else
printf("connect with: %s socket is:%d\n", inet_ntoa(their_addr.sin_addr), new_fd);
setnonblocking(new_fd);
ev.events = EPOLLIN | EPOLLET; //邊沿觸發
ev.data.fd = new_fd;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, new_fd, &ev) < 0) {
fprintf(stderr, "把 socket '%d' 加入 epoll 失敗!%s\n",
new_fd, strerror(errno));
return -1;
}
curfds++;
} else {
ret = handle_message(events[n].data.fd);
if (ret < 1 && errno != 11) {
epoll_ctl(kdpfd, EPOLL_CTL_DEL, events[n].data.fd,
&ev);
curfds--;
}
}
}
}
close(listener);
return 0;
}
關於epoll的緩衝區處理還是一個很複雜的問題,限於篇幅,沒辦法在多說。今天就先說到這裡,很多問題也只是很淺層次的說明,服務端牽扯了太多的機制,沒辦法一一列舉,當然也是限於自己的知識水平,好多問題都還不瞭解。慢慢學習吧。