此次讨论的是阻塞的fd
先写一个简单的server端
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <sys/poll.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <time.h>
#define MAXLEN 4096
int main(){
int listenfd,connfd,n;
struct sockaddr_in servaddr;
char buf[MAXLEN];
if((listenfd = socket(AF_INET,SOCK_STREAM,0)) == -1)//创建fd
{
printf("create socket error:%s",strerror(errno));
return -1;
}
memset(&servaddr,0,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(8888);
if(bind(listenfd,(struct sockaddr*)&servaddr,sizeof(servaddr)) == -1)//绑定本机地址
{
printf("bind socket err:%s\n",strerror(errno));
return -1;
}
if(listen(listenfd,10) == -1)//监听
{
printf("listen socket err:%s\n",strerror(errno));
return -1;
}
struct sockaddr_in client;
socklen_t len = sizeof(client);
printf("----waiting for client requst----\n");
if ((connfd = accept(listenfd, (struct sockaddr *)&client, &len)) == -1) //等待客户端连接
{
printf("accept socket err:%s\n",strerror(errno));
return -1;
}
while(1)
{
n = recv(connfd,buf,MAXLEN,0);
if(n > 0)
{
printf("----waiting---\n");
buf[n] = '\0';
printf("recv msg form client:%s\n",buf);
send(connfd,buf,n,0);
}
else if(n == 0)
{
printf("----close--\n");
close(connfd);
}
}
close(listenfd);
return 0;
}
使用客户端去连接的时候发现,这样只能进行一对一的服务,当有第二个客户端连接时sock在队列里,没有accpet去取出。
针对这种情况,进行一下改进,每有一个客户端请求,创建一个线程来accpet,来进行服务
//C10K
void *client_pth(void* cfd)
{
char buf[MAXLEN];
int connfd = *(int*)cfd;
while(1)
{
int n = recv(connfd,buf,MAXLEN,0);
if(n > 0)
{
buf[n] = '\0';
printf("recv client connfd %d:%s\n",connfd,buf);
send(connfd,buf,n,0);
}
else if(n == 0)
{
printf("confd:%d\n",connfd);
close(connfd);
break;
}
}
return NULL;
}
printf("----waiting for client request----\n");
while(1)
{
printf("--creat--\n ");
struct sockaddr_in client;
socklen_t len = sizeof(client);
if((connfd = accept(listenfd,(struct sockaddr*)&client,&len)) == -1)
{
printf("accept socket err:%s\n",strerror(errno));
return -1;
}
printf("creat pthread\n");
pthread_t pid;
pthread_create(&pid,NULL,(void*)client_pth,(void*)&connfd);
}
但是问题来了,每来一个新的连接,就创建一个新的线程,当并发数较大时,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞), 进程/线程上下文切换消耗大,很有可能导致操作系统崩溃,这就是我们所说的C10K问题的本质,所以这种方式只适用于并发量较小的局域网或者内部使用。
select
由这可以引入一个新方法,那就是select。select就是把所有的fd全部一个函数中去管理,select会阻塞等待某一个fd或多个fd的写事件、读事件或者异常事件触发,从而实现相应事件的操作。
这里我们举个例子,如一栋公寓,里面有很多户人家,他们要收发快递,多线程的方式是这么做的,每户人家都雇一个人专门为他收发快递,如果住户很多,会造成很大的人员开销,于是这些住户商量着,把收发快递的活全部交给一个人统一去管理,这就是select的处理方式
以下只针对读事件和写事件做处理
printf("----waiting for client request----\n");
fd_set rfd,rset,wfd,wset;
FD_ZERO(&rfd);
FD_ZERO(&wfd);
FD_SET(listenfd,&rfd);
int max_fd = listenfd;
while(1)
{
printf("start \n");
wset = wfd;
rset = rfd;
int num = select(max_fd+1,&rset,&wset,NULL,NULL);//监听每个fd的写事件和读事件
printf("num:%d,max_fd:%d\n",num,max_fd);
if(FD_ISSET(listenfd,&rset))//当我们监听的fd有写事件过来时,代表有新的客户端请求
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
if((connfd = accept(listenfd,(struct sockaddr*)&client,&len)) == -1)
{
printf("accept socket err:%s\n",strerror(errno));
return -1;
}
FD_SET(connfd,&rfd);
if(connfd > max_fd) max_fd = connfd;
printf("max_fd:%d\n",max_fd);
if(--num == 0) continue;
}
for(int i = listenfd+1;i <= max_fd;i++)
{
printf("fd:%d\n",i);
if(FD_ISSET(i,&rset))
{
n = recv(i,buf,MAXLEN,0);
if(n > 0)
{
buf[n] = '\0';
printf("recv buf:%s\n",buf);
FD_SET(i,&wfd);
//send(i,buf,n,0);
}
else if(n == 0) //n=0代表连接断开
{
FD_CLR(i,&rfd); //读集合里除去这个fd
printf("close socket fd:%d\n",i);
close(i);
}
//if(--num == 0) break;
}
else if(FD_ISSET(i,&wset))
{
printf("send fd:%d\n",i);
//printf("send buf:%s\n",buf);
send(i,buf,n,0);
memset(buf,'\0',sizeof(buf));
FD_SET(i,&rfd);
FD_CLR(i,&wfd);//一定要清除写集合
}
}
}
这里有一个小坑,当写事件被触发后,如果不及时清除,会一直触发写事件,这是我不了解写事件的触发条件造成的,当套接口发送缓冲区有空间容纳数据(大多数时候缓冲区是不满的,会不断产生可写事件)所以每次写完之后要及时清除写集合。
但是,select的容纳个数也是有限的,在内核里FD_SET的最大数默认为1024,改的话需要重新编译内核,这不是一个好办法,或者是多创建几个select来扩容,这里先不谈。还有一种方法则是
poll
我们知道select是通过一个集合去管理的fd,而集合都是有固定大小的,但poll是通过链表的形式管理,所以没有最大fd的限制。
下面实现poll:
#define POLL_SIZE 1024
printf("----waiting for client request----\n");
struct pollfd fds[POLL_SIZE] = {0};
fds[0].fd = listenfd;
fds[0].events = POLLIN;
int max_fd = listenfd;
for(int i = 1; i < POLL_SIZE; i++)
{
fds[i].fd = -1;
}
while(1)
{
int num = poll(fds,max_fd+1,-1);
if(fds[0].revents & POLLIN)
{
struct sockaddr_in client;
socklen_t len;
if((connfd = accept(listenfd,(struct sockaddr*)&client,&len)) == -1)
{
printf("accept socket err:%s\n",strerror(errno));
return -1;
}
printf("accpet fd:%d\n",connfd);
fds[connfd].fd = connfd;
fds[connfd].events = POLLIN;
if(connfd > max_fd) max_fd = connfd;
if(--num == 0) continue;
}
for(int i = listenfd+1; i <= max_fd; i++)
{
printf("fd:%d\n",i);
if(fds[i].revents & POLLIN)
{
n = recv(i,buf,MAXLEN,0);
if(n > 0)
{
buf[n] = '\0';
printf("recv buf:%s\n",buf);
fds[i].events = POLLOUT;
}
else if(n == 0)
{
fds[i].fd = -1;
printf("close socket fd:%d\n",i);
close(i);
}
if(--num == 0) break;
}
else if(fds[i].revents & POLLOUT)
{
printf("send fd:%d\n",i);
send(i,buf,n,0);
//fds[i].events = 0;
fds[i].events = POLLIN;
if(--num == 0) break;
}
}
}
由代码可以看出select和poll的实现方式相差不大,都是通过阻塞等待某一个fd或多个fd的事件触发后,轮询查找相应的fd进行操作。由于每次事件触发,都会去遍历一遍fd,从而导致多次的无用遍历,造成额外的cpu开销。
接着上面收发快递的例子,select和poll的处理方式是由一个快递员统一管理收发快递,但没有告诉快递员某个快递应该送到哪一户,于是快递员只能一家一家的询问,很耗费快递员的精力,如果我们在收发快递的时候告诉快递员该往哪家送,那快递员的活就会轻松不少,这就是下面要实现的
epoll
epoll把select、poll的主动轮询为被动通知,当有事件发生时,被动接收通知。每个事件都有一个关联的fd,这样每次的事件操作都是有意义的
printf("----waiting for client request----\n");
int epfd = epoll_create(1);
struct epoll_event events[POLL_SIZE] = {0};
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ev);
while(1)
{
int num = epoll_wait(epfd,events,POLL_SIZE,-1);
if (num == -1) continue;
for(int i = 0; i < num; i++)
{
int clientfd = events[i].data.fd;
if(clientfd == listenfd)
{
struct sockaddr_in client;
socklen_t len;
if((connfd = accept(listenfd,(struct sockaddr*)&client,&len)) == -1)
{
printf("accept socket err:%s\n",strerror(errno));
return -1;
}
printf("accpet fd:%d\n",connfd);
ev.data.fd = connfd;
ev.events = EPOLLIN;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev);
}
else if(events[i].events & EPOLLIN)
{
n = recv(clientfd,buf,MAXLEN,0);
if(n > 0)
{
buf[n] = '\0';
printf("recv buf:%s\n",buf);
ev.data.fd = clientfd;
ev.events = EPOLLOUT;
epoll_ctl(epfd,EPOLL_CTL_MOD,clientfd,&ev);
}
else if(n == 0)
{
ev.data.fd = clientfd;
ev.events = EPOLLHUP;
epoll_ctl(epfd,EPOLL_CTL_DEL,clientfd,&ev);
close(clientfd);
}
}
else if(events[i].events & EPOLLOUT)
{
printf("send fd:%d\n",clientfd);
send(clientfd,buf,n,0);
ev.data.fd = clientfd;
ev.events = EPOLLIN;
epoll_ctl(epfd,EPOLL_CTL_MOD,clientfd,&ev);
}
}
}
epoll方法能有效的解决poll和select的一些缺陷,如fd数目限制,cpu开销过大的问题,在海量连接并发的时候起到很大作用。
总结
- select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
- select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
select、poll、epoll这三种都是io多路复用的用法,实际上在recv与send时还是堵塞的,本质上还是同步io,异步io的用法后续在更新
参考