I/O多路复用之select()系统调用
为什么要使用I/O多路复用
应用程序常常需要在多于一个文件描述符上阻塞:例如响应键盘输入(stdin)、进程间通信以及同时操作多个文件。
在不使用线程(怎么理解线程的存在呢?我么可以举一个例子。当我们运行qq这个进程的时候,是可以执行不同的任务的。例如,我们可以在使用qq发送消息的同时来收发文件。而这两个不同的任务就是利用线程来完成的),尤其是独立处理每一个文件的情况下,进程无法在多个文件描述符上同时阻塞。如果文件都处于准备好被读写的状态,同时操作多个文件描述符是没有问题的。但是,一旦在该过程中出现一个未准备好的文件描述符(就是说,如果一个read()
被调用,但没有读入数据),则这个进程将会阻塞,不能再操作其他文件。可能阻塞只有几秒钟,但是应用无响应也会造成不好的用户体验。然而,如果文件描述符始终没有任何可用数据,就可能一直阻塞下去。
如果使用非阻塞I/O,应用可以发起I/O请求并返回一个特别的错误,从而避免阻塞。但是,从两个方面来讲,这种方法效率较差。首先,进程需要以某种不确定的方式不断发起I/O操作,直到某个打开的文件描述符准备好进行I/O。其次,如果程序可以睡眠的话将更加有效,可以让处理器进行其他工作,直到一个或更多文件描述符可以进行I/O时再唤醒。
三种I/O多路复用方案
I/O多路复用允许应用在多个文件描述符上同时阻塞,并在其中某个可以读写时收到通知。这时I/O多路复用就成了应用的关键所在。
I/O多路复用的设计遵循一下原则:
1、I/O多路复用:当任何文件描述符准备好I/O时告诉我
2、在一个或更多文件描述符就绪前始终处于睡眠状态
3、唤醒:哪个准备好了?
4、在不阻塞的情况下处理所有I/O就绪的文件描述符
5、返回第一步,重新开始
Linux提供了三种I/O多路复用方案:select
、poll
、epoll
。
先来说说select()
select()系统调用的声明
|
|
其中fd_set
是select
机制中提供的一种数据结构,实际上是一long
类型的数组,每一个数组元素都能与一打开的文件句柄(不仅是socket
句柄,还是其他文件或命名管道或设备句柄)建立联系,建立联系的工作由程序员完成,当调用select()
时,由内核根据IO状态修改fd_set
的内容,由此来通知执行了select()
的进程哪一个socket
或文件发生了可读或可写事件
监测的文件描述符可以分为三类,分别等待不同的事件。监测readfds
集合中的文件描述符,确认其中是否有可读数据(也就是说,确认好了的文件描述符的读操作可以无阻塞的完成)。监测writefds
集合中的文件描述符,确认其中是否有一个写操作可以不阻塞地完成。监测exceptfds
中的文件描述符,确认其中是否有出现异常发生或者出现带外数据(这种情况只适用于套接字)。指定的集合可能为空(NULL)。相应的,select()
则不对此类事件进行监测。
成功返回时,每个集合只包含对应类型的I/O就绪的文件描述符。举个例子,readfds
集合中有两个文件描述符:7和9.当调用返回时,如果7还在集合中,该文件描述符就准备好进行无阻塞I/O了。如果9已不在集合中,它可能在被读取时会发生阻塞。出现错误返回-1。
第一个参数n,等于所有集合中文件描述符的最大值加1。这样,select()
的调用者需要找到最大的文件描述符值,并将其加1后传给第一个参数。
timeout
参数是一个指向timeval
结构体的指针,定义如下:
|
|
如果这个参数不是NULL,即使此时没有文件描述符处于I/O就绪状态,select()
调用也将在tv_sec秒、tv_usec微秒
后返回。即 select在timeout时间内阻塞,超时时间之内有事件到来就返回了,否则在超时后不管怎样一定返回。
如果时限中的两个值都是0,调用会立即返回,并报告调用时所有事件对应的文件描述符均不可用,且不等待任何后续事件。
若将NULL以形参传入,即不传入时间结构,就是将select置于阻塞状态,一定等到监视文件描述符集合中某个文件描述符发生变化为止
举个select()的小例子
|
|
让我们执行这段代码之后,等待5秒:
可以看到,在这5秒内,进程是处于阻塞状态的(因为文件描述符的状态没有发生变化)
现在让我们执行完这段代码后输入一些内容:
上面这个例子虽然只是检测了一个文件描述符(因此不是多路复用),但是对于select()
这个系统调用的用法已经很清晰了。
select函数及fd_set介绍
1. select函数
1. 用途
在编程的过程中,经常会遇到许多阻塞的函数,好像read和网络编程时使用的recv, recvfrom函数都是阻塞的函数,当函数不能成功执行的时候,程序就会一直阻塞在这里,无法执行下面的代码。这时就需要用到非阻塞的编程方式,使用select函数就可以实现非阻塞编程。
select函数是一个轮循函数,循环询问文件节点,可设置超时时间,超时时间到了就跳过代码继续往下执行。
2. 大致原理
select需要驱动程序的支持,驱动程序实现fops内的poll函数。select通过每个设备文件对应的poll函数提供的信息判断当前是否有资源可用(如可读或写),如果有的话则返回可用资源的文件描述符个数,没有的话则睡眠,等待有资源变为可用时再被唤醒继续执行。详细的原理请看这里
3. 函数定义
该函数声明如下
1 |
|
参数:
nfds 需要检查的文件描述字个数
readset 用来检查可读性的一组文件描述字。
writeset 用来检查可写性的一组文件描述字。
exceptset 用来检查是否有异常条件出现的文件描述字。(注:错误不包括在异常条件之内)
timeout 超时,填NULL为阻塞,填0为非阻塞,其他为一段超时时间
返回值:
返回fd的总数,错误时返回SOCKET_ERROR
. fd_set结构体
上面select函数中需要用到两个fd_set形参,这个结构体到底做什么用的呢
fd_set其实这是一个数组的宏定义,实际上是一long类型的数组,每一个数组元素都能与一打开的文件句柄(socket、文件、管道、设备等)建立联系,建立联系的工作由程序员完成,当调用select()时,由内核根据IO状态修改fd_set的内容,由此来通知执行了select()的进程哪个句柄可读。
系统提供了FD_SET, FD_CLR, FD_ISSET, FD_ZERO进行操作,声明如下:
1 2 3 4 |
|
下面写一段程序探究一下这几个宏的工作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
当使用FD_SET添加完1、2、3、7后,fdset的值如下:
然后经过FD_CLR以后,fd_array[2]就被清除了,数组后面的数据依次往前提,即7被放到了fd_array[2]
所以isset前后两次打印的值分别为1和0
3. 小结
select的结果会对fd_set造成影响。例如,对于一个监听的socket:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
所以可以使用select以及fd的操作来完成异步的网络消息处理,具体的实现请看这里的例子
select 系统调用
#include <sys/select.h>
int select (int maxfd + 1,fd_set *readset,fd_set *writeset,
fd_set *exceptset,const struct timeval * timeout);
参数一:最大的文件描述符加1。
参数二:用于检查可读性,
参数三:用于检查可写性,
参数四:用于检查带外数据,
参数五:一个指向timeval结构的指针,用于决定select等待I/o的最长时间。如果为空将一直等待。timeval结构的定义:struct timeval{
long tv_sec; // seconds
long tv_usec; // microseconds
}
两种poll
poll 函数 此函数在系统调用select内部被使用
unsigned int (*poll)(struct file * fp, struct poll_table_struct * table)
此函数在系统调用select内部被使用,作用是把当前的文件指针挂到设备内部定义的等待
队列中。这里的参数table可以不考虑,是在select函数实现过程中的一个内部变量。
函数具体实现时:
wait_queue_head_t t = ((struct mydev *)filp->private_data)->wait_queue;
poll_wait(filp, t, table);
return mask;
这里mask可以是:
POLLIN | POLLRDNORM
POLLOUT | POLLWRNORM
等等。
poll()函数 系统调用
poll()函数:这个函数是某些Unix系统提供的用于执行与select()函数同等功能的函数,下面是这个函数的声明:
#include <poll.h>
int poll(struct pollfd fds[], nfds_t nfds, int timeout);
参数说明:
fds:是一个struct pollfd结构类型的数组,用于存放需要检测其状态的Socket描述符;每当调用这个函数之后,系统不会清空这个数组,操作起来比较方便;特别是对于socket连接比较多的情况下,在一定程度上可以提高处理的效率;这一点与select()函数不同,调用select()函数之后,select()函数会清空它所检测的socket描述符集合,导致每次调用select()之前都必须把socket描述符重新加入到待检测的集合中;因此,select()函数适合于只检测一个socket描述符的情况,而poll()函数适合于大量socket描述符的情况;
nfds:nfds_t类型的参数,用于标记数组fds中的结构体元素的总数量;
timeout:是poll函数调用阻塞的时间,单位:毫秒;
返回值:
>0:数组fds中准备好读、写或出错状态的那些socket描述符的总数量;
==0:数组fds中没有任何socket描述符准备好读、写,或出错;此时poll超时,超时时间是timeout毫秒;换句话说,如果所检测的socket描述符上没有任何事件发生的话,那么poll()函数会阻塞timeout所指定的毫秒时间长度之后返回,如果timeout==0,那么poll() 函数立即返回而不阻塞,如果timeout==INFTIM,那么poll() 函数会一直阻塞下去,直到所检测的socket描述符上的感兴趣的事件发生是才返回,如果感兴趣的事件永远不发生,那么poll()就会永远阻塞下去;
-1: poll函数调用失败,同时会自动设置全局变量errno;
select、poll、epoll之间的区别(搜狗面试)
(1)select==>时间复杂度O(n)
它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
(2)poll==>时间复杂度O(n)
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.
(3)epoll==>时间复杂度O(1)
epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))
select,poll,epoll都是IO多路复用的机制。I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
epoll跟select都能提供多路I/O复用的解决方案。在现在的Linux内核里有都能够支持,其中epoll是Linux所特有,而select则应该是POSIX所规定,一般操作系统均有实现
select:
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:
1、 单个进程可监视的fd数量被限制,即能监听端口的大小有限。
一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048.
2、 对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低:
当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
3、需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大
poll:
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:
1、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
2、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
epoll:
epoll有EPOLLLT和EPOLLET两种触发模式,LT是默认的模式,ET是“高速”模式。LT模式下,只要这个fd还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作,而在ET(边缘触发)模式中,它只会提示一次,直到下次再有数据流入之前都不会再提示了,无 论fd中是否还有数据可读。所以在ET模式下,read一个fd的时候一定要把它的buffer读光,也就是说一直读到read的返回值小于请求值,或者 遇到EAGAIN错误。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll为什么要有EPOLLET触发模式?
如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLLET这种边沿触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符
epoll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3、 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。
select、poll、epoll 区别总结:
1、支持一个进程所能打开的最大连接数
select
单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。
poll
poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的
epoll
虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接
2、FD剧增后带来的IO效率问题
select
因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
poll
同上
epoll
因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
3、 消息传递方式
select
内核需要将消息传递到用户空间,都需要内核拷贝动作
poll
同上
epoll
epoll通过内核和用户空间共享一块内存来实现的。
总结:
综上,在选择select,poll,epoll时要根据具体的使用场合以及这三种方式的自身特点。
1、表面上看epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
2、select低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善
关于这三种IO多路复用的用法,前面三篇总结写的很清楚,并用服务器回射echo程序进行了测试。连接如下所示:
select:IO多路复用之select总结
poll:O多路复用之poll总结
epoll:IO多路复用之epoll总结
我们来看下select
函数的参数。参数n
指定需要测试的描述符的数目,测试的描述符范围从0到n-1。第二个参数fdset
指定需要测试的可读描述符集合。当fdset
集合中有描述符可读,或者经历了timeout
时间时,select
将返回。当select
返回时,作为一个副作用,select
修改了参数fdset
指向的描述符集合,这时fdset
变成由读集合中准备好可以读了的描述符组成。select
函数的返回值则指明了就绪集合的基数。值得注意的是,由于这个副作用,我们必须每次在调用select
时都更新读集合。
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <sys/time.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
int listenfd, connfd;
int server_len, client_len;
struct sockaddr_in server_address;
struct sockaddr_in client_address;
fd_set readfds, testfds;
// 创建套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
// 命名套接字
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(6240);
server_len = sizeof(server_address);
bind(listenfd, (struct sockaddr*)&server_address, server_len);
// 创建套接字队列
listen(listenfd, 5);
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);
// 等待客户请求
while (1) {
char ch;
int fd;
int nread;
// 同时检查监听套接字和已连接套接字
testfds = readfds;
printf("server waiting\n");
int result = select(FD_SETSIZE, &testfds, (fd_set*)0, (fd_set*)0, (struct timeval*)0);
if (result < 1) {
perror("select error");
exit(1);
}
for (fd = 0; fd < FD_SETSIZE; fd++) {
// 检查哪个描述符可读
if (FD_ISSET(fd, &testfds)) {
// 如果是一个新的客户连接请求
if (fd == listenfd) {
client_len = sizeof(client_address);
connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_len);
FD_SET(connfd, &readfds);
printf("adding client on fd %d\n", connfd);
}
// 如果是旧的客户活动
else {
ioctl(fd, FIONREAD, &nread);
// 如果客户断开连接
if (nread == 0) {
close(fd);
FD_CLR(fd, &readfds);
printf("removing client on fd %d\n", fd);
}
// 客户请求数据到达
else {
read(fd, &ch, 1);
sleep(5);
printf("serving client on fd %d\n", fd);
ch++;
write(fd, &ch, 1);
}
}
}
}
}
}
上面的代码展示了如何使用select
来编写多并发服务器的过程。服务器可以让select
调用同时检查监听套接字和已连接套接字。一旦select
指示有活动发生,就可以用FD_ISSET
来遍历所有可能的文件描述符,以检查是哪个描述符上面有活动发生。
如果是监听套接字可读,这说明正有一个客户试图建立连接,此时就可以调用accept
创建一个客户的已连接套接字而不用担心阻塞。如果是某个客户描述符准备好,这说明该描述符上有一个客户请求需要我们读取处理。如果读操作返回零字节,这表示有一个客户进程已结束,这时我们可以关闭该套接字并把它从描述符集合中删除
Select函数实现原理分析
转载至:http://blog.chinaunix.net/uid-20643761-id-1594860.html
select需要驱动程序的支持,驱动程序实现fops内的poll函数。select通过每个设备文件对应的poll函数提供的信息判断当前是否有资源可用(如可读或写),如果有的话则返回可用资源的文件描述符个数,没有的话则睡眠,等待有资源变为可用时再被唤醒继续执行。
下面我们分两个过程来分析select:
1. select的睡眠过程
支持阻塞操作的设备驱动通常会实现一组自身的等待队列如读/写等待队列用于支持上层(用户层)所需的BLOCK或NONBLOCK操作。当应用程序通过设备驱动访问该设备时(默认为BLOCK操作),若该设备当前没有数据可读或写,则将该用户进程插入到该设备驱动对应的读/写等待队列让其睡眠一段时间,等到有数据可读/写时再将该进程唤醒。
select就是巧妙的利用等待队列机制让用户进程适当在没有资源可读/写时睡眠,有资源可读/写时唤醒。下面我们看看select睡眠的详细过程。
select会循环遍历它所监测的fd_set内的所有文件描述符对应的驱动程序的poll函数。驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。当select循环遍历完所有fd_set内指定的文件描述符对应的poll函数后,如果没有一个资源可用(即没有一个文件可供操作),则select让该进程睡眠,一直等到有资源可用为止,进程被唤醒(或者timeout)继续往下执行。
下面分析一下代码是如何实现的。
select的调用path如下:sys_select -> core_sys_select -> do_select
其中最重要的函数是do_select, 最主要的工作是在这里, 前面两个函数主要做一些准备工作。do_select定义如下:
2. select的唤醒过程
前面介绍了select会循环遍历它所监测的fd_set内的所有文件描述符对应的驱动程序的poll函数。驱动程序提供的poll函数首先会将调用select的用户进程插入到该设备驱动对应资源的等待队列(如读/写等待队列),然后返回一个bitmask告诉select当前资源哪些可用。
一个典型的驱动程序poll函数实现如下:
(摘自《Linux Device Drivers – ThirdEdition》Page 165)
参考:
https://www.cnblogs.com/wuyepeng/p/9745573.html
https://www.cnblogs.com/aspirant/p/9166944.html
https://blog.csdn.net/lihao21/article/details/66097377
https://blog.csdn.net/liitlefrogyyh/article/details/52104120