典型伺服器模式原理分析與實踐
本文作為自己學習網路程式設計的總結筆記。打算分析一下主流伺服器模式的優缺點,及適用場景,每種模型實現一個回射伺服器。客戶端用同一個版本,服務端針對每種模型編寫對應的回射伺服器。
本文所有程式碼放在: github.com/oscarwin/mu…
單程序迭代伺服器
單程序迭代伺服器是我接觸網路程式設計編寫的第一個伺服器模型,雖然程式碼只有幾行,但是每一個套接字程式設計的函式都涉及到大量的知識,這裡我並不打算介紹每個套接字函式的功能,只給出一個套接字程式設計的基礎流程圖。

有幾點需要解釋的是:
-
伺服器呼叫listen函式以後,客戶端與服務端的3次握手是由核心自己完成的,不需要應用程式的干預。核心為所有的連線維護兩個個佇列,佇列的大小之和由listen函式的backlog引數決定。服務端收到客戶算的SYN請求後,會回覆一個SYN+ACK給客戶端,並往未完成佇列中插入一項。所以未完成佇列中的連線都是SYN_RCVD狀態的。當伺服器收到客戶端的ACK應答後,就將該連線從未完成佇列轉移到已完成佇列。
-
當未完成佇列和已完成佇列滿了後,伺服器就會直接拒絕連線。常見的SYN洪水攻擊,就是通過大量的SYN請求,佔滿了該佇列,導致伺服器拒絕其他正常請求達到攻擊的目的。
-
accept函式會一直阻塞,直到已完成佇列不為空,然後從已完成佇列中取出一個完成連線的套接字。
多程序併發伺服器
單程序伺服器只能同時處理一個連線。新建立的連線會一直呆在已完成佇列裡,得不到處理。因此,自然想到通過多程序來實現同時處理多個連線。為每一個連線產生一個程序去處理,稱為PPC模式,即process per connection。其流程圖如下(圖片來自網路,侵刪):

