一切都從最基本開始。
網絡編程中客戶端連服務端的經典代碼:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in svraddr;
svraddr.sin_family = AF_INET;
svraddr.sin_port = htons(iPort);
svraddr.sin_addr.s_addr = inet_addr(Ip);
connect(sockfd, (struct sockaddr*)&svraddr, sizeof(svraddr);
char req[1024]="test";
sendto(acceptfd, req, strlen(req)+1, 0, (struct sockaddr*)&svraddr, sizeof(svraddr));
網絡編程中服務端接收客戶端的經典代碼:
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in svraddr;
svraddr.sin_family = AF_INET;
svraddr.sin_port = htons(iPort);
svraddr.sin_addr.s_addr = INADDR_ANY;
bind(listenfd, (struct sockaddr*)&svraddr, sizeof(svraddr)
listen(listenfd, SOMAXCONN);
struct sockaddr_in cliaddr;
socklen_t clilen = 0;
while(1)
{
int acceptfd = accept(listenfd, (struct sockaddr*)&cliaddr, & clilen);
char rec[1024];
memset(sRec, 0, 1024);
int byte_read = recv(acceptfd, rec, 1024, 0);
if (byte_read > 0)
{
sendto(acceptfd, rec, byte_read, 0, (struct sockaddr*)&cliaddr, sizeof(cliaddr));
}
close(acceptfd);
}
在單進程單請求的處理中,這幾個函數完美的完成了一次客戶端請求服務端發包的過程。Server端就是一個處理機,當來一個請求時,server捕獲到這個請求,然後處理。
當服務端accept時,會阻塞當前進程,直到客戶端發送請求過來。
由於服務端是處理單請求,當多個請求同時到來時,後面的請求會被阻塞住。比如:
服務端S先執行,阻塞於accept。
客戶端c1連接S,但先不發送數據,則S會阻塞在recv這個io上面
此時,客戶端c2連接S,即使發送數據過去,S仍然無法響應
當c1發送數據給S後,S處理完畢,會accept到c2,處理c2的請求
這種模式的缺點太明顯了,無法做到處理多個fd。那麼有沒有這麼一種設計,當io請求到來的時候,告訴服務端有請求去處理呢?
這就是i/o多路複用了
Select下的多路複用,仍然會阻塞,但是不會像Accept那樣阻塞在i/o上面,而是會阻塞到select這個系統調用上面。
如此這般,當有請求(新客戶端連接請求,已經連接的套接口發送數據請求等)到來時,select會捕捉到這些請求。
Select的基本流程是這樣的:
阻塞於包含一堆fd的集合之上,這些fds可能是套接口,文件等
Fd中有可寫,可讀,或異常,或超時,select返回。
進入邏輯處理階段
Select繼續阻塞,循環處理。
經過改造之後的server端代碼
char line[MAXLINE];
socklen_t clilen;
//聲明epoll_event結構體的變量,ev用於註冊事件,數組用於回傳要處理的事件
struct epoll_event ev,events[20];
//生成用於處理accept的epoll專用的文件描述符
epfd=epoll_create(256);
struct sockaddr_in clientaddr;
struct sockaddr_in serveraddr;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
//把socket設置爲非阻塞方式
//setnonblocking(listenfd);
//設置與要處理的事件相關的文件描述符
ev.data.fd=listenfd;
//設置要處理的事件類型
ev.events=EPOLLIN|EPOLLET;
//ev.events=EPOLLIN;
//註冊epoll事件
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
bzero(&serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = inet_addr(Ip);
serveraddr.sin_port=htons(SERV_PORT);
bind(listenfd,(sockaddr *)&serveraddr, sizeof(serveraddr));
listen(listenfd, LISTENQ);
maxi = 0;
for ( ; ; ) {
//等待epoll事件的發生
nfds=epoll_wait(epfd,events,20,500);
//處理所發生的所有事件
for(i=0;i<nfds;++i)
{
if(events[i].data.fd==listenfd)
{
connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen);
//setnonblocking(connfd);
//設置用於讀操作的文件描述符
ev.data.fd=connfd;
//設置用於注測的讀操作事件
ev.events=EPOLLIN|EPOLLET;
//ev.events=EPOLLIN;
//註冊ev
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
}
else if(events[i].events&EPOLLIN)
{
if ( (sockfd = events[i].data.fd) < 0)
continue;
if ( (n = read(sockfd, line, MAXLINE)) < 0) {
if (errno == ECONNRESET) {
close(sockfd);
events[i].data.fd = -1;
}
} else if (n == 0) {
close(sockfd);
events[i].data.fd = -1;
}
line[n] = '/0';
cout << "read " << line << endl;
//設置用於寫操作的文件描述符
ev.data.fd=sockfd;
//設置用於注測的寫操作事件
ev.events=EPOLLOUT|EPOLLET;
//修改sockfd上要處理的事件爲EPOLLOUT
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
else if(events[i].events&EPOLLOUT)
{
sockfd = events[i].data.fd;
write(sockfd, line, n);
//設置用於讀操作的文件描述符
ev.data.fd=sockfd;
//設置用於注測的讀操作事件
ev.events=EPOLLIN|EPOLLET;
//修改sockfd上要處理的事件爲EPOLIN
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);
}
}
}
經過select改造後的server可以同時接受10個請求而不會阻塞,效率提升了很多。
Select返回可能會小於0,這就是異常,其中包括被信號打斷,此時error= EINTR,我們需要特殊處理這種情況。
Select超時情況下會返回爲0.
正常情況下select會返回準備就緒的fd。如果是listen的套接字返回,則程序需要去accept請求,如果是accept的套接字返回,則程序需要去讀或者寫該套接字。
Select有個缺點,就是他僅能知道集合中有某個fd觸發,卻無法知道是哪一個,只能挨個去找。
這樣就誕生了epoll。Epoll會記錄下某個fd的信息,這樣當fd觸發的時候,epoll機制可以很快的找到相應的fd,進行處理。
Epoll技術是在linux2.6新加的,在頭文件sys/epoll.h裏面定義了其中的全部數據結構和操作。
主要使用三個接口:
int epoll_create(int size);
創建一個epoll句柄
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
將某個fd操作(op)到該句柄上
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
等待該epoll句柄。
Epoll設計下server代碼就變成了下面這個樣子了。
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
int epollfd = epoll_create(20);
struct epoll_event ev, events[20];
//setnonblocking(listenfd);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = listenfd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, listenfd, &ev);
struct sockaddr_in svraddr;
svraddr.sin_family = AF_INET;
svraddr.sin_port = htons(atoi(argv[1]));
svraddr.sin_addr.s_addr = INADDR_ANY;
bind(listenfd, (struct sockaddr *)&svraddr, sizeof(svraddr));
listen(listenfd, SOMAXCONN);
int clientfd[10];
memset(clientfd, -1, sizeof(clientfd));
struct sockaddr_in cliaddr;
socklen_t clilen;
char recv[1024];
while(1)
{
int nfds = epoll_wait(epollfd, events, 20, 500);
for(int n = 0; n < nfds; ++n)
{
if(events[n].data.fd == listenfd)
{
int iclifd = accept(listenfd, (struct sockaddr*)&cliaddr, &clilen);
for(int i=0; i<10; i++)
{
if (clientfd[i] == -1)
{
clientfd[i] = iclifd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, iclifd, &ev);
break;
}
}
}
for(int i=0; i<=10; i++)
{
if(clientfd[i]>=0 && events[n].data.fd==clientfd[i])
{
int n = read(clientfd[i], recv, 1024);
if (n<=0)
{
close(clientfd[i]);
epoll_ctl(epollfd, EPOLL_CTL_DEL, iclifd, &ev);
clientfd[i] = -1;
}
else
{
write(clientfd[i], recv, n);
}
}
}
}
}
Epoll的重要數據結構:
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
結構體epoll_event 被用於註冊所感興趣的事件和回傳所發生待處理的事件,其中epoll_data 聯合體用來保存觸發事件的某個文件描述符相關的數據。
例如一個client連接到服務器,服務器通過調用accept函數可以得到於這個client對應的socket文件描述符,可以把這文件描述符賦給epoll_data的fd字段以便後面的讀寫操作在這個文件描述符上進行。這個可以通過epoll_ctrl的EPOLL_CTL_ADD 選項來實現,然後wait此epoll句柄,當觸發特定的事件時進行特殊的處理。
epoll_event 結構體的events字段是表示感興趣的事件和被觸發的事件。可能的取值爲:
EPOLLIN :表示對應的文件描述符可以讀;
EPOLLOUT:表示對應的文件描述符可以寫;
EPOLLPRI:表示對應的文件描述符有緊急的數據可讀
EPOLLERR:表示對應的文件描述符發生錯誤;
EPOLLHUP:表示對應的文件描述符被掛斷;
EPOLLET:表示對應的文件描述符有事件發生;
ET和LT模式的區別:
LT(水平觸發)模式是缺省的工作模式,同時支持block和noblock方式,這種模式下面編程會容易一些,因爲只要沒有處理完畢,內核不不斷的通知。LT模式下的epoll和poll/select差不多,只是理論速度會快一點而已。
ET(邊沿觸發)模式是epoll的高速模式,只支持noblock方式。這種情況下,描述符由未就緒到就緒的變化內核會通知,直到程序做了某些操作使得該描述符不再爲就緒狀態。
比如從一個描述符讀取數據,一次沒有讀完,LT模式下面內核會通知繼續去讀,但在ET模式下需要程序自己去控制,直到讀完位置。一次讀取的內容如果與自己想讀取的數量一致,這個時候緩衝區很有可能有未讀的數據存在。
同樣,往fd寫數據的時候,可能發送端的速度高於接收端的速度,這樣就很容易把緩衝區填滿(返回EAGAIN錯誤),這個時候就需要等一下子,慢慢再傳纔可以了。
ET模式下面客戶端關閉某個fd的時候,fd狀態改變,內核會告訴服務端該fd有數據可讀,但這個時候的讀顯然已經違背了服務端的本意,時間上也差遠了。
多路複用的目的,就是爲了提高服務端處理高併發請求的能力,採用懶人辦法,就是多進程處理,父進程負責接收信號,然後fork子進程處理邏輯,這樣在邏輯梳理上就清晰多了。
每種方法都有自己的利弊,就看我們更看重哪一點了。