摘要
編寫連接數巨大的高負載服務器程序時,經典的多線程模式和select模式都不再適用。
應當拋棄它們,采用epoll/kqueue/dev_poll來捕獲I/O事件。最后簡要介紹了AIO。
由來
網絡服務在處理數以萬計的客戶端連接時,往往出現效率低下甚至完全癱瘓,這被稱為
C10K問題。隨著互聯網的迅速發展,越來越多的網絡服務開始面臨C10K問題,作為大型
網站的開發人員有必要對C10K問題有一定的了解。本文的主要參考文獻是
<
http://www.kegel.com/c10k.html>
http://www.kegel.com/c10k.htmls。
C10K問題的最大特點是:設計不夠良好的程序,其性能和連接數及機器性能的關系往往
是非線性的。舉個例子:如果沒有考慮過C10K問題,一個經典的基于select的程序能在
舊服務器上很好處理1000并發的吞吐量,它在2倍性能新服務器上往往處理不了并發
2000的吞吐量。
這是因為在策略不當時,大量操作的消耗和當前連接數n成線性相關。會導致單個任務
的資源消耗和當前連接數的關系會是O(n)。而服務程序需要同時對數以萬計的socket進
行I/O處理,積累下來的資源消耗會相當可觀,這顯然會導致系統吞吐量不能和機器性
能匹配。為解決這個問題,必須改變對連接提供服務的策略。
基本策略
主要有兩方面的策略:1.應用軟件以何種方式和操作系統合作,獲取I/O事件并調度多
個socket上的I/O操作;2. 應用軟件以何種方式處理任務和線程/進程的關系。前者主
要有阻塞I/O、非阻塞I/O、異步I/O這3種方案,后者主要有每任務1進程、每任務1線
程、單線程、多任務共享線程池以及一些更復雜的變種方案。常用的經典策略如下:
1. Serve one client with each thread/process, and use blocking I/O
這是小程序和java常用的策略,對于交互式的長連接應用也是常見的選擇(比如BBS)。
這種策略很能難足高性能程序的需求,好處是實現極其簡單,容易嵌入復雜的交互邏
輯。Apache、ftpd等都是這種工作模式。
2. Serve many clients with single thread, and use nonblocking I/O
and readiness notification
這是經典模型,datapipe等程序都是如此實現的。優點在于實現較簡單,方便移植,也
能提供足夠的性能;缺點在于無法充分利用多CPU的機器。尤其是程序本身沒有復雜的
業務邏輯時。
3. Serve many clients with each thread, and use nonblocking I/O and
readiness notification
對經典模型2的簡單改進,缺點是容易在多線程并發上出bug,甚至某些OS不支持多線程
操作readiness notification。
4. Serve many clients with each thread, and use asynchronous I/O
在有AI/O支持的OS上,能提供相當高的性能。不過AI/O編程模型和經典模型差別相當
大,基本上很難寫出一個框架同時支持AI/O和經典模型,降低了程序的可移植性。在
Windows上,這基本上是唯一的可選方案。
本文主要討論模型2的細節,也就是在模型2下應用軟件如何處理Socket I/O。
select 與 poll
最原始的同步阻塞 I/O 模型的典型流程如下:
同步阻塞 I/O 模型的典型流程
從應用程序的角度來說,read 調用會延續很長時間,應用程序需要相當多線程來解決
并發訪問問題。同步非阻塞I/O對此有所改進:
經典的單線程服務器程序結構往往如下:
do {
Get Readiness Notification of all sockets
Dispatch ready handles to corresponding handlers
If (readable) {
read the socket
If (read done)
Handler process the request
}
if (writable)
write response
if (nothing to do)
close socket
} while(True)
非阻塞 I/O 模型的典型流程:
異步阻塞 I/O 模型的典型流程
其中關鍵的部分是readiness notification,找出哪一個socket上面發生了I/O事件。
一般從教科書和例子程序中首先學到的是用select來實現。Select定義如下:
int select(int n, fd_set *rd_fds, fd_set *wr_fds, fd_set *ex_fds, struct
timeval *timeout);
Select用到了fd_set結構,從man page里可以知道fd_set能容納的句柄和FD_SETSIZE相
關。實際上fd_set在*nix下是一個bit標志數組,每個bit表示對應下標的fd是不是在
fd_set中。fd_set只能容納編號小于 FD_SETSIZE的那些句柄。
FD_SETSIZE默認是1024,如果向fd_set里放入過大的句柄,數組越界以后程序就會垮
掉。系統默認限制了一個進程最大的句柄號不超過1024,但是可以通過ulimit -n命令
/setrlimit函數來擴大這一限制。如果不幸一個程序在FD_SETSIZE=1024的環境下編
譯,運行時又遇到ulimit –n > 1024的,那就只有祈求上帝保佑不會垮掉了。 在ACE環境中,ACE_Select_Reactor針對這一點特別作了保護措施,但是還是有recv_n
這樣的函數間接的使用了select,這需要大家注意。
針對fd_set的問題,*nix提供了poll函數作為select的一個替代品。Poll的接口如下:
int poll(struct pollfd *ufds, unsigned int nfds, int timeout);
第1個參數ufds是用戶提供的一個pollfd數組,數組大小由用戶自行決定,因此避免了
FD_SETSIZE帶來的麻煩。Ufds是fd_set的一個完全替代品,從select到poll的移植很方
便。到此為止,至少我們面對C10K,可以寫出一個能work的程序了。
然而Select和Poll在連接數增加時,性能急劇下降。這有兩方面的原因:首先操作系統
面對每次的select/poll操作,都需要重新建立一個當前線程的關心事件列表,并把線
程掛在這個復雜的等待隊列上,這是相當耗時的。其次,應用軟件在select/poll返回
后也需要對傳入的句柄列表做一次掃描來dispatch,這也是很耗時的。這兩件事都是和
并發數相關,而I/O事件的密度也和并發數相關,導致CPU占用率和并發數近似成O(n2)
的關系。
epoll, kqueue, /dev/poll
因為以上的原因,*nix的hacker們開發了epoll, kqueue, /dev/poll這3套利器來幫助
大家,讓我們跪拜三分鐘來感謝這些大神。其中epoll是linux的方案,kqueue是
freebsd的方案,/dev/poll是最古老的Solaris的方案,使用難度依次遞增。
簡單的說,這些api做了兩件事:1.避免了每次調用select/poll時kernel分析參數建立
事件等待結構的開銷,kernel維護一個長期的事件關注列表,應用程序通過句柄修改這
個列表和捕獲I/O事件。2.避免了select/poll返回后,應用程序掃描整個句柄表的開
銷,Kernel直接返回具體的事件列表給應用程序。
在接觸具體api之前,先了解一下邊緣觸發(edge trigger)和條件觸發(level trigger)
的概念。邊緣觸發是指每當狀態變化時發生一個io事件,條件觸發是只要滿足條件就發
生一個io事件。舉個讀socket的例子,假定經過長時間的沉默后,現在來了100個字
節,這時無論邊緣觸發和條件觸發都會產生一個read ready notification通知應用程
序可讀。應用程序讀了50個字節,然后重新調用api等待io事件。這時條件觸發的api會
因為還有50個字節可讀從而立即返回用戶一個read ready notification。而邊緣觸發
的api會因為可讀這個狀態沒有發生變化而陷入長期等待。
因此在使用邊緣觸發的api時,要注意每次都要讀到socket返回EWOULDBLOCK為止,否則
這個socket就算廢了。而使用條件觸發的api時,如果應用程序不需要寫就不要關注
socket可寫的事件,否則就會無限次的立即返回一個write ready notification。大家
常用的select就是屬于條件觸發這一類,以前本人就犯過長期關注socket寫事件從而
CPU 100%的毛病。
epoll的相關調用如下:
int epoll_create(int size)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int
timeout)
epoll_create創建kernel中的關注事件表,相當于創建fd_set。
epoll_ctl修改這個表,相當于FD_SET等操作
epoll_wait等待I/O事件發生,相當于select/poll函數
epoll完全是select/poll的升級版,支持的事件完全一致。并且epoll同時支持邊緣觸
發和條件觸發,一般來講邊緣觸發的性能要好一些。這里有個簡單的例子:
struct epoll_event ev, *events;
int kdpfd = epoll_create(100);
ev.events = EPOLLIN | EPOLLET; // 注意這個EPOLLET,指定了邊緣觸發
ev.data.fd =listener;
epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev);
for(;;) {
nfds = epoll_wait(kdpfd, events, maxevents, -1);
for(n = 0; n < nfds; ++n) {
if(events[n].data.fd == listener) {
client = accept(listener, (struct sockaddr *) &local,
&addrlen);
if(client < 0){
perror("accept");
continue;
}
setnonblocking(client);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = client;
if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0) {
fprintf(stderr, "epoll set insertion error: fd=%d0,
client);
return -1;
}
}
else
do_use_fd(events[n].data.fd);
}
}
簡單介紹一下kqueue和/dev/poll
kqueue是freebsd的寵兒,kqueue實際上是一個功能相當豐富的kernel事件隊列,它不
僅僅是select/poll的升級,而且可以處理signal、目錄結構變化、進程等多種事件。
Kqueue是邊緣觸發的
/dev/poll是Solaris的產物,是這一系列高性能API中最早出現的。Kernel提供一個特
殊的設備文件/dev/poll。應用程序打開這個文件得到操縱fd_set的句柄,通過寫入
pollfd來修改它,一個特殊ioctl調用用來替換select。由于出現的年代比較早,所以
/dev/poll的接口現在看上去比較笨拙可笑。
C++開發:ACE 5.5以上版本提供了ACE_Dev_Poll_Reactor封裝了epoll和/dev/poll兩種
api,需要分別在config.h中定義ACE_HAS_EPOLL和ACE_HAS_DEV_POLL來啟用。
Java開發: JDK 1.6的Selector提供了對epoll的支持,JDK1.4提供了對/dev/poll的支
持。只要選擇足夠高的JDK版本就行了。
異步I/O以及Windows
和經典模型不同,異步I/O提供了另一種思路。和傳統的同步I/O不同,異步I/O允許進
程發起很多 I/O 操作,而不用阻塞或等待任何操作完成。稍后或在接收到 I/O 操作完
成的通知時,進程就可以檢索 I/O 操作的結果。
異步非阻塞 I/O 模型是一種處理與 I/O 重疊進行的模型。讀請求會立即返回,說明
read 請求已經成功發起了。在后臺完成讀操作時,應用程序然后會執行其他處理操
作。當 read 的響應到達時,就會產生一個信號或執行一個基于線程的回調函數來完成
這次 I/O 處理過程。異步I/O 模型的典型流程:
異步非阻塞 I/O 模型的典型流程
對于文件操作而言,AIO有一個附帶的好處:應用程序將多個細碎的磁盤請求并發的提
交給操作系統后,操作系統有機會對這些請求進行合并和重新排序,這對同步調用而言
是不可能的——除非創建和請求數目同樣多的線程。
Linux Kernel 2.6提供了對AIO的有限支持——僅支持文件系統。libc也許能通過來線
程來模擬socket的AIO,不過這對性能沒意義。總的來說Linux的aio還不成熟
Windows對AIO的支持很好,有IOCP隊列和IPCP回調兩種方式,甚至提供了用戶級異步調
用APC功能。Windows下AIO是唯一可用的高性能方案,詳情請參考MSDN
Trackback: http://tb.blog.csdn.net/TrackBack.aspx?PostId=1537545