這種模式下有幾點需要注意:
- 統一由父程序來accept連線,然後fork子程序處理讀寫
- 父程序fork以後,立即關閉了連線套接字,而子程序則立即關閉了監聽套接字。因為父程序只處理連線,子程序只處理讀寫。linux在fork了以後,子程序會繼承父程序的檔案描述符,父程序關閉連線套接字後,檔案描述符的計數會減一,在子程序裡並沒有關閉,當子程序退出關閉連線套接字後,該檔案描述符才被關閉
這種模式存在的問題:
- fork開銷大。程序fork的開銷太大,在fork時需要為子程序開闢新的程序空間,子程序還要從父程序那裡繼承許多的資源。儘管linux採用了寫時複製技術,總的來看,開銷還是很大
- 只能支援較少的連線。程序是作業系統重要的資源,每個程序都要分配獨立的地址空間。在普遍的伺服器上,該模式只能支援幾百的連線。
- 程序間通訊複雜。雖然linux有豐富的程序間通訊方法,但是這些方法使用起來都有些複雜。
核心程式碼段如下,完整程式碼在ppc_server目錄。
while(1) { clilen = sizeof(stCliAddr); if ((iConnectFd = accept(iListenFd, (struct sockaddr*)&stCliAddr, &clilen)) < 0) { perror("accept error"); exit(EXIT_FAILURE); } // 子程序 if ((childPid = fork()) == 0) { close(iListenFd); // 客戶端主動關閉,傳送FIN後,read返回0,結束迴圈 while((n = read(iConnectFd, buf, BUFSIZE)) > 0) { printf("pid: %d recv: %s\n", getpid(), buf); fflush(stdout); if (write(iConnectFd, buf, n) < 0) { perror("write error"); exit(EXIT_FAILURE); } } printf("child exit, pid: %d\n", getpid()); fflush(stdout); exit(EXIT_SUCCESS); } // 父程序 else { close(iConnectFd); } } 複製程式碼
預先派生子程序伺服器
既然fork程序時的開銷比較大,因此很自然的一種優化方式是,在伺服器啟動的時候就預先派生子程序,即prefork。每個子程序自己進行accept,大概的流程圖如下(圖片來自網路,侵刪):

相比於pcc模式,prefork在建立連線時的開銷小了很多,但是另外兩個問題——連線數有限和程序間通訊複雜的問題還是存在。除此之外,prefork模式還引入了新的問題,當有一個新的連線到來時,雖然只有一個程序能夠accept成功,但是所有的程序都被喚醒了,這個現象被稱為驚群。驚群導致不必要的上下文切換和資源的排程,應該儘量避免。好在linux2.6版本以後,已經解決了驚群的問題。對於驚群的問題,也可以在應用程式中解決,在accept之前加鎖,accept以後釋放鎖,這樣就可以保證同一時間只有一個程序阻塞accept,從而避免驚群問題。程序間加鎖的方式有很多,比如檔案鎖,訊號量,互斥量等。
無鎖版本的程式碼在prefork_server目錄。加鎖版本的程式碼在prefork_lock_server目錄,使用的是程序間共享的執行緒鎖。
多執行緒併發伺服器
執行緒是一種輕量級的程序(linux實現上派生程序和執行緒都是呼叫do_fork函式來實現),執行緒共享同一個程序的地址空間,因此建立執行緒時不需要像fork那樣,拷貝父程序的資源,維護獨立的地址空間,因此相比程序而言,多執行緒模型開銷要小很多。多執行緒併發伺服器模型與多程序併發伺服器模型類似。

多執行緒併發伺服器模型,與多程序併發伺服器模型相比,開銷小了很多。但是同樣存在連線數很有限這個限制。除此之外,多執行緒程式還引入了新的問題
- 多執行緒程式不如多程序程式穩定,一個執行緒崩潰可能導致整個程序崩潰,最終導致服務完全不可用。而多程序程式則不存在這樣的問題
- 多程序程式共享了地址空間,省去了多程序程式之間複雜的通訊方法。但是卻需要對共享資源同時訪問時進行加鎖保護
- 建立執行緒的開銷雖然比建立程序的開銷小,但是整體來說還是有一些開銷的。
預先派生執行緒伺服器
和預先派生子程序相似,可以通過預先派生執行緒來消除建立執行緒的開銷。

預先派生執行緒的程式碼在pthread_server目錄。
reactor模式
前面提及的幾種模式都沒能解決的一個問題是——連線數有限。而IO多路複用就是用來解決海量連線數問題的,也就是所謂的C10K問題。
IO多路複用有三種實現方案,分別是select,poll和epoll,關於三者之間的區別就不在贅述,網路上已經有很多文章講這個的了,比如這篇文章 Linux IO模式及 select、poll、epoll詳解 。
epoll因為其可以開啟的檔案描述符不像select那樣受系統的限制,也不像poll那樣需要在核心態和使用者態之間拷貝event,因此效能最高,被廣泛使用。
epoll有兩種工作模式,一種是LT(level triggered)模式,一種是ET(edge triggered)模式。LT模式下通知可讀,加入來了4k的資料,但是隻讀了2k,那麼再次阻塞在epoll上時,還會再次通知。而ET模式下,如果只讀了2k,再次阻塞在epoll上時,就不會通知。因此,ET模式下一次讀就要把資料全部讀完。因此,只能採用非阻塞IO,在while迴圈中讀取這個IO,read或write返回EAGAIN。如果採用了非阻塞IO,read或write會一直阻塞,導致沒有阻塞在epoll_wait上,IO多路複用就失效了。 非阻塞IO配合IO多路複用就是reactor模式 。reactor是核反應堆的意思,光是聽這名字我就覺得牛不不要不要的了。
epoll編碼的核心程式碼,我直接從man命令裡的說明裡拷貝過來了,我們的實現在目錄reactor_server裡。
#define MAX_EVENTS 10 struct epoll_event ev, events[MAX_EVENTS]; int listen_sock, conn_sock, nfds, epollfd; /* Set up listening socket, 'listen_sock' (socket(),bind(), listen()) */ // 建立epoll控制代碼 epollfd = epoll_create(10); if (epollfd == -1) { perror("epoll_create"); exit(EXIT_FAILURE); } // 將監聽套接字註冊到epoll上 ev.events = EPOLLIN; ev.data.fd = listen_sock; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) { perror("epoll_ctl: listen_sock"); exit(EXIT_FAILURE); } for (;;) { // 阻塞在epoll_wait nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1); if (nfds == -1) { perror("epoll_pwait"); exit(EXIT_FAILURE); } for (n = 0; n < nfds; ++n) { if (events[n].data.fd == listen_sock) { conn_sock = accept(listen_sock, (struct sockaddr *) &local, &addrlen); if (conn_sock == -1) { perror("accept"); exit(EXIT_FAILURE); } // 將連線套接字設定為非阻塞、邊緣觸發,然後註冊到epoll上 setnonblocking(conn_sock); ev.events = EPOLLIN | EPOLLET; ev.data.fd = conn_sock; if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock, &ev) == -1) { perror("epoll_ctl: conn_sock"); exit(EXIT_FAILURE); } } else { do_use_fd(events[n].data.fd); } } } 複製程式碼
然後我們再分析一下epoll的原理。
epoll_create建立了一個檔案描述符,這個檔案描述符實際是指向的一個紅黑樹。當用epoll_ctl函式去註冊檔案描述符時,就是往紅黑樹中插入一個節點,該節點中儲存了該檔案描述符的資訊。當某個檔案描述符準備好了,回去呼叫一個回撥函式ep_poll_callback將這個檔案描述符準備好的資訊放到rdlist裡,epoll_wait則阻塞於rdlist直到其中有資料。

proactor模式
proactor模式就是採用非同步IO加上IO多路複用的方式。使用非同步IO,將讀寫的任務也交給了核心來做,當資料已經準備好了,使用者執行緒直接就可以用,然後處理業務邏輯就OK了。
多種模式的伺服器該如何選擇
常量連線常量請求,如:管理後臺,政府網站,可以使用ppc和tpc模式
常量連線海量請求,如:中介軟體,可以使用ppc和tpc模式
海量連線常量請求,如:入口網站,ppc和tpc不能滿足需求,可以使用reactor模式
海量連線海量請求,如:電商網站,秒殺業務等,ppc和tpc不能滿足需求,可以使用reactor模式