要介绍这三者,首先要先提一下I/O多路复用:
I/O复用无非就是多个进程共同使用一个I/O输入输出流。一旦发现进程指定的一个或者多个描述符可进行无阻塞IO访问时,它就通知该进程。
服务器端工作流程:
调用 socket() 函数创建套接字,用 bind() 函数将创建的套接字与服务端IP地址绑定;
调用 listen()函数监听socket() 函数创建的套接字,等待客户端连接;
当客户端请求到来之后,调用 accept()函数接受连接请求,返回一个对应于此连接的新的套接字,做好通信准备;
调用 write()/read() 函数和 send()/recv()函数进行数据的读写,通过 accept() 返回的套接字和客户端进行通信 关闭socket(close)客户端工作流程:
调用 socket() 函数创建套接字;
调用 connect() 函数连接服务端;
调用write()/read() 函数或者 send()/recv() 函数进行数据的读写
关闭socket(close);I/O多路复用的目的:I/O 多路复用是为了解决进程或线程阻塞到某个 I/O 系统调用而出现的技术,使进程或线程不阻塞于某个特定的 I/O 系统调用。
IO多路复用适用的场合:
(1) 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
(2) 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
(3) 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
(4) 如果一个服务器既要处理TCP,又要处理UDP,一般要使用I/O复用。
(5) 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。与多进程和多线程技术相比,I/O多路复用技术的大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减少了系统的开销。
对于应用层来说,使用非阻塞I/O的应用程序通常会使用select()和poll()系统调用查询是否可对设备进行无阻塞的访问。I/O处理的模型有5种:
(1)阻塞I/O模型:在这种模型下,若所调用的I/O函数没有完成相关的功能,则会使进程挂起,直到相关数据到达才会返回。如常见对管道设备、终端设备和网络设备进行读写时经常会出现这种情况。
(2)非阻塞I/O模型:在这种模型下,当请求的I/O操作不能完成时,则不让进程睡眠,而且立即返回。非阻塞I/O使用户可以调用不会阻塞的I/O操作,如open()、write()和read()。如果该操作不能完成,则会立即返回出错(如打不开文件)或者返回0(如在缓冲区中没有数据可以读取或者没空间可以写入数据)。
(3)I/O多路转接模型:在这种模型下,如果请求的I/O操作阻塞,且它不是真正阻塞I/O,而是让其中的一个函数等待,在此期间,I/O还能进行其他操作。如本小节要介绍的select()和poll()函数,就是属于这种模型。
(4)信号驱动I/O模型:在这种模型下,进程要定义一个信号处理程序,系统可以自动捕获特定信号的到来,从而启动I/O。这是由内核通知用户何时可以启动一个I/O操作决定的。它是非阻塞的。当有就绪的数据时,内核就向该进程发送SIGIO信号。 无论我们如何处理SIGIO信号,这种模型的好处是当等待数据到达时,可以不阻塞。主程序继续执行,只有收到SIGIO信号时才去处理数据即可。
(5)异步I/O模型:在这种模型下,进程先让内核启动I/O操作,并在整个操作完成后通知该进程。这种模型与信号驱动模型的主要区别在于:信号驱动I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知进程I/O操作何时完成的。现在,并不是所有的系统都支持这种模型。可以看到,select()和poll()的I/O多路转接模型是处理I/O复用的一个高效的方法。它可以具体设置程序中每一个所关心的文件描述符的条件、希望等待的时间等,从select()和poll()函数返回时,内核会通知用户已准备好的文件描述符的数量、已准备好的条件(或事件)等。通过使用select()和poll()函数的返回结果(可能是检测到某个文件描述符的注册事件或是超时,或是调用出错),就可以调用相应的I/O处理函数了。
1、select:
select系统调用的用途:在一段时间内,监听用户感兴趣的文件描述符上的可读,可写和异常等事件;
select 函数监视的文件描述符分3类:分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select()函数:
select()函数根据希望进行的文件操作对文件描述符进行了分类处理,这里对文件描述符的处理主要涉及4个宏函数,如下表所示:
当应用程序使用FD_ZERO/FD_SET/FD_CLR宏设置好要监听的文件描述符集合后,调用select()函数执行监听,如果没有一个描述符准备好IO并且没有指定超时时间,那么select()函数会一直等待下去不会返回。
当函数正常返回后,监听的文件描述符集合中没有准备好的文件描述符会被删除,只剩下已经准备好的文件描述符(但是不知道是哪一个,导致后面要用FD_ISSET来判断),之后可以使用FD_ISSET(fd, set);宏来判断set集合中是否有fd文件描述符来判断fd是否准备好IO。
一般来说,在每次使用select()函数之前,首先使用FD_ZERO()和FD_SET()来初始化文件描述符集(在需要重复调用select()函数时,先把一次初始化好的文件描述符集备份下来,每次读取它即可)。在select()函数返回后,可循环使用FD_ISSET()来测试描述符集,在执行完对相关文件描述符的操作后,使用FD_CLR()来清除描述符集。
当使用select()函数时,存在一系列的问题,例如,内核必须检查多余的文件描述符,每次调用select()之后必须重置被监听的文件描述符集,而且可监听的文件个数受限制(使用FD_SETSIZE宏来表示fd_set结构能够容纳的文件描述符的大数目)等。
基本流程:
select的优点:
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点;
select的缺点:
1、select最大的缺陷就是单个进程所打开的FD是有一定限制的,它由FD_SETSIZE设置,默认值是1024。可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但是这样也会造成效率的降低;
一般来说这个数目和系统内存关系很大,具体数目可以cat /proc/sys/fs/file-max察看。32位机默认是1024个。64位机默认是2048;
2、对socket进行扫描时是线性扫描,即采用轮询的方法,效率较低。
当套接字比较多的时候,每次select()都要通过遍历FD_SETSIZE个Socket来完成调度,不管哪个Socket是活跃的,都遍历一遍。这会浪费很多CPU时间。如果能给套接字注册某个回调函数,当他们活跃时,自动完成相关操作,那就避免了轮询,这正是epoll与kqueue做的。
3、需要维护一个用来存放大量fd的数据结构(fdset),这样会使得用户空间和内核空间在传递该结构时复制开销大。
2、poll:
poll系统调用:在制定时间内轮询一定数量的文件描述符,已测试其中是否有就绪者
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
poll()函数:
每一个pollfd结构体指定了一个被监视的文件描述符,可以传递多个结构体,指示poll()监视多个文件描述符。每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。revents域是文件描述符的操作结果事件掩码,内核在调用返回时设置这个域。
events域中请求的任何事件都可能在revents域中返回。合法的事件如下:
这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。
使用poll()和select()不一样,你不需要显式地请求异常情况报告。
poll的优点:
它没有最大连接数的限制,原因是它是基于链表来存储的。
poll的缺点:
(1) 大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
(2) poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。POLLIN等价于POLLRDNORM |POLLRDBAND,而POLLOUT则等价于POLLWRNORM。例如,要同时监视一个文件描述符是否可读和可写,我们可以设置 events为POLLIN |POLLOUT。在poll返回时,我们可以检查revents中的标志,对应于文件描述符请求的events结构体。如果POLLIN事件被设置,则文件描述符可以被读取而不阻塞。如果POLLOUT被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。
select()和poll()函数的比较:
select()和poll()函数本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有大文件描述符数量的限制。并且select()返回后,之前没有准备好的文件描述符会从集合当中删除,这样如果下次需要再次添加所有文件描述符或者使用两个相同的文件描述符集合,一个用于备份,一个用于监听,比较复杂。poll不需要这个复杂的操作。poll和select同样存在一个缺点就是包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而无论这些文件描述符是否就绪。它的开销随着文件描述符数量的增加而线性增加。
3、epoll:
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就绪态,并且只会通知一次。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。
epoll接口是linux中特有的,其他平台是不支持的。epoll接口可以检测的描述符的个数要远大于select,而且使用的灵活性更高。接下来我们来分析一下epoll的函数API,它主要包括三个函数:epoll_create、epoll_ctl、epoll_wait。
int epoll_create(int size);
功能:创建一个epoll的标示符。
参数:size 现在已经无用,并不表示检测的大值。
返回值:就是获得的epoll的标示符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:控制需要检测的文件描述符。
参数:
epfd: epoll_create得到的标识符。
op:表示此次调用要执行的操作,包括添加描述符、删除和修改属性。
EPOLL_CTL_ADD 添加描述符
EPOLL_CTL_DEL 删除描述符
EPOLL_CTL_MOD 修改描述符触发的属性
fd: 要检测的文件描述符的值
event: 执行要检测的描述符的特性,结构体成员如下
struct epoll_event
{
uint32_t events; /* Epoll events ,指定描述符被检测的条件*/
epoll_data_t data; /* User data variable */
};
typedef union epoll_data
{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
功能:等待就绪的文件描述符
参数:
epfd: epoll_create得到的标识符
events: 用来存储就绪的文件描述符状态的结构体数组指针
maxevents: 指定要检测的描述符的大个数
timeout: 指定超时检测的时间(如果不指定超时,将该参数指定为-1即可)
返回值:就绪的文件描述符的个数。
epoll的优点:
(1)没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口)。
(2)效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
(3)内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。注意:
如果没有大量的idle-connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle-connection,就会发现epoll的效率大大高于select/poll。
参考:
1、什么是I/O多路复用??https://www.cnblogs.com/xzj8023tp/p/11308154.html