目錄
本文中的代碼示例建議實踐一遍。
1 爲什麼要進行I/O複用?
在這之前,大家應該先對5中I/O模型有一個簡單的瞭解。
首先我們應該都知道,I/O複用可以同時對多個文件描述符進行監聽,能很大程度上去提高程序的性能。就相當於一個同時能監聽多個電話的電話接線員。
I/O複用在網絡編程中最爲常用,在傳統的網絡編程中,如果有多個TCP連接,往往都是去開闢多個線程去進行處理,在線程數量較多的情況下,線程的開闢、喚醒等操作大大的浪費CPU時間。因此如果能有一個類似於觀察者的角色去監聽多個連接,那麼程序的性能將能得到大幅度的提升。這個時候I/O複用就閃亮登場了。
在網絡編程的過程中,以下幾種情況(不限於)需要使用I/O複用模型:
- 客戶端要處理多個socket。
- 客戶端程序要同時處理用戶輸入和網絡連接。
- TCP服務器要同時處理監聽socket和連接socket。這種情況是I/O複用使用最多的一種情況。
- 服務器要同時處理TCP請求和UDP請求。
- 服務器要同時監聽多個窗口,或者處理多種任務。
I/O複用和其他I/O模型的比較如下圖所示:
Linux通過select、poll和epoll三種系統調用實現了I/O複用。epoll是linux特有的。下面進行一一介紹。
2 select
select系統調用可以在某一時間段內監聽文件描述符上的可讀、可寫或者異常事件。
爲了便於理解select,我形象的比喻一下這句話:
假如你是公司老闆,我們將select看作是你的專屬接線員,你有幾個客戶的電話號碼(相當於文件描述符),然後你將這幾個電話好號碼告訴了你的接線員,並且告訴他,如果這些客戶要是買產品的話再告訴你(將可讀操作比喻爲買產品),其他情況不用告訴我,並且一個小時彙報一次,不管有沒有客戶買產品。那麼對於你而言,只有當固定的幾個客戶打電話並且要買產品,你才能從接線員那裏收到消息。當然除了買產品以外,你還可以規定其他兩件事情,但是不能超過三件。(這些事情相當於:可讀、寫和異常)
select其實是在指定事件內輪詢一定數量的文件描述符,檢測是否有就緒者。
下面是select常用的操作:
#include<sys/select.h>
int select(int nfds,fd_set *readfds,fd_set* writefds,fd exceptfds,struct timeval * timeout);
此函數用於監聽文件描述符,文件描述符存於readfds,writefds,exceptfds.
nfds是需要監聽文件描述符的個數,假設readfds包含1個文件描述符,writefds包含兩個文件描述符,exceptfds包含3個文件描述符,那麼,nfds = 1+2+3=6。通常設置爲文件描述符的最大值+1,因爲文件描述符是從0開始計數的。
readfds、writefds和exceptfds是三個fd_set指針,fd_set是一個結構體,裏面有一個整型數組,用於存儲文件描述符。三個指針爲空,那麼select只監聽超時。如果不爲空,那麼select就去監聽文件描述符的響相應事件。readfds監聽讀事件,writefds監聽寫事件,exceptfds監聽異常事件。某一個爲NULL,那就不監聽相應的事件。
timeout爲一個結構體,用於設置超時時間。
struct timeval
{
long tv_ser;//秒
long tv_usec;//毫秒
};
select失敗返回-1,超時返回0,事件就緒返回大於0的數,表示滿足就緒事件文件描述符的個數。
例如有3個文件描述符滿足可讀操作,那麼select就返回3。
FD_ZERO(fd_set* fdset);//對fdset進行清零操作
FD_SET(int fd,fd_set *fdset);//即將fd添加到fdset的存儲描述符的數組中
FD_CLR(int fd,fd_set* fdset);//將fd從fdset的文件描述符數組中刪除
int FD_ISSET(int fd,fd_set *fdset);//即檢查fdset文件描述符數組中的fd是否發生變化,即相應的事件是否滿足,如果事件滿足返回真,否則返回假。這個函數是在使用select中最常使用的一個函數,用於檢測相應文件描述符是否有事件發生。
下面是一個示例,用監聽socket和連接socket,包含兩個文件select.c和cli.c。
在介紹poll和epoll的時候客戶端的代碼和cli.c一樣,這裏給出,後面不再展示。
select.c
#include <stdio.h>
#include<unistd.h>
#include<string.h>
#include<assert.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/select.h>
#include<sys/time.h>
#include<signal.h>
//同時監聽套接字的最大個數
# define MAXD 10
//收集文件描述符
void FdsAdd(int fds[],int fd)
{
int i = 0;
for(;i<MAXD;++i)
{
//找到一個位置放入
if(fds[i]==-1)
{
fds[i]=fd;
break;
}
}
}
//刪除文件描述符
void FdsDel(int fds[],int fd)
{
int i = 0;
for(;i<MAXD;++i)
{
if(fds[i]==fd)
{
fds[i]=-1;
break;
}
}
}
int main()
{
//創建套接字和地址
int sockfd = socket(AF_INET,SOCK_STREAM,0);
assert(sockfd!=-1);
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family=AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
assert(res!=-1);
listen(sockfd,5);
fd_set fdSet;//一個描述符集合,用於監聽固定事件
int fds[MAXD];//收集要監聽的描述符
//對fds進行初始化操作
int i = 0;
for(;i<MAXD;++i)
fds[i]=-1;
//將監聽套接字sockfd添加到集合fds中
FdsAdd(fds,sockfd);
while(1)
{
//對fdSet進行清零操作
FD_ZERO(&fdSet);
//用於存儲要最大的文件描述符
int maxFd = 0;
for(i=0;i<MAXD;++i)
{
if(fds[i]==-1)
continue;
//將文件描述符添加到fdSet中
FD_SET(fds[i],&fdSet);
if(fds[i]>maxFd)
maxFd=fds[i];
}
//設置超時時間
struct timeval tv = {5,0};
int n = select(maxFd+1,&fdSet,NULL,NULL,&tv);//只對讀事件感興趣
printf("n = %d\n",n);
//從select的返回值來判斷
if(n==-1)
perror("error\n");
else if(n==0)
printf("time out\n");
else
{
printf("進入事件處理\n");
//判斷是哪個文件描述符有可讀的操作
for(i = 0;i<MAXD;++i)
{
if(fds[i]==-1)
continue;
//可讀
if(FD_ISSET(fds[i],&fdSet))
{
//監聽套接字sockfd可讀,表明有新連接
if(fds[i]==sockfd)
{
//創建新連接
struct sockaddr_in caddr;
int len = sizeof(caddr);
int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
if(c<0)
continue;
printf("accept c =%d\n",c);
//將連接套接字放入集合中
FdsAdd(fds,c);
}
//監聽套接字可讀
else
{
char buff[128] = {0};
int res = recv(fds[i],buff,127,0);
if(res<=0)//斷開連接
{
close(fds[i]);
FdsDel(fds,fds[i]);
printf("one client is over\n");
}
else
{
printf("recv %d :%s\n",fds[i],buff);
send(fds[i],"ok",2,0);
}
}
}
}
}
}
return 0;
}
cli.c
#include <stdio.h>
#include<unistd.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<assert.h>
#include<string.h>
#include<stdlib.h>
int main()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in caddr;
memset(&caddr,0,sizeof(caddr));
caddr.sin_family=AF_INET;
caddr.sin_port=htons(6000);
caddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = connect(sockfd,(struct sockaddr*)&caddr,sizeof(caddr));
assert(res!=-1);
while(1)
{
printf("input:\n");
char buff[128]={0};
fgets(buff,128,stdin);
if(strncmp(buff,"end",3)==0)
{
break;
}
send(sockfd,buff,strlen(buff),0);
memset(buff,0,128);
recv(sockfd,buff,127,0);
printf("recv buff = %s\n",buff);
}
close(sockfd);
return 0;
}
執行結果如下圖所示:
可以發現select可以完美的處理監聽套接字和連接套接字。
我們可以發現,select有個缺點,假設我們有一個文件描述符,如果我們要監聽它的可讀和可寫,那麼這個文件描述符就需要傳入到兩個fd_set中,再將兩個fd_set傳入到select中,略顯繁瑣,另一個系統調用poll將這個問題完美的解決掉了。另外,select監測的事件只有3種,而poll將監測事件細化了。
3 poll
poll和select非常相似,也是在指定事件內輪詢一定數量的文件描述符,檢測是否有就緒者。
至於poll和select有什麼區別,看完poll的相關操作就明瞭了。
#include<poll.h>
int poll(struct pollfd* fds,nfds_t nfds,int timeout);
用於監聽文件描述符。nfds文件描述符個數,timeout爲超時時間,單位毫秒,如果爲-1,poll永遠阻塞,爲0立即返回。
返回值的含義也和select相同,所以我們着重看一下pollfd這個結構體。
struct pollfd
{
int fd;//文件描述符
short events;//註冊的事件,即感興趣的事件。可以同時註冊多個事件,用“|”隔開即可
short revents;//實際發生的事件,當檢測有某個事件發生後,內核會將事件填充到這個變量裏。如果沒有事件發生,內核會將這個變量清零。
}
poll中傳入的是一個pollfd數組
poll可以監測的事件和相應的描述如下所示:(圖片來源於網絡)
其中POLLIN最常用。接下來我們來看poll的示例,兩個文件poll.c和cli.c,cli.c上一節中已經給出。
poll.c
#include <stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<poll.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/socket.h>
//監聽文件符最大個數
#define MAXFD 10
//創建監聽套接字
int CreateSocket()
{
int sockfd= socket(AF_INET,SOCK_STREAM,0);
assert(sockfd!=-1);
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family=AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
assert(res!=-1);
listen(sockfd,5);
return sockfd;
}
//創建連接套接字
int CreateConSocket(int sockfd)
{
struct sockaddr_in caddr;
int len = sizeof(caddr);
memset(&caddr,0,sizeof(caddr));
int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
assert(c>=0);
printf("accept c = %d\n",c);
return c;
}
//添加文件描述符
void FdsAdd(struct pollfd fds[],int fd)
{
int i =0;
for(;i<MAXFD;++i)
{
//插入到合適的位置
if(fds[i].fd==-1)
{
fds[i].fd= fd;
fds[i].events=POLLIN;//註冊可讀事件
fds[i].revents=0;
break;
}
}
}
//刪除文件描述符
void FdsDel(struct pollfd fds[],int fd)
{
int i = 0;
for(;i<MAXFD;++i)
{
if(fds[i].fd==-1)
continue;
if(fds[i].fd==fd)
{
fds[i].fd=-1;
fds[i].events=0;
fds[i].revents=0;
break;
}
}
}
//對pollfd數組進行初始化
void FdsInit(struct pollfd fds[])
{
int i = 0;
for(;i<MAXFD;++i)
{
fds[i].fd=-1;
fds[i].events=0;
fds[i].revents=0;
}
}
int main()
{
int sockfd = CreateSocket();
struct pollfd fds[MAXFD];
FdsInit(fds);
FdsAdd(fds,sockfd);
while(1)
{
int n = poll(fds,MAXFD,5000);//5秒超時
if(n==-1)
perror("poll error\n");
if(n ==0)
printf("time out\n");
else
{
int i = 0;
for(;i<MAXFD;++i)
{
if(fds[i].fd==-1)
continue;
if(fds[i].revents&POLLIN)
{
//監聽套接字
if(fds[i].fd==sockfd)
{
int c =CreateConSocket(sockfd);
FdsAdd(fds,c);
}
//連接套接字
else
{
char buff[128]={0};
int n = recv(fds[i].fd,buff,127,0);
if(n<=0)
{
close(fds[i].fd);
FdsDel(fds,fds[i].fd);
printf("one client is over\n");
}
else
{
printf("recv %d:%s\n",fds[i].fd,buff);
send(fds[i].fd,"ok",2,0);
}
}
}
}
}
}
return 0;
}
程序運行效果如下圖所示:
select和poll採用的都是輪詢檢測的機制,即每次調用都要重複的將文件描述符傳入到內核當中,這一點很大程度上降低了程序的運行效率,因此linux提出了自己特有的epoll。
4 epoll(linux特有)
epoll是linux特有的I/O複用函數,它在實現、使用上與select和poll有很大的差異。首先,epoll是用一組函數來完成監聽任務,而不是一個函數。其次,epoll把用戶關心的文件描述符上的事件放在內核中的一個事件表上,無需像select和poll那樣每次都將文件描述符拷入到內核當中。
4.1 epoll介紹與示例
接下來我們介紹一下epoll的幾個操作函數。
#include<sys/epoll.h>
int epoll_create(int size);
此函數用於在內核中創建一個事件表,size並不起作用,只是給一個提示,告訴內核事件表多大。
函數返回內核事件表的文件描述符。
因爲epoll需要在內核中去創建一個內核事件表,因此需要使用一個額外的文件描述符去標識內核中的這個事件表。
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
此函數用於對事件表進行操作,epfd爲epoll_create的返回值。
op爲操作方式,有如下幾種:
EPOLL_CTL_ADD:向事件表中添加事件。
EPOLL_CTL_MOD:修改事件表上的事件。
EPOLL_CTL_DEL:刪除事件表上的事件。
fd爲要操作的文件描述符,event參數指定事件,即用來表明用戶感興趣的文件描述事件。
epoll_event的定義如下:
struct epoll_event
{
_uint32_t events;//epoll事件,即用戶感興趣的事件
epoll_data_t data; //用戶數據
};
typedef union epoll_data
{
……
int fd;//用來存儲文件描述符
……
};
當要刪除文件描述符的時候,可以將event設爲NULL.
epoll的核心函數爲epoll_wait,用於監聽文件描述符
int epoll_wait(int epfd,struct epoll_event* events,int maxevents,int timeout);
epfd、maxevents和timeout很好理解。
events是指向一個epoll_event的數組,是一個用來收集文件描述符的集合。內核會將事件發生的文件描述符和相關信息存入events。
返回值的含義與select和poll一樣。
什麼都沒有代碼來的直觀,我們能來看一下epoll的示例。epoll.c和cli.c,cli.c和前面一樣。
#include <stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<sys/epoll.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<sys/socket.h>
//監聽文件符最大個數
#define MAXFD 10
//創建監聽套接字
int CreateSocket()
{
int sockfd= socket(AF_INET,SOCK_STREAM,0);
assert(sockfd!=-1);
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family=AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
assert(res!=-1);
listen(sockfd,5);
return sockfd;
}
//創建連接套接字
int CreateConSocket(int sockfd)
{
struct sockaddr_in caddr;
int len = sizeof(caddr);
memset(&caddr,0,sizeof(caddr));
int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
if(c<0)
perror("accept error\n");
printf("accept c = %d\n",c);
return c;
}
//向事件表中註冊事件
void EpollAdd(int epfd,int fd)
{
//創建epoll_event結構體
struct epoll_event ev;
ev.data.fd=fd;
ev.events=EPOLLIN;//這個事件和poll的很相似,前面加E
//將事件添加到事件表
if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)==-1)
perror("epoll add error\n");
}
//從事件表中刪除事件
void EpollDel(int epfd,int fd)
{
if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)==-1)
perror("epoll del error\n");
}
int main()
{
int sockfd = CreateSocket();
//創建內核事件表
int epfd = epoll_create(MAXFD);
if(epfd==-1)
perror("epoll create errror\n");
//將監聽套接字加入
EpollAdd(epfd,sockfd);
//用來存儲事件發生變化的文件描述符和相關的信息
struct epoll_event events[MAXFD];
while(1)
{
int n = epoll_wait(epfd,events,MAXFD,5000);
if(n==-1)
perror("epoll wait error\n");
if(n==0)
printf("time out\n");
else
{
int i = 0;
for(;i<n;++i)
{
int fd = events[i].data.fd;
if(events[i].events&EPOLLIN)
{
//監聽套接字
if(fd==sockfd)
{
int c =CreateConSocket(sockfd);
EpollAdd(epfd,c);
}
else
{
char buff[128]={0};
int res = recv(fd,buff,127,0);
if(res<=0)
{
EpollDel(epfd,fd);
close(fd);
printf("one client is over\n");
}
else
{
printf("recv %d : %s\n",fd,buff);
send(fd,"ok",2,0);
}
}
}
}
}
}
return 0;
}
代碼運行結果如下圖所示:
你以爲epoll這就完了?想的太簡單,接下來我介紹一些epoll的兩種工作模式。
4.2 LT模式和ET模式
linux epoll的工作方式有兩種,一種是LT(電平觸發)模式,另一種是ET(邊沿觸發)模式。
LT模式:即當文件描述符上有數據的時候,如果一次沒有讀完,io複用函數會一直提醒我們知道數據讀完。LT模式下有阻塞和非阻塞兩種模式,epoll默認的工作方式是阻塞的LT模式。
ET模式:當數據描述符上有數據時,io複用函數只會提醒一次。因此在ET模式下,當文件描述符事件發生的時候,要一次將數據處理完,如果一次沒有將數據處理完那麼不會有第二次提醒。因此ET工作方式只有非阻塞模式,因爲如果是阻塞模式的話,那麼程序一定會阻塞在最後一次的write或者read函數。
如上述,ET模式很大程度上降低了同一個epoll事件被重複觸發的次數,因此ET模式的效率要比LT模式高。
應用下面代碼可以將文件描述符設置位非阻塞的:
void setnonblock(int fd)
{
int oldfl = fcntl(fd,F_GETFL);//取得文件標誌位
int newfl = oldfl|O_NONBLOCK;//設置非阻塞模式
if(fcntl(fd,F_SETFL,newfl)==-1)
{
perror("fcntl error\n");
}
}
下面的代碼實現了一個非阻塞的ET模式,大家可以參考下面代碼來搞清楚上述函數setnoblock是怎麼使用的。
# include<stdio.h>
# include<unistd.h>
# include<stdlib.h>
# include<string.h>
# include<assert.h>
# include<poll.h>
# include<netinet/in.h>
# include<sys/socket.h>
# include<arpa/inet.h>
# include<sys/epoll.h>
# include<fcntl.h>
# include<signal.h>
# define MAXFD 10
# include <errno.h>
int pipefd[2];
int create_sockfd()
{
int sockfd = socket(AF_INET,SOCK_STREAM,0);
if(sockfd ==-1)
{
return -1;
}
struct sockaddr_in saddr;
memset(&saddr,0,sizeof(saddr));
saddr.sin_family = AF_INET;
saddr.sin_port = htons(6000);
saddr.sin_addr.s_addr=inet_addr("127.0.0.1");
int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
if(res ==-1)
{
return -1;
}
listen(sockfd,5);
return sockfd;
}
void setnonblock(int fd)
{
int oldfl = fcntl(fd,F_GETFL);//取得文件標誌位
int newfl = oldfl|O_NONBLOCK;//設置非阻塞模式
if(fcntl(fd,F_SETFL,newfl)==-1)
{
perror("fcntl error\n");
}
}
void epoll_add(int epfd,int fd)
{
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN|EPOLLRDHUP;//開啓et EPOLLET
if(epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev)==-1)
{
perror("epoll add error \n");
}
setnonblock(fd);
}
void epoll_del(int epfd,int fd)
{
if(epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL)==-1)
{
perror("epoll del error");
}
}
void fun(int sig)
{
write(pipefd[1],&sig,sizeof(sig));
}
int main()
{
int sockfd = create_sockfd();
assert(sockfd !=-1);
int epfd = epoll_create(MAXFD);//創建了一個內核事件表
assert(epfd!=-1);
epoll_add(epfd,sockfd);
pipe(pipefd);
epoll_add(epfd,pipefd[0]);
signal(SIGINT,fun);
struct epoll_event events[MAXFD];
int run =1;
while(run)
{
int n = epoll_wait(epfd,events,MAXFD,5000);
if(n==-1)
{
if(errno!=EINTR)
{
perror("epoll wait error\n");
}
}
if(n==0)
{
printf("time out\n");
}
else
{
int i = 0;
for(;i<n;++i)
{
int fd = events[i].data.fd;
if(events[i].events&EPOLLIN)
{
if(fd == sockfd)
{
struct sockaddr caddr;
int len = sizeof(caddr);
int c =accept(sockfd,(struct sockaddr*)&caddr,&len);
if(c<0)
{
continue;
}
printf("accept c = %d\n",c);
epoll_add(epfd,c);
}
else if(fd == pipefd[0])
{
int sig = 0;
read(pipefd[0],&sig,sizeof(sig));
printf("recv sig = %d",sig);
run = 0;
}
else
{
char buf[128]={0};
int res = recv(fd,buf,1,0);
if(res==0)
{
epoll_del(epfd,fd);
close(fd);
//epoll_del(epfd,fd);
printf("client %d is out\n",fd);
}
else if(res==-1)
{
send(fd,"ok",2,0);
break;
}
else
{
printf("recv(%d)=%s\n",fd,buf);
}
}
}
}
}
}
close(sockfd);
close(epfd);
printf("service over\n");
exit(0);
}
如上所示,ET模式除了在添加事件的時候要添加EPOLLRDHUP事件,開啓ET模式外,還要設置非阻塞的文件描述符。
5 select、poll和epoll的區別
經過上面的幾個示例,對三種模式有了很深的瞭解,三者的區別上述也斷斷續續有提到,下圖展示了三種模式的詳細區別。
I/O複用是網絡編程的一個重點內容,面試也會經常問道,希望我的博文能夠幫助大家進行理解。同時歡迎各位批評指正。