select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
问题:如果我们先前创建的几个进程承载不了目前快速发展的业务的话,是不是还得增加进程数?我们都知道系统创建进程是需要消耗大量资源的,所以这样就会导致系统资源不足的情况。
那么有没有一种方式可以让一个进程同时为多个客户端端提供服务?
对于IO复用,我们可以通过一个例子来很好的理解它。
某教室有10名学生和1名老师,这些学生上课会不停的提问,所以一个老师处理不了这么多的问题。那么学校为每个学生都配一名老师,也就是这个教室目前有10名老师。此后,只要有新的转校生,那么就会为这个学生专门分配一个老师,因为转校生也喜欢提问题。如果把以上例子中的学生比作客户端,那么老师就是负责进行数据交换的服务端。则该例子可以比作是多进程的方式。
后来有一天,来了一位具有超能力的老师,这位老师回答问题非常迅速,并且可以应对所有的问题。而这位老师采用的方式是学生提问前必须先举手,确认举手学生后在回答问题。则现在的情况就是IO复用。
select:
accept创建了五个客户端连接,并把相应的文件描述符fd放到数组中
文件描述符是随机产生的,并不是连续的
求出最大的文件描述符
if(fd[i]>max){max=fd[i];
}
对需要关注的文件描述符初始化为0
FD_ZERO(&read_fds);
FD_SET设置哪个文件描述符被关注的置1
for(i=0;i<5;i++){FD_SET(fd[i],&read_fds);
}
过程:
select函数参数:
1、max+1,限定了循环遍历bitmap的范围,bitmap最多有1024位,限定了遍历的最大长度,可以减少无谓的扫描
2、读文件描述符的集合:读文件描述符实际上是bitmap,来表示具体哪个文件描述符被监听
3、写文件描述符的集合
4、异常文件描述符集合
5、超时时间,在指定的时间内如果还没有检测到某个文件描述符已就绪,这个时候也会阻塞,不再等待,立即返回。设置成NULL永不超时,一直阻塞下去,一直等到有数据到达,时间已就绪才返回。设置为0,表示不阻塞
ret = select(max + 1,&read_fds,NULL,NULL,NULL);
进程A调用select会将bitmap拷贝到内核态中,由内核判断哪一个socket文件描述符也就是fd,有对应的数据到达,需要关注的事件已就绪。内核的效率比用户态要高,用户态判断的时候需要询问内核哪个文件描述符已就绪,这样就会产生用户态和内核态的上下文切换,如果每一次对每一个fd都判断一次的话,那么就会存在多次的内核态和用户态的上下文切换,会非常浪费资源,因此select函数就选择将bitmap一次性的拷贝内核态,由内核去遍历哪个fd有数据到达。
哪个socket文件描述符对应的数据接收队列有数据到达,那么就标志这个文件描述符已就绪,这个时候select函数就会返回,返回的时候不仅仅是拷贝已就绪的fd,他是把所有的文件描述符信息全部拷贝到用户态,同时告知用户态现在有几个文件描述符已就绪,只是告诉个数,并没有告诉具体哪个就绪,所以需要for循环去判断具体哪个文件描述符就绪了
for(int i=0;i<5;i++){if(FD_ISSET(fd[i],&read_fds)){ret = recv(fd[i],buff,sizeof(buff);}
}
以下函数的头文件都是#include<sys/select.h>
fd_set set ; //创建监听集合
int sockfd ; //套接字文件描述符
int max_fd ; //套接字文件描述符表中的描述符个数
struct timeval *timeout ; //时间结构体,在这里表示工作模式——1.阻塞、2.非阻塞、3.定时阻塞(非阻塞与定时阻塞需要设置该结构体)
timeout中有两个成员,一个表示秒(timeout.tv_sec),一个表示微秒(timeout.tv_usec)
struct timeval
{
__time_t tv_sec; /* Seconds. */
__suseconds_t tv_usec; /* Microseconds. */
};
- timeout = NULL 就表示阻塞监听
- timeout.tv_sec = 0 、timeout.tv_sec = 0 就表示非阻塞监听
- timeout.tv_sec = 4、timeout.tv_usec = 30 就表示阻塞4秒30微秒,之后不阻塞
函数 | 功能 | 返回值 |
FD_ZERO(&set); | 初始化监听集合,将所有位的位码都初始化为0 | 无 |
FD_SET(sockfd , &set); | 将set集合中与sockfd对应位的位码设置为1 | 无 |
FD_CLR(sockfd , &set); | 将set集合中与sockfd对应位的位码设置为0 | 无 |
FD_ISSET(sockfd , &set); | 获取set集合中与sockfd对应位的位码 | 0或1 |
int select(max_fd, 是否监听读事件 , 是否监听写事件 , 是否监听错误事件 , timeout); | 监听我们要求的文件描述符的状态变化情况,并通过返回值告知(PS:想监听对应时间就传入&set,不想就传NULL) | 返回处于就绪状态的套接字数量 |
poll
poll相比于select的亮点是定义了pollfd结构体,events代表用户关注的事件,比如读事件,写事件,revents指返回的事件,由系统的内核填充并返回,如果当前的文件描述符,有状态的变化,比如说已就绪的话,revents就会做出相应的变化,poll的结构体数组模式就不会有1024个文件描述符的限制,相对select来说,能够承受更高的并发。
执行原理:首先也是accept创建4个文件描述符,建立了四个客户端连接,同时注册了需要关注的读事件
参数的含义
1、传入文件描述符的结构体数组
2、结构体数组的最大长度
3、阻塞等待时间
ret = poll(fds,4,4000);
跟select一样,一次性将一批文件描述符发送到内核态,在内核中文件描述符遍历,看哪个文件描述符的数据接收队列有数据,就将revents置1,poll函数返回,将文件描述符的结构体数组拷贝回用户态,遍历具体哪个已就绪,与select不同的是,判断完成之后,直接将revents恢复成0.
for(int i=0;i<4;i++){if(fds[i].revents & POLLIN){fd[i].revents = 0;ret = recv(fds[i].fd,buff,sizeof(buff)-1,0);
}
epoll
首先定义了epoll_event的结构体,events表示关注的事件(读写事件),epoll_data是一个自定义的联合体,fd保存需要监听的文件描述符信息
int epoll_fd = epoll_reate
进程调用epoll_create方法时,内核会创建一个eventpoll结构体:
rdyList(就绪队列)已就绪的文件描述双链表,当某个读事件或者写事件就绪的时候,就会把相应的文件描述符信息放到已就绪的双链表中
rbr是一颗红黑树,用红黑树去管理用户进程放进来的所有socket连接
wq是一个等待队列,当他需要关注的某个事件未就绪的时候,就会把当前进程的描述符以及回调函数放到进程等待队列里,当软中断数据到达的时候就会通过检查阻塞队列,找到相应的阻塞进程去唤醒他执行后续的动作
epoll_ctl函数用于增加,删除,修改epoll事件,epoll事件会存储于内核epoll结构体红黑树中。每当我们创建一个客户端和服务端的socket连接的时候,就会把链接添加到一个红黑树里面。
#include <sys/epoll.h>int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);参数:
epfd:epoll文件描述符
op:操作码
EPOLL_CTL_ADD:插入事件
EPOLL_CTL_DEL:删除事件
EPOLL_CTL_MOD:修改事件
fd:事件绑定的套接字文件描述符
events:事件结构体返回值:
成功:返回0
失败:返回-1
epoll_wait检查双链表中是否有就绪的事件,如果有就绪的事件的话,wait函数会立即返回不阻塞,但是这个里面为空的时候,它就会进行阻塞,等待有就绪事件到达。
#include <sys/epoll.h>int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);参数:
epfd:epoll文件描述符
events:epoll事件数组
maxevents:epoll事件数组长度
timeout:超时时间
小于0:一直等待
等于0:立即返回
大于0:等待超时时间返回,单位毫秒返回值:
小于0:出错
等于0:超时
大于0:返回就绪事件个数
区别
select,poll,epoll都是I/O多路复用机制,即能监视多个fd,一旦某fd就绪(读或写就绪),能够通知程序进行相应读写操作。 但select,poll,epoll本质都是同步I/O,因为他们都需在读写事件就绪后,自己负责进行读写,即该读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O实现会负责把数据从内核拷贝到用户空间。
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都是用于实现 I/O 多路复用的方式,可以在同一时间内监听多个文件描述符的就绪状态。
- select 是一种比较老的方式,它使用位图来表示文件描述符的状态。调用 select 时,内核需要遍历整个位图,检查每个文件描述符是否就绪。这种轮询的方式在连接数量很少时还是很有效的,但当连接数量增多时,性能会下降。由于使用位图来保存描述符,所以 select 还有描述符个数的限制,一般只能支持 2048 个,不过 select 的跨平台性比较好,几乎所有的平台都可以支持。
- poll 使用链表结构来表示文件描述符的状态,没有最大连接数的限制。和 select 函数一样,poll 返回后,需要轮询来获取就绪的描述符,因此随着监视的描述符数量的增长,其效率也会线性下降。
- epoll 是 Linux 特有的一种方式,它使用了事件驱动的模型,没有最大连接数的限制。它将文件描述符添加到 epoll 的事件集合中,等待事件的发生。与 select 和 poll 不同的是,epoll 不需要轮询,它使用回调的方式,只关注真正发生事件的文件描述符。这使得epoll在大规模高并发连接下具有卓越的性能。
应用场景上,select和poll适用于连接数量较少的场景,而epoll则适用于需要处理大规模并发连接且性能要求较高的场景。
参考:腾讯面试:请描述 select、poll、epoll 这三种IO多路复用技术的执行原理_哔哩哔哩_bilibili