I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。select,poll,epoll都是IO多路复用的机制。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。下来,分别谈谈。
select
select 的核心功能是调用tcp文件系统的poll函数,不停的查询,如果没有想要的数据,主动执行一次调度(防止一直占用cpu),直到有一个连接有想要的消息为止。从这里可以看出select的执行方式基本就是不停的调用poll,直到有需要的消息为止。
缺点:
1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大;
2、同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大;
3、select支持的文件描述符数量太小了,默认是1024。
线程函数
void *client_thread(void *arg) {
int clientfd = *(int*)arg;
while (1) { //slave
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(clientfd, buffer, BUFFER_LENGTH, 0);
if (ret == 0) {
close(clientfd);
break;
}
printf("ret: %d, buffer: %s\n", ret, buffer);
send(clientfd, buffer, ret, 0);
}
}
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0); //创建一个io
//printf("sockfd: %d\n", sockfd);
//domain:地址族规范。
//int type:新套接字的类型规范。
//int protocol:要使用的协议。
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(struct sockaddr_in)); //192.168.10.8
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); //0.0.0.0
//htonl:将主机的无符号长整形数转换成网络字节顺序。
//本函数s将一个32位数从主机字节顺序转换成网络字节顺序
//htonl()返回一个网络字节顺序的值。
serveraddr.sin_port = htons(9999);
if (-1 == bind(sockfd,(struct sockaddr*)&serveraddr,sizeof(struct sockaddr))) {
printf("bind fail: %s", strerror(errno));
return -1;
}
/* bind:(1)参数 sockfd ,需要绑定的socket。
(2)参数 addr ,存放了服务端用于通信的地址和端口。
(3)参数 addrlen ,表示 addr 结构体的大小
(4)返回值:成功则返回0 ,失败返回-1,错误原因存于 errno 中。如果绑定的地址错误,或者端口已被占用,bind 函数一定会报错,否则一般不会返回错误。
*/
listen(sockfd, 10);
//n:内核应该为相应套接字排队的最大连接个数
/*backlog(10)参数定义sockfd的挂起连接队列可能增长到的最大长度。如果有连接
* 当队列已满时,请求到达,客户机可能会收到一个错误,指示ECONNREFUSED或(如果底层的)
* 协议支持重传,请求可以被忽略,以便稍后重新尝试连接成功*/
// sleep(10);
#if 0
printf("sleep\n");
int flags = fcntl(sockfd, F_GETFL, 0);
//fcntl系统调用可以用来对已打开的文件描述符进行各种控制操作以改变已打开文件的的各种属性
//F_GETFL 获取文件状态标志
flags |= O_NONBLOCK;
//非阻塞调用
fcntl(sockfd, F_SETFL, flags);
//F_SETFL设置文件锁
#endif
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
#if 1 //一请求一线程
while (1) {
//while循环测试阻塞IO
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);// 会创建一个fd,与客户端形成一对一的关系
printf("acccept\n");
#if 0
// while (1) {
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(clientfd, buffer, BUFFER_LENGTH, 0);//没数据的时候也会阻塞
//第一个参数指定接收端套接字描述符;
//第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据;
//第三个参数指明buf的长度;
//第四个参数一般置0。
printf("ret: %d, buffer: %s\n", ret, buffer);
send(clientfd, buffer, ret,0);
#else
pthread_t threadid;
pthread_create(&threadid, NULL, client_thread, &clientfd);
#endif
}
#endif
优点:
1、select的可移植性更好,在某些Unix系统上不支持poll()。
2、select对于超时值提供了更好的精度:微秒,而poll是毫秒。
select实现
#if 1
fd_set rfds, rset; //第一个参数是固定用法,集合中哪个文件描述符的值大就填其+1的值进去,这表示将所有描述符监听起来
//第二个参数是填入读监听的监听集合地址
//第三个参数是填入写监听的监听集合地址,没有就填NULL
//第四个参数是填入异常监听的监听集合地址,没有就填NULL
//第五个参数是填超时时间,填NULL表示永久等待,永不超时
FD_ZERO(&rfds);//把当前fd_set(rfds)所有位的数字都置为0
/*
为什么需要清空监听集合重新再监听
这是由于select函数的设计导致的,在select函数的参数列表中明显其参数并没有用const修饰起来
这意味着我们传入的参数和最后函数执行完所返回的参数结果有可能并不一致,所以我们才需要重新设置监听集合
*/
FD_SET(sockfd, &rfds);//句柄加入到fd_set中
int maxfd = sockfd;
int clientfd = 0;
while (1) {
rset = rfds;//一个是应用层集合(rfds)用来交给交给内核(rset)操作的集合
int nready = select(maxfd+1, &rset, NULL, NULL, NULL);
/* nfds 需要检查的文件描述字个数
readset 用来检查可读性的一组文件描述字。
writeset 用来检查可写性的一组文件描述字。
exceptset 用来检查是否有异常条件出现的文件描述字。(注:错误不包括在异常条件之内)
timeout 超时,填NULL为阻塞,填0为非阻塞,其他为一段超时时间
在所有POSIX 兼容的平台上,select 函数使我们可以执行I/O多路转接。传给 select 的参数告诉内核∶
1.我们所关心的描述符;
2.对于每个描述符我们所关心的条件(是否想从一个给定的描述符读,是否想写一个给定的描述符,是否关心一个给定描述符的异常条件);
3.愿意等待多长时间(可以永远等待、等待一个固定的时间或者根本不等待)。
select 返回时,内核告诉我们∶
1.已准备好的描述符的总数量;
2.对于读、写或异常这3个条件中的每一个,哪些描述符已准备好。
3.使用这种返回信息,就可调用相应的 I/O函数(一般是 read 或 write),并且确知该函数不会阻塞。
*/ if (FD_ISSET(sockfd, &rset)) {
//如果绑定的联系在则返回1,反之,则返回0。
clientfd = accept(sockfd, (struct sockaddr*)&clientfd, &len);
printf("accept: %d\n",clientfd);
FD_SET(clientfd, &rfds);
if (clientfd > maxfd) maxfd = clientfd;
if (-- nready == 0) continue;
}
int i = 0;
for (i = sockfd+1; i <= maxfd; i ++) {
if (FD_ISSET(i, &rset)) {
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(clientfd, buffer, BUFFER_LENGTH, 0);
//recv函数仅仅是copy数据,真正的接收数据是协议来完成的), recv函数返回值实际copy的字节数。
if (ret == 0) {
close(clientfd);
break;
}
printf("ret: %d, buffer: %s\n", ret, buffer);
send(clientfd, buffer, ret, 0);
}
}
}
poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
缺点:
1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义;
2、与select一样,poll返回后,需要轮询pollfd来获取就绪的描述符。
优点:
1、poll() 不要求开发者计算最大文件描述符加一的大小。
2、poll() 在应付大数目的文件描述符的时候速度更快,相比于select。
3、它没有最大连接数的限制,原因是它是基于链表来存储的。
poll实现
struct pollfd fds[POLL_SIZE] = {0}; //定义一个结构体数组
fds[sockfd].fd = sockfd;
fds[sockfd].events = POLLIN; //event 关注的IO是否可读
/*
struct pollfd{
int fd; //文件描述符,如建立socket后获取的fd, 此处表示想查询的文件描述符
short events; //等待的事件,就是要监测的感兴趣的事情
short revents; //实际发生了的事情
};
*/
int maxfd = sockfd;
int clientfd = 0;
while (1) {
int nready = poll(fds, maxfd+1, -1);//poll()函数的作用是把当前的文件指针挂到等待队列中,监视并等待多个文件描述符的属性变化
/*
函数原型:int poll(struct pollfd *fds, unsigned int nfds, int timeout)
pollfd *fds : 指向pollfd结构体数组,用于存放需要检测器状态的Socket 描述符或其它文件描述符。
unsigned int nfds: 指定pollfd 结构体数组的个数,即监控几个pollfd.
timeout: 指poll() 函数调用阻塞的时间,单位是ms.如果timeout=0则不阻塞,
如timeout=INFTIM 表 示一直阻塞直到感兴趣的事情发生。
返回值:>0 表示数组fds 中准备好读,写或出错状态的那些socket描述符的总数量
==0 表示数组fds 中都没有准备好读写或出错,当poll 阻塞超时timeout 就会返回。
-1 表示poll() 函数调用失败,同时回自动设置全局变量errno.
*/
if (fds[sockfd].revents & POLLIN) { //revents内核传出来的 判断可读返回出来的
clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
printf("accept: %d\n", clientfd);
fds[clientfd].fd = clientfd;
fds[clientfd].events = POLLIN;
if (clientfd > maxfd) maxfd = clientfd;
if (-- nready == 0) continue;
}
int i =0;
for (i = 0; i <= maxfd; i ++) {
if (fds[i].revents & POLLIN) {
char buffer[BUFFER_LENGTH] = {0};
int ret = recv(clientfd, buffer, BUFFER_LENGTH, 0);
if (ret == 0) {
fds[clientfd].fd = -1; //避免一直关注可读事件
fds[clientfd].events = 0;
close(clientfd);
break;
}
printf("ret: %d, buffer: %s\n", ret, buffer);
send(clientfd, buffer, ret, 0);
}
}
}
epoll
原理概述:
epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时, 返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一 个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射技术,这 样便彻底省掉了这些文件描述符在系统调用时复制的开销。
epoll的优点就是改进了前面所说缺点:
1、支持一个进程打开大数目的socket描述符:相比select,epoll则没有对FD的限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
2、IO效率不随FD数目增加而线性下降:epoll不存在这个问题,它只会对"活跃"的socket进行操作— 这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的—比如一个高速LAN环境,epoll并不比select/poll有什么效率,相 反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
3、使用mmap加速内核与用户空间的消息传递:这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就 很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。
epoll实现
// networkio, select/poll
int epfd = epoll_create(1);
printf("epfd: %d\n", epfd);
//1000 list size
/*
参数只是告诉内核这个 epoll对象会处理的事件大致数目,而不是能够处理的事件的最大个数。
在 Linux最新的一些内核版本的实现中,这个 size参数没有任何意义。
当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,
是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。
*/
struct epoll_event ev;//ev用于注册事件
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
/* epoll的事件注册函数,epoll_ctl向 epoll对象中添加、修改或者删除感兴趣的事件(将监听的文件描述符添加到epoll实例),返回0表示成功,否则返回–1,
此时需要根据errno错误码判断错误类型。它与select()不同是在监听事件时告诉内核要监听什么类型的事件,
而是在这里先注册要监听的事件类型。
**epoll_wait方法返回的事件必然是通过 epoll_ctl添加到 epoll中的。
第一个参数是epoll_create()的返回值;
第二个参数表示动作,用三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的fd;
第四个参数是告诉内核需要监听什么事;
*/
struct epoll_event events[1024] = {0};//数组用于回传要处理的事件
while (1) { //main
int nready = epoll_wait(epfd, events, 1024, -1);
/*
等待事件的产生,类似于select()调用。参数events用来从内核得到事件的集合,
maxevents告之内核这个events有多大,这个 maxevents的值不能大于创建epoll_create()时的size,
参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。
该函数返回需要处理的事件数目,如返回0表示已超时。如果返回–1,则表示出现错误,需要检查 errno错误码判断错误类型。
第1个参数 epfd是 epoll的描述符。
第2个参数 events则是分配好的 epoll_event结构体数组,epoll将会把发生的事件复制到 events数组中
(events不可以是空指针,内核只负责把数据复制到这个 events数组中,不会去帮助我们在用户态中分配内存。
内核这种做法效率很高)。
第3个参数 maxevents表示本次可以返回的最大事件数目,通常 maxevents参数与预分配的events数组的大小是相等的。
第4个参数 timeout表示在没有检测到事件发生时最多等待的时间(单位为毫秒),
如果 timeout为0,则表示 epoll_wait在 rdllist链表中为空,立刻返回,不会等待。
*/
if (nready < 0) continue;
int i = 0;
for (i = 0; i < nready; i ++) {
//printf("nready: %d\n", nready);
int connfd = events->data.fd;//如果新监测到一个SOCKET用户连接到了绑定的SOCKET端口,建立新的连接。
//printf("events->data.fd: %d\n",events->data.fd);
if (sockfd == connfd) {
int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len);
if (clientfd < 0) {
continue;
}
printf("clientfd: %d\n", clientfd);
ev.events = EPOLLIN | EPOLLET; //多重事件?
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
}else if (events[i].events & EPOLLIN) {//如果是已经连接的用户,并且收到数据,那么进行读入。
char buffer[BUFFER_LENGTH] = {0};
// short len = 0;
// recv(connfd, &len, 2, 0); //读包头数据大小2字节
// len = ntohs(len);
int n = recv(connfd, buffer, BUFFER_LENGTH, 0);
if (n > 0) {
printf("recv : %s\n", buffer);
send(connfd, buffer, n, 0);
} else if (n == 0) {
/*
1.对于IO未读完的数据不好存储,假设客户端有2k的数据,io只有1k的空间,io读完了,但是数据包没有发送发,
就需要存储临时数据
2.就绪的io占比比较少,所以需要处理对应事件
所以才有了封装reactor
*/
printf("close\n");
epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL);
close(connfd);
}
}
}
}
}
总结:
1、select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
2、select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。