Linux下常見服務器模型
到今天在公司呆了8個月,完成從校園人到職場人的轉身。出身社會,感觸頗多,一切身不由己。在工作中快樂過、焦慮過、失望過,到最后的心涼,這一路下來成長不少。大學畢業設計涉及網絡知識,當時學習了一些基礎的網絡知識,工作不久,轉到新的項目組hms做的產品IPTV剛好和網絡相關,最近幾個月一直在看《linux高性能服務器編程》,在網上也看了不少文章,一直想寫篇總結。
基礎
圖1 c/s架構通信
圖1是一個簡化的tcp通信過程,左側為“服務器端”,右側為“客戶端”,tcp協議本身並沒有定義服務端、客戶端這些概念,因為大量的資源掌握在通信的左側,請求都指向它,也就有了服務端、客戶端這些概念。在網絡發展的初期,網絡傳輸最多的是文本文件,不像現在大量的動態圖、視頻等多媒體資源。客戶端建立連接后,提交一個資源請求,服務端收到請求后解析請求的資源,然后發送客戶端請求的資源,客戶端收到請求的資源后,斷開連接,一次通信結束。文本文件的內容長度不大,一般在KB以下,服務端發送數據的次數取決於服務器端的鏈路MTU,早期的施樂X.25是576,而以太網則是1500字節,發送的次數也就1、2次。隨着網絡的普及、多媒體技術的發展、瀏覽器的升級、新協議的推出,早期服務器的架構已經不能滿足要求,服務器的模型也一直在衍化。下面介紹幾種常見的服務器模型。
迭代模型
迭代模型算是最早期的服務器模型,其核心實現是每來一個用戶,然后為這個用戶服務到底,過程中不接受任何新的用戶請求,單台服務器就服務一個用戶,其流程圖如圖1。
核心代碼:
bind(listenfd);
listen(listenfd);
for( ; ; )
{
connfd = accept(listenfd, ...); //接受客戶端來的連接
while(user_oline)
{
read(connfd,recv_buf, ...); //從客戶端讀取數據
release_request(recv_buf); //解析客戶請求
write(connfd,send_buf, …) //發送數據到客戶端
}
close(connfd)
}
這種模式最大的問題是幾個主要的操作都是阻塞的,譬如accept,如果一直沒有用戶過來,那么進程一直堵在這兒。還有read操作,前面假設的用戶建立連接后發送資源請求,但是用戶不發送呢?如果打開了tcp保活檢測,那也是幾分鍾后的事才能關掉這個惡意連接,如果沒有打開tcp保活檢測,那也要設置一個連接有效時間。即使這樣,服務器在中間這幾分鍾也是完全空閑的,但是不能接受新的用戶連接。
多進程模型
為了解決上述操作阻塞,不能接受新用戶連接,使用多進程模型。此模型的核心思想是在主進程接受用戶連接,子進程中處理業務,這樣就不會阻塞新用戶連接。
圖2 多進程模型
核心代碼:
bind(listenfd);
listen(listenfd);
for( ; ; )
{
connfd = accept(listenfd, ...); //開始接受客戶端來的連接
pid = fork();
switch( pid )
{
case -1 :
do_err ();
break;
case 0 : // 子進程
client_handler(user_info);
break ;
default : // 父進程
close(connfd);
continue ;
}
}
voidclient_handler(user_info)
{
while( )
{
read(connfd,recv_buf,...); //從客戶端讀取數據
dosomthingonbuf(recv_buf); //解析用戶請求
write(connfd,send_buf) //發送數據到客戶端
}
shutsown(connfd)
}
此種模式的劣勢會在多線程中給出。
多線程模型
Linux上面線程又稱為輕量級進程,它和主線程共享整個進程的數據,線程切換的開銷遠小於進程。多線程模型的核心思想是每來一個用戶連接就為用戶創建一個線程,其流程圖如圖2,只需將fork改為pthread_create即可。
核心代碼:
bind(listenfd);
listen(listenfd);
for( ; ; )
{
connfd = accept(listenfd, ...); //開始接受客戶端來的連接
ret = pthread_create( , worker, , user_info);
}
void worker(user_info)
{
while( )
{
read(connfd,recv_buf,...); //從客戶端讀取數據
dosomthingonbuf(recv_buf); //解析用戶請求
write(connfd,send_buf) //發送數據到客戶端
}
shutsown(connfd)
}
多進程模型、多線程模型的劣勢:
1、進程、線程的創建、銷毀在某些時候會造成很大的消耗,舉個簡單的例子:現在終端設備觀看視頻主流的傳輸協議是基於http協議的hls,華為IPTV最新版本單台服務器出流60G,在hls短連接的情況下出流打8折48G,按照700K的碼率,那么用戶數接近7W,服務器的caps按照2W計算。按照多線程、多進程的的模型,1s建立、銷毀幾萬的線程,服務器根本扛不住。所以大都的設計都會實現進程、線程池,減少這部分的開銷。進程、線程池不能避免的是資源的搶占,在進程池中多用信號量、共享內存實現資源的分配,在線程池中多用互斥鎖或者條件變量實現資源分配。
2、多線程情況下,如果一個線程出現問題,可能導致所在進程掛掉。
3、在多核情況下沒有意義的多進程、多線程。如前面所說,一台服務器出流60G,假如用戶是標清2M碼率,那么在線用戶數就是30000。以華為最新架構的RH5288 V3配置Intel 2658 48核做硬件,那么單個核上面的進程、線程數是30000/48=625。一個核上面跑這么多功能完全相同的進程、或者線程是完全沒有任意意義的,這還是默認單台服務器能跑這么多進程、線程。說白一點,一個用戶就使用一個進程、線程是絕對不行的。
端口復用
Select/poll
撇開框架不說,select/poll就是用來解決上述一個用戶就使用一個進程、線程的問題,select/poll可以在一個進程、線程監聽多個文件句柄。
代碼:
bind(listenfd);
listen(listenfd);
FD_ZERO(&set);
FD_SET(listenfd, &aset);
for( ; ; )
{
//循環添加所有文件描述符
for()
select(...);
if (FD_ISSET(listenfd, &rset));
{
connfd = accept();
user_info [] = connfd;
FD_SET(connfd, &set);
}
else if ...
//循環檢測文件描述符
for( ; ; )
{
fd = user_info[i].fd;
if (FD_ISSET(fd , &rset))
dosomething();
else if…
}
}
Select監聽多個文件描述符,set的本質時候一個整形數組,數組的每一比特位表示一個文件描述符,select可以監聽文件描述符上的讀事件、寫事件、異常事件,當文件描述符上發生其中某件事,系統調用就會把相應的位置1。Select最讓人詬病的有兩點,一是每次調用select之前都要將文件描述符添加到數組中,暫且不說依次遍歷添加的時間,把數據從用戶態拷貝到內核態是很消耗性能的。二是select調用返回必須再次遍歷數組,查看文件描述符是否有事件產生,又是一個O(n)的操作。其實還有一點,select操作用戶的數據必須單獨保存,在select調用中無法保存用戶數據。
bind(listenfd);
listen(listenfd);
AddToPoll(listenfd);
for( ; ; )
{
Number = poll();
if (event.revents &POLLIN && fd == listenfd)
{
connfd = accept();
event [] = connfd;
AddToPoll(connfd);
}
else if …
//循環檢測文件描述符
for( ; ; )
{
fd = event.fd;
if (event.revents&POLLIN)
dosomething();
else if…
}
}
poll 的實現機制是將文件描述符以及對此文件描述符感興趣的事件寫入一個結構體,poll調用返回,操作系統會把文件描述符發生過的事件寫入一個變量中。poll較select的優化之處在於不用每次拷貝文件描述符、將事件都寫入了一個變量,不像select使用三個變量,poll調用僅能保存文件描述符。但是poll調用返回也必須再次遍歷數組,這也是一個O(n)操作。
epoll
linux IO復用使用最多就是epoll,epoll的實現同poll類似,但是它做了兩點改進。一是epoll調用中使用的結構體能夠保存用戶數據(不僅僅),二是epoll返回實際發生了事件的文件描述符個數,這些對應的事件都寫入返回數組。這對有些場景是很又意義的,譬如RTSP協議在使用UDP傳送數據的使用,其信令數據使用tcp傳輸,信令數據較業務數據少得多,一般較長時間才會有一個信令交互。以60G的場景,用戶數據2M碼率,那么需要監聽的文件描述符為30000,某個時刻一個文件描述符產生了事件,如果是poll調用則需遍歷長度為30000的數組,而epoll只需要1次。
代碼:
bind(listenfd);
listen(listenfd);
AddToEpoll(listenfd);
for( ; ; )
{
Number = Epoll_wait ();
if (event.events &EPOLLIN && fd == listenfd)
{
connfd = accept();
event [].data.fd = connfd;
AddToEpoll(connfd);
}
else if …
//循環檢測文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events&EPOLLIN)
dosomething();
else if…
}
}
IO復用下的設計
顯然有了IO復用這一特性,原有的多進程、多線程模式設計流程已經不適合。前面的所有流程中,接受新用戶連接(accept)這一操作都是在主進程或者主線程中完成中,但是在有些時候單進程、單線程處理就會遇到瓶頸,在前面的短連接例子中,單個進程、線程的caps是不到1s 2W的。關於主進程、線程和工作進程、線程的分工必須明確,到底誰負責連接、誰負責業務處理、誰負責讀寫。
為了解決accept瓶頸問題,有些模式是把處理accept放到每個進程、線程中,還有些公司在linux上開發內核模塊,使用端口NAT技術,每一個核監聽一個單獨的端口。好消息是linux 3.7以上的版本支持 PortReuse這一特性,多個進程可以同時監聽一個端口又不會產生驚群效應。
工作進程負責所有工作
模型如圖3,工作線程能接受新用戶連接,主進程在listen之后創建多個進程。
圖3
核心代碼:
bind(listenfd);
listen(listenfd);
//一般創建同cpu個數個子進程
Master = 1;
for( ;<cpu_number; )
{
pid = fork();
assert(pid >= 0);
if(pid > 0)
{
continue;
}
else
{
master =-1;
break;
}
}
if(1 == master)
{
run_master();
}
else
{
run_worker();
}
void run_worker()
{
epoll_create();
for( ; ; )
{
Number = Epoll_wait();
If (event.events &EPOLLIN && fd == listenfd)
{
connfd= accept();
event[].data.fd = connfd;
AddToEpoll(connfd);
}
else if …
//循環檢測文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events &EPOLLIN)
dosomething();
else if…
}
}
可以看到主進程創建了多個字進程,然后在子進程創建自己的epoll文件描述符,有些實現是在主進程epoll創建后才fork,個人不是很喜歡此種做法。越來越火的nginx也使用了類似的模式,為了避免驚群效應,其用共享內存實現了一把互斥鎖,在調用accept之前必須先獲取到此互斥鎖。
主進程通知子進程accept
《linux 高性能服務器編程》寫了一種免鎖的工作進程accept方式,具體的實現是子進程epoll中不加入監聽句柄。在進程創建初期,創建管道,父進程epoll監聽listenfd,但是不做accept操作,而是通過管道通知某個子進程去accept。
圖4 免鎖的進程accept
核心代碼
void run_worker()
{
epoll_create();
for( ; ; )
{
Number = Epoll_wait();
//如果父進程通過管道通知了,就去accept
If (event.events &EPOLLIN && fd ==pipefd[0])
{
connfd= accept();
event[].data.fd = connfd;
AddToEpoll(connfd);
}
else if …
//循環檢測文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events &EPOLLIN)
dosomething();
else if…
}
}
線程模式
從諸多的實際應用來看,使用線程的時候很少有在子線程中做accept操作,一般的做法是主線程只做accept操作,然后子線程負責數據的讀寫。這樣編程也是最簡單的,但是極易出現主線程accept的瓶頸。
圖5 多線程IO復用
如圖5所示,在主線程accept之前,會創建一些線程和對應數量的epoll,為每一個線程分配一個epoll。主線程接受到新用戶后,因為是同一進程,直接將用戶添加到某個線程的epoll中。
核心代碼:
bind(listenfd);
listen(listenfd);
//創建同cpu個數個進程
for( ;<cpu_number; )
{
pthread_create( , worker, , );
}
//創建同cpu個數加1個eopll
for( idx = 0;<cpu_number + 1; )
{
thread_epoll[idx] = epoll_create(number);;
}
//將監聽文件描述符添加到主線程epoll
epoll_ctl( , listenfd,)
while( 1)
{
Number = Epoll_wait();
If (event.events &EPOLLIN && fd == listenfd)
{
connfd = accept();
event [].data.fd = connfd;
//輪詢或者某種算法,鎖定到某個線程
AddToEpoll(connfd);
}
else if …
{
}
}
void* worker()
{
for( ; ; )
{
Number = Epoll_wait();
//循環檢測文件描述符
for( ; ; )
{
fd = event.data.fd;
if (event.events &EPOLLIN)
{
dosomething();
}
elseif…
}
}
Epoll 線程池也有另外一種做法,主線程負責accept,負責分發任務,它會把用戶的scoket寫入鏈表,然后多個線程鏈表去競爭這個鏈表,得到鏈表的線程去除頭節點然后釋放所有權,工作線程只有業務處理,沒有epoll操作。這種做法有兩個缺點,一是主線程既要處理用戶連接請求,又要分發任務,造成主線程忙死、子線程閑死的現象,完全沒有發揮epoll和多線程的特點。

圖6
總結