目前支持I/O多路復用的系統調用有 select,poll,epoll,I/O多路復用就是通過一種機制,一個進程可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒后自己負責進行讀寫,也就是說這個讀寫過程是阻塞的。而異步I/O則無需自己負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。
I/O多路復用優勢和適用場景:
I/O多路復用的優勢在於,當處理的消耗對比IO幾乎可以忽略不計時,可以處理大量的並發IO,而不用消耗太多CPU/內存。這就像是一個工作很高效的人,手上一個todo list,他高效的依次處理每個任務。這比每個任務單獨安排一個人要節省。典型的例子是nginx做代理,代理的轉發邏輯相對比較簡單直接,那么IO多路復用很適合。相反,如果是一個做復雜計算的場景,計算本身可能是個 指數復雜度的東西,IO不是瓶頸。那么怎么充分利用CPU或者顯卡的核心多干活才是關鍵。
此外,IO多路復用適合處理很多閑置的IO,因為IO socket的數量的增加並不會帶來進(線)程數的增加,也就不會帶來stack內存,內核對象,切換時間的損耗。因此像長鏈接做通知的場景非常適合。
文件描述符fd
文件描述符(File descriptor)是計算機科學中的一個術語,是一個用於表述指向文件的引用的抽象化概念。
文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫往往會圍繞着文件描述符展開。但是文件描述符這一概念往往只適用於UNIX、Linux這樣的操作系統。
select
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函數監視的文件描述符分3類,分別是readfds、writefds、和exceptfds。調用后select函數會阻塞,直到有描述符就緒(有數據 可讀、可寫、或者有except),或者超時(timeout指定等待時間,如果立即返回設為null即可),函數返回。當select函數返回后,可以 通過遍歷fdset,來找到就緒的描述符。
優點:
- select目前幾乎在所有的平台上支持,其良好跨平台支持也是它的一個優點。
缺點:
-
單個進程能夠監視的文件描述符的數量存在最大限制,它由FD_SETSIZE設置,默認值是1024。
可以通過修改宏定義甚至重新編譯內核的方式提升這一限制,但 是這樣也會造成效率的降低。一般來說這個數目和系統內存關系很大,具體數目可以cat /proc/sys/fs/file-max察看。
32位機默認是1024個。64位機默認是2048.
-
fd集合在內核被置位過,與傳入的fd集合不同,不可重用。
重復進行FD_ZERO(&rset); FD_SET(fds[i],&rset);操作 -
每次調⽤用select,都需要把fd集合從用戶態拷貝到內核態,這個開銷在fd很多時會很大。
-
同時每次調用select都需要在內核遍歷傳遞進來的所有fd標志位,O(n)的時間復雜度,這個開銷在fd很多時也很大。
例:
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof (addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
listen (sockfd, 5);
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
// 創建了5個文件描述符(5個數,代表文件描述符的編號,隨機) socket可以接受5個客戶端連接,存到fds中
fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
if(fds[i] > max)
max = fds[i]; // 存入最大值
}
//-------------------fds ,max准備完畢----------------------
while(1){
FD_ZERO(&rset);
for (i = 0; i< 5; i++ ) {
FD_SET(fds[i],&rset); // rset 是 bitmap 0110010101000... //1 2 5 7 9
}
puts("round again");
// max+1最大文件描述符+1; 讀文件描述符集合;寫文件描述符集合;異常文件描述符集合;超時時間
//select就是將rset從用戶態拷貝到內核態,內核負責判斷每一個fd是否有數據來。
//無數據阻塞;有數據來后,把相應的bit置位,返回
select(max+1, &rset, NULL, NULL, NULL);
for(i=0;i<5;i++) { //遍歷rset,判斷fd對應的rset位 被置位了
if (FD_ISSET(fds[i], &rset)){
memset(buffer,0,MAXBUF);
read(fds[i], buffer, MAXBUF); //讀取數據
puts(buffer);
}
}
}
poll
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
不同與select使用三個位圖bitmap來表示三個fdset的方式,poll使用一個 pollfd的指針實現。
struct pollfd {
int fd; /* file descriptor */
//讀 POLLIN; 寫POLLOUT;
short events; /* requested events to watch 要監視的event*/
short revents; /* returned events witnessed 發生的event*/
};
pollfd結構包含了要監視的event和發生的event,不再使用select “參數-值” 傳遞的方式。同時,pollfd並沒有最大數量限制(但是數量過大后性能也是會下降)。 和select函數一樣,poll返回后,需要輪詢pollfd來獲取就緒的描述符。
優點:
- poll用pollfd數組代替了bitmap,沒有最大數量限制。(解決select缺點1)
- 利用結構體pollfd,每次置位revents字段,每次只需恢復revents即可。pollfd可重用。(解決select缺點2)
缺點:
-
每次調⽤用poll,都需要把pollfd數組從用戶態拷貝到內核態,這個開銷在fd很多時會很大。(同select缺點3)
-
和select函數一樣,poll返回后,需要輪詢pollfd來獲取就緒的描述符。事實上,同時連接的大量客戶端在一時刻可能只有很少的處於就緒狀態,因此隨着監視的描述符數量的增長,其效率也會線性下降。(同select缺點4)
例:
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN; // 讀 POLLIN
}
sleep(1);
// ---------------------------
while(1){
puts("round again");
//pollfds:pollfd的數組; 數組中有5個元素;超時時間
//將rset從用戶態拷貝到內核態
//無數據,阻塞;有數據,將相應的pollfd.revents置位,返回
poll(pollfds, 5, 50000);
for(i=0;i<5;i++) { //遍歷 判斷
if (pollfds[i].revents & POLLIN){
pollfds[i].revents = 0;
memset(buffer,0,MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer);
}
}
}
epoll
epoll是在2.6內核中提出的,是之前的select和poll的增強版本。相對於select和poll來說,epoll更加靈活,沒有描述符限制。epoll使用一個文件描述符管理多個描述符,將用戶關系的文件描述符的事件存放到內核的一個事件表中,這樣在用戶空間和內核空間的copy只需一次。
1. epoll操作過程
epoll操作過程需要三個接口,分別如下:
int epoll_create(int size);//創建一個epoll的句柄,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);
1. int epoll_create(int size);
創建一個epoll的句柄,size用來告訴內核這個監聽的數目一共有多大,這個參數不同於select()中的第一個參數,給出最大監聽的fd+1的值,參數size並不是限制了epoll所能監聽的描述符最大個數,只是對內核初始分配內部數據結構的一個建議。
當創建好epoll句柄后,它就會占用一個fd值,在linux下如果查看/proc/進程id/fd/,是能夠看到這個fd的,所以在使用完epoll后,必須調用close()關閉,否則可能導致fd被耗盡。
當某一進程調用epoll_create方法時,Linux內核會創建一個eventpoll結構體,這個結構體中有兩個成員與epoll的使用方式密切相關。
eventpoll結構體如下所示:
struct eventpoll{
...
// 紅黑樹的根節點,這棵樹中存儲着所有添加到epoll中的需要監控的事件
struct rb_root rbr;
// 雙鏈表中則存放着將要通過epoll_wait返回給用戶的滿足條件的事件
struct list_head rdlist;
...
}
每一個epoll對象都有一個獨立的eventpoll結構體,用於存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重復添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是lgn,其中n為樹的高度)。
而所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關系,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函數是對指定描述符fd執行op操作。
用於向內核注冊新的描述符或者是改變某個文件描述符的狀態。已注冊的描述符在內核中會被維護在一棵紅黑樹上
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三個宏來表示:添加EPOLL_CTL_ADD,刪除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分別添加、刪除和修改對fd的監聽事件。
- fd:是需要監聽的fd(文件描述符)
- epoll_event:是告訴內核需要監聽什么事,struct epoll_event結構如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下幾個宏的集合:
EPOLLIN :表示對應的文件描述符可以讀(包括對端SOCKET正常關閉);
EPOLLOUT:表示對應的文件描述符可以寫
EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這里應該表示有帶外數據到來);
EPOLLERR:表示對應的文件描述符發生錯誤
EPOLLHUP:表示對應的文件描述符被掛斷
EPOLLET: 將EPOLL設為邊緣觸發(Edge Triggered)模式,這是相對於水平觸發(Level Triggered)來說的。
EPOLLONESHOT:只監聽一次事件,當監聽完這次事件之后,如果還需要繼續監聽這個socket的話,需要再次把這個socket加入到EPOLL隊列里
在epoll中,對於每一個事件,都會建立一個epitem結構體,如下所示 :
struct epitem{
struct rb_node rbn; //紅黑樹節點
struct list_head rdllink; //雙向鏈表節點
struct wpoll_filefd ffd; //事件句柄信息
struct evntpoll *ep; //指向其所屬的eventpoll對象
struct epoll_event event; //期待發生的事件類型
}
當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發生的事件復制到用戶態,同時將事件數量返回給用戶 。
3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents個事件。
通過回調函數內核會將 I/O 准備好的描述符添加到rdlist雙鏈表管理,進程調用 epoll_wait() 便可以得到事件完成的描述符。
參數events用來從內核得到事件的集合,maxevents告之內核這個events有多大,這個maxevents的值不能大於創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。
2.工作模式
epoll對文件描述符的操作有兩種模式:LT (level trigger)(默認)和ET (edge trigger)。LT模式是默認模式。
LT模式與ET模式的區別如下:
- LT模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序可以不立即處理該事件。下次調用epoll_wait時,會再次響應應用程序並通知此事件。
- ET模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序必須立即處理該事件。如果不處理,下次調用epoll_wait時,不會再次響應應用程序並通知此事件。
1. LT模式
LT(level triggered)是缺省的工作方式,並且同時支持block和no-block socket。在這種做法中,內核告訴你一個文件描述符是否就緒了,然后你可以對這個就緒的fd進行IO操作。如果你不作任何操作,內核還是會繼續通知你的。
2. ET模式
ET(edge-triggered)是高速工作方式,只支持no-block socket。在這種模式下,當描述符從未就緒變為就緒時,內核通過epoll告訴你。然后它會假設你知道文件描述符已經就緒,並且不會再為那個文件描述符發送更多的就緒通知,直到你做了某些操作導致那個文件描述符不再為就緒狀態了(比如,你在發送,接收或者接收請求,或者發送接收的數據少於一定量時導致了一個EWOULDBLOCK 錯誤)。但是請注意,如果一直不對這個fd作IO操作(從而導致它再次變成未就緒),內核不會發送更多的通知(only once)
ET模式在很大程度上減少了epoll事件被重復觸發的次數,因此效率要比LT模式高。epoll工作在ET模式的時候,必須使用非阻塞套接口,以避免由於一個文件句柄的阻塞讀/阻塞寫操作把處理多個文件描述符的任務餓死。
3. 總結
假如有這樣一個例子:
- 我們已經把一個用來從管道中讀取數據的文件句柄(RFD)添加到epoll描述符
- 這個時候從管道的另一端被寫入了2KB的數據
- 調用epoll_wait(2),並且它會返回RFD,說明它已經准備好讀取操作
- 然后我們讀取了1KB的數據
- 調用epoll_wait(2)......
LT模式:
如果是LT模式,那么在第5步調用epoll_wait(2)之后,仍然能受到通知。
ET模式:
如果我們在第1步將RFD添加到epoll描述符的時候使用了EPOLLET標志,那么在第5步調用epoll_wait(2)之后將有可能會掛起,因為剩余的數據還存在於文件的輸入緩沖區內,而且數據發出端還在等待一個針對已經發出數據的反饋信息。只有在監視的文件句柄上發生了某個事件的時候 ET 工作模式才會匯報事件。因此在第5步的時候,調用者可能會放棄等待仍在存在於文件輸入緩沖區內的剩余數據。
當使用epoll的ET模型來工作時,當產生了一個EPOLLIN事件后,
讀數據的時候需要考慮的是當recv()返回的大小如果等於請求的大小,那么很有可能是緩沖區還有數據未讀完,也意味着該次事件還沒有處理完,所以還需要再次讀取:
while(rs){
buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
if(buflen < 0){
// 由於是非阻塞的模式,所以當errno為EAGAIN時,表示當前緩沖區已無數據可讀
// 在這里就當作是該次事件已處理處.
if(errno == EAGAIN){
break;
}
else{
return;
}
}
else if(buflen == 0){
// 這里表示對端的socket已正常關閉.
}
if(buflen == sizeof(buf){
rs = 1; // 需要再次讀取
}
else{
rs = 0;
}
}
Linux中的EAGAIN含義
Linux環境下開發經常會碰到很多錯誤(設置errno),其中EAGAIN是其中比較常見的一個錯誤(比如用在非阻塞操作中)。從字面上來看,是提示再試一次。這個錯誤經常出現在當應用程序進行一些非阻塞(non-blocking)操作(對文件或socket)的時候。
例如,以 O_NONBLOCK的標志打開文件/socket/FIFO,如果你連續做read操作而沒有數據可讀。此時程序不會阻塞起來等待數據准備就緒返回,read函數會返回一個錯誤EAGAIN,提示你的應用程序現在沒有數據可讀請稍后再試。
又例如,當一個系統調用(比如fork)因為沒有足夠的資源(比如虛擬內存)而執行失敗,返回EAGAIN提示其再調用一次(也許下次就能成功)。
3. 代碼演示
下面是一段不完整的代碼且格式不對,意在表述上面的過程,去掉了一些模板代碼。
//添加監聽描述符事件
add_event(epollfd,listenfd,EPOLLIN);
//循環等待
for ( ; ; ){
//該函數返回已經准備好的描述符事件數目
ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
//處理接收到的連接
handle_events(epollfd,events,ret,listenfd,buf);
}
//事件處理函數
static void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf)
{
int i;
int fd;
//進行遍歷;這里只要遍歷已經准備好的io事件。num並不是當初epoll_create時的FDSIZE。
for (i = 0;i < num;i++)
{
fd = events[i].data.fd;
//根據描述符的類型和事件類型進行處理
if ((fd == listenfd) &&(events[i].events & EPOLLIN))
handle_accpet(epollfd,listenfd);
else if (events[i].events & EPOLLIN)
do_read(epollfd,fd,buf);
else if (events[i].events & EPOLLOUT)
do_write(epollfd,fd,buf);
}
}
//添加事件
static void add_event(int epollfd,int fd,int state){
struct epoll_event ev;
ev.events = state;
ev.data.fd = fd;
epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
}
//處理接收到的連接
static void handle_accpet(int epollfd,int listenfd){
int clifd;
struct sockaddr_in cliaddr;
socklen_t cliaddrlen;
clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);
if (clifd == -1)
perror("accpet error:");
else {
printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port); //添加一個客戶描述符和事件
add_event(epollfd,clifd,EPOLLIN);
}
}
//讀處理
static void do_read(int epollfd,int fd,char *buf){
int nread;
nread = read(fd,buf,MAXSIZE);
if (nread == -1) {
perror("read error:");
close(fd); //記住close fd
delete_event(epollfd,fd,EPOLLIN); //刪除監聽
}
else if (nread == 0) {
fprintf(stderr,"client close.\n");
close(fd); //記住close fd
delete_event(epollfd,fd,EPOLLIN); //刪除監聽
}
else {
printf("read message is : %s",buf);
//修改描述符對應的事件,由讀改為寫
modify_event(epollfd,fd,EPOLLOUT);
}
}
//寫處理
static void do_write(int epollfd,int fd,char *buf) {
int nwrite;
nwrite = write(fd,buf,strlen(buf));
if (nwrite == -1){
perror("write error:");
close(fd); //記住close fd
delete_event(epollfd,fd,EPOLLOUT); //刪除監聽
}else{
modify_event(epollfd,fd,EPOLLIN);
}
memset(buf,0,MAXSIZE);
}
//刪除事件
static void delete_event(int epollfd,int fd,int state) {
struct epoll_event ev;
ev.events = state;
ev.data.fd = fd;
epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
}
//修改事件
static void modify_event(int epollfd,int fd,int state){
struct epoll_event ev;
ev.events = state;
ev.data.fd = fd;
epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
}
//注:另外一端我就省了
4. epoll總結
在 select/poll中,進程只有在調用一定的方法后,內核才對所有監視的文件描述符進行掃描,而epoll事先通過epoll_ctl()來注冊一 個文件描述符,一旦基於某個文件描述符就緒時,內核會采用類似callback的回調機制,迅速激活這個文件描述符,當進程調用epoll_wait() 時便得到通知。(此處去掉了遍歷文件描述符,而是通過監聽回調的的機制。這正是epoll的魅力所在。)
優點:
-
監視的描述符數量不受限制,它所支持的FD上限是最大可以打開文件的數目,這個數字一般遠大於2048,舉個例子,在1GB內存的機器上大約是10萬左 右,具體數目可以cat /proc/sys/fs/file-max察看,一般來說這個數目和系統內存關系很大。
select的最大缺點就是進程打開的fd是有數量限制的。這對 於連接數量比較大的服務器來說根本不能滿足。雖然也可以選擇多進程的解決方案( Apache就是這樣實現的),不過雖然linux上面創建進程的代價比較小,但仍舊是不可忽視的,加上進程間數據同步遠比不上線程間同步的高效,所以也不是一種完美的方案。
-
epoll是內核空間用一個 紅黑樹維護所有的fd,epoll_wait 通過回調函數內核會將 I/O 准備好的描述符加入到一個鏈表中管理,只把就緒的fd用鏈表復制到用戶空間。
-
IO的效率不會隨着監視fd的數量的增長而下降。epoll不同於select和poll輪詢的方式,而是通過每個fd定義的回調函數來實現的。只有就緒的fd才會執行回調函數。
如果沒有大量的idle -connection或者dead-connection,epoll的效率並不會比select/poll高很多,但是當遇到大量的idle- connection,就會發現epoll的效率大大高於select/poll。
不用重復傳遞。我們調用epoll_wait時就相當於以往調用select/poll,但是這時卻不用傳遞socket句柄給內核,因為內核已經在epoll_ctl中拿到了要監控的句柄列表。
在內核里,一切皆文件。所以,epoll向內核注冊了一個文件系統,用於存儲上述的被監控socket。當你調用epoll_create時,就會在這個虛擬的epoll文件系統里創建一個file結點。當然這個file不是普通文件,它只服務於epoll。
epoll在被內核初始化時(操作系統啟動),同時會開辟出epoll自己的內核高速cache區,用於安置每一個我們想監控的socket,這些socket會以紅黑樹的形式保存在內核cache里,以支持快速的查找、插入、刪除。這個內核高速cache區,就是建立連續的物理內存頁,然后在之上建立slab層,簡單的說,就是物理上分配好你想要的size的內存對象,每次使用時都是使用空閑的已分配好的對象。
極其高效的原因:
這是由於我們在調用epoll_create時,內核除了幫我們在epoll文件系統里建了個file結點,在內核cache里建了個紅黑樹用於存儲以后epoll_ctl傳來的socket外,還會再建立一個list鏈表,用於存儲准備就緒的事件,當epoll_wait調用時,僅僅觀察這個list鏈表里有沒有數據即可。有數據就返回,沒有數據就sleep,等到timeout時間到后即使鏈表沒數據也返回。所以,epoll_wait非常高效。這個准備就緒list鏈表是怎么維護的呢?
當我們執行epoll_ctl時,除了把socket放到epoll文件系統里file對象對應的紅黑樹上之外,還會給內核中斷處理程序注冊一個回調函數,告訴內核,如果這個句柄的中斷到了,就把它放到准備就緒list鏈表里。所以,當一個socket上有數據到了,內核在把網卡上的數據copy到內核中后就來把socket插入到准備就緒鏈表里了。
上面這句可以看出,epoll的基礎就是回調!如此,一顆紅黑樹,一張准備就緒句柄鏈表,少量的內核cache,就幫我們解決了大並發下的socket處理問題。執行epoll_create時,創建了紅黑樹和就緒鏈表,執行epoll_ctl時,如果增加socket句柄,則檢查在紅黑樹中是否存在,存在立即返回,不存在則添加到樹干上,然后向內核注冊回調函數,用於當中斷事件來臨時向准備就緒鏈表中插入數據。執行epoll_wait時立刻返回准備就緒鏈表里的數據即可。
Redis IO多路復用技術
redis 是一個單線程卻性能非常好的內存數據庫, 主要用來作為緩存系統。 redis 采用網絡IO多路復用技術來保
證在多連接的時候, 系統的高吞吐量。
為什么Redis中要使用I/O多路復用呢?
首先,Redis 是跑在單線程中的,所有的操作都是按照順序線性執行的,但是由於讀寫操作等待用戶輸入或輸出都
是阻塞的,所以 I/O 操作在一般情況下往往不能直接返回,這會導致某一文件的 I/O 阻塞導致整個進程無法對其
它客戶提供服務,而 I/O 多路復用就是為了解決這個問題而出現的。
select,poll,epoll都是IO多路復用的機制。I/O多路復用就通過一種機制,可以監視多個描述符,一旦某個
描述符就緒,能夠通知程序進行相應的操作。
redis的io模型主要是基於epoll實現的,不過它也提供了 select和kqueue的實現,默認采用epoll 。
為什么 Redis 使用了單線程 IO 多路復用,為什么那么快?
-
cpu 處理相比於 IO 可以忽略
每個客戶端建立連接時,都需要服務端為其 創建 socket 套接字,建立連接。
然后該客戶端的每個請求都要經歷以下幾步:
(1)等待請求數據數據從客戶端發送過來
(2)將請求數據從內核復制到用戶進程的緩沖區(buffer)
(3)對請求數據進行處理(對於 redis 而言,一般就是簡單的 get/set)由於操作簡單+只涉及內存,所以第(3)步的處理很簡單、很快,主要時間耗在(1)步,所以,如果采用普通 BIO 模式,每個請求都要經歷這幾步,那么處理十萬條數據,就要在(1)步花費大量的時間,這樣的話,qps 一定很低。
所以就采用了更高效的 IO 多路復用模式,即,將(1)步統一交給第三方(也就是操作系統,操作系統提供了 select、poll、epoll、kqueue、iocp等系統調用函數),結合 redis 的單線程,現在整個處理流程是這樣的:
一下子來了一堆請求,線程將這些請求都交給操作系統去處理,讓操作系統幫忙完成第(1)步,等到這些請求里的一個或多個走完了第(1)步,就將一個集合交給這個線程,並說,我這里收集到了幾個走完第一步的請求,你去走(2)、(3)步吧。於是線程拿着這個集合去遍歷,等遍歷結束之后。又去檢查操作系統那兒有沒有(這個線程自己維護了一個 while 循環)走完第(1)步的請求,發現又有一些了,拿到后繼續遍歷進行(2)、(3)步,如此循環往復。
注:有些 IO 模式是將(1)(2)步都交給操作系統處理了,線程本身只需處理第(3)步 -
瓶頸在帶寬,而不在 cpu
由於 數據存放在內存中+處理邏輯簡單,導致即使是單線程,Redis 可支持的 qps 也相當大,而當 qps 相當大的時候,首先限制性能的是帶寬,即不需要把 cpu 的性能挖掘出來,因為在這之前,帶寬就不夠用了。所以沒有必要為了提高 cpu 利用率而使用多線程處理業務邏輯。
