网络io的多路复用:select/poll/epoll详解
一、前言
在上篇文章中我们用一请求一线程的方式实现了服务器与客户端的即时通信,但这种方式有资源消耗大、线程切换调度开销大、可扩展性受限,难以应对高并发、线程管理复杂等问题。在本篇文章中,我们将在一条线程下分别用select/poll/epoll三种方式实现与多个客户端的通信,即网络io的多路复用。
二、select/poll/epoll详解
-
select
select 是 Linux 系统中用于实现 I/O 多路复用的经典系统调用,它能够让程序同时监听多个文件描述符,一旦其中有一个或多个进入就绪状态(可读、可写或者发生异常),程序就能及时进行处理,代码如下:- 创建描述符集合
fd_set rfds, rset;//文件描述符集合 FD_ZERO (&rfds); FD_SET(sockfd, &rfds); int maxfd = sockfd;
fd_set是一个比特位集合,默认大小为1024bit,每一位都对应了一个文件描述符。当某一位变为1时,说明该fd有需要处理的事件,例如第五个比特位为1时,则说明clientfd = 5 的网络io有待处理的事件。创建好集合之后,把每一位先置零,再把服务器的监听套接字加入这个集合里,此时只有sockfd一个io,因此最大的描述符就是监听套接字的描述符。fd_set的定义方式如下:
#define FD_SETSIZE 1024 typedef struct { unsigned long fds_bits[FD_SETSIZE / (8 * sizeof(unsigned long))]; } fd_set;
- accept与recv
while(1){ rset = rfds; int nready = select(maxfd+1, &rset, NULL, NULL, NULL);//(集合大小,可读,可写,出错,时间间隔) //NULL:一直等直到io传来数据 time:设置时间,设置多少秒就等多少秒 //accept:接收一个新的客户端连接 if(FD_ISSET(sockfd, &rset)){ int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len); FD_SET(clientfd, &rfds); if(clientfd > maxfd) maxfd = clientfd; printf("client connect: %d\n", maxfd); } //recv:从客户端接受请求 int i = 0; for( i = sockfd+1; i <= maxfd; i ++) { if( FD_ISSET(i, &rset) ) { //接收数据 char buffer[1024] = {0}; int count = recv(i, buffer, 1024, 0); //断开连接 if(count == 0){//客户端断开时recv返回0 printf("client disconnect: %d\n", i); close(i); FD_CLR(i, &rfds); continue; } printf("recv: %s\n", buffer); //发送信息 count = send(i, buffer, count, 0); printf("send: %d\n", count); } } }
-
select是如何工作的?
工作流程:每次调用select都要把fdset从用户空间copy到内核,然后循环遍历到maxfd+1检测是否有事件发生(本代码中只关注是否有可读的事件),本代码设置为阻塞模式,所以在有事件发生之前线程会一直阻塞在此处循环对fd_set进行检测,返回值为有事件发生的io的数量。
-
为什么要设置两个fd_set集合?
每次执行select后,会对传入select的集合进行修改。因此另外设置一个 rfds 作为原始集合,保存所有需要监听的描述符。每次循环时,将 rfds 复制到 rset(即 rset = rfds),然后将 rset 传入 select。这样,即使 rset 被修改,rfds 仍保持不变,下次循环可继续复用。 -
代码如何区分发生事件的是监听套接字还是客户端?
在执行select后,首先通过FD_ISSET判断sockfd是否在集合里,如果在集合里则accept,并将这个clientfd添加到总集合里。由于fd是依此增加的,所以sockfd是第一个。然后对集合剩下的元素遍历判断是否在rset里,在集合里则进行recv。
- select的优点和缺点
- 优点:与一请求一线程相比只用一条线程就完成了与多个客户端的通信
- 缺点:参数太多;只要有请求就要把所有的fd遍历一遍太耗时。
-
poll
poll 是 Unix/Linux 系统中用于实现 I/O 多路复用的系统调用,它与 select 类似,但提供了更灵活的接口,代码如下:- 创建描述符集合
struct pollfd fds[1024] = {0}; fds[sockfd].fd = sockfd; fds[sockfd].events = POLLIN; int maxfd = sockfd;
- accept与recv
while (1) { int nready = poll(fds, maxfd+1, -1);//(集合, 规模, timeout) if(fds[sockfd].revents & POLLIN) {//若有数据可读 int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len); printf("clientfd connect: %d\n", clientfd); fds[clientfd].fd = clientfd; fds[clientfd].events = POLLIN; if(clientfd > maxfd) maxfd = clientfd; } //recv:从客户端接受请求 int i = 0; for( i = sockfd+1; i <= maxfd; i ++) { if( fds[i].revents & POLLIN ) { //接收数据 char buffer[1024] = {0}; int count = recv(i, buffer, 1024, 0); //断开连接 if(count == 0){//客户端断开时recv返回0 printf("client disconnect: %d\n", i); close(i); fds[i].fd = -1; fds[i].events = 0; continue; } printf("recv: %s\n", buffer); //发送信息 count = send(i, buffer, count, 0); printf("send: %d\n", count); } } }
- poll与select有什么相似之处?
大致工作流程一致。首先都要创建一个集合来存储文件描述符,然后当这个集合中的io传来请求时就遍历这个集合并处理有事件发生的io。 - poll和select有什么区别?
poll与select的主要区别在poll用了一个结构体数组pollfd
来存储文件描述符集合,而select使用的是比特位集合fd_set
,这个区别导致poll有如下优势:-
poll集合规模更灵活。
结构体数组大小可动态设定,没有固定的限制,而fd_set
大小默认为1024,更改起来比较复杂。 -
poll对集合的操作更加简单。
select借助三个集合(fd_set)来分别处理读、写和异常事件,而且每次执行select之后集合会被修改,还需另外设置一个集合来存储完整的文件描述符,这种方式参数过多且操作比较复杂。而poll采用的pollfd结构体包含fd(文件描述符)、events(待监听的事件)和revents(发生的事件),只需设置好events并将revents与events进行对比即可正常工作,并且每次使用poll都只需要对revents进行更新,这种设计避免了比特位集合操作的复杂性。
-
-
epoll
epoll是 Linux 内核为处理大批量文件描述符而作的改进,相较于早期的select和poll,epoll在处理大量并发连接时,效率有了质的提升。代码如下:- 创建描述符集合
int epfd = epoll_create(1);//只要不为0都一样 struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = sockfd; epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
- accept和recv
while(1) { struct epoll_event events[1024] = {0}; int nready = epoll_wait(epfd, events, 1024, -1); int i = 0; for(i = 0; i < nready;i ++) { int connfd = events[i].data.fd; if(connfd == sockfd){ int clientfd = accept(sockfd, (struct sockaddr*)&clientaddr, &len); printf("clientfd connect: %d\n", clientfd); ev.events = EPOLLIN; ev.data.fd = clientfd; epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev); }else if(events[i].events & EPOLLIN){ //接收数据 char buffer[1024] = {0}; int count = recv(connfd, buffer, 1024, 0); //断开连接 if(count == 0){//客户端断开时recv返回0 printf("client disconnect: %d\n", connfd); close(connfd); epoll_ctl(epfd, EPOLL_CTL_DEL, connfd, NULL); continue; } printf("recv: %s\n", buffer); //发送信息 count = send(connfd, buffer, count, 0); printf("send: %d\n", count); } } }
-
epoll是如何提升效率的?
当某个io发生事件时,select和poll都需要遍历所有的io端口来找出这个io并处理事件,而epoll会把有事件发生的io收集起来然后只需遍历这个已就绪的集合即可,大大减少了时间复杂度。
以快递员送快递的场景来打比方。当快递员收到有人要送快递的讯息时,select/poll采取的方式是快递员挨家挨户的去问是哪一家要送快递,而epoll只需要去丰巢取一下已就绪的快递,struct epoll_event events
就是收快递的盒子,然后逐个处理这个盒子中的事件即可。 -
这三种方法适用场景如何选择?
在 Linux 平台上,除非有特殊需求,应优先使用 epoll。除非对兼容性的要求导致epoll不可用,绝大部分场景都可以用epoll来解决问题。
文章参考<零声教育>的C/C++linux系统教程学习:https://github.com/0voice