I/O复用——select和poll
概述
I/O多路复用(multiplexing)的本质是通过一种机制(系统内核缓冲I/O数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。I/O复用的函数本身是阻塞的,他们提高程序的效率原因在于他们具有同时监听多个I/O事件的能力。
Linux中基于socket的通信本质也是一种I/O,使用socket()函数创建的套接字默认都是阻塞的,这意味着当sockets API的调用不能立即完成时,线程一直处于等待状态,直到操作完成获得结果或者超时出错。会引起阻塞的socket API分为以下四种:
- 输入操作: recv()、recvfrom()。以阻塞套接字为参数调用该函数接收数据时,如果套接字缓冲区内没有数据可读,则调用线程在数据到来前一直睡眠。
- 输出操作: send()、sendto()。以阻塞套接字为参数调用该函数发送数据时,如果套接字缓冲区没有可用空间,线程会一直睡眠,直到有空间。
- 接受连接:accept()。以阻塞套接字为参数调用该函数,等待接受对方的连接请求。如果此时没有连接请求,线程就会进入睡眠状态。
- 外出连接:connect()。对于TCP连接,客户端以阻塞套接字为参数,调用该函数向服务器发起连接。该函数在收到服务器的应答前,不会返回。这意味着TCP连接总会等待至少服务器的一次往返时间。
阻塞式I/O模型
当进程在等待数据时,若该数据一直没有产生,则该进程将一直等待,直到等待的数据产生为止,这个过程中进程的状态是阻塞的。
用户态进程调用recvfrom系统调用(udp)接收数据,当前内核中并没有准备好数据,该用户态进程将一直在此等待,不会进行其他的操作,待内核态准备好数据将数据从内核态拷贝到用户空间内存然后recvfrom返回成功的指示(或发生错误也返回,如系统调用被信号中断),此时用户态进行才解除阻塞的状态,处理收到的数据。
用户态接收内核态数据的时候,主要有两个过程:内核态获得数据-->将数据从内核态的内存空间中复制到用户态进程的缓冲区中
非阻塞式I/O模型
进程把一个套接字设置成非阻塞是在通知内核:当所请求的I/O操作非得把本进程投入睡眠才算完成,不要把本进程投入睡眠而是返回个错误。
用户态进程调用recvfrom接收数据,当前并没有数据报文产生,此时recvfrom返回EWOULDBLOCK,用户态进程会一直调用recvfrom询问内核,待内核准备好数据的时候,之后用户态进程不再询问内核,待数据从内核复制到用户空间,recvfrom成功返回,用户态进程开始处理数据。当数据从内核复制到用户空间中的这一段时间中,用户态进程是处于阻塞的状态的。
I/O复用模型
IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。IO多路复用适用如下场合:
- 当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
- 当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
- 如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
- 如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
- 如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
如果一个进程需要等到多种不同的消息,那么一般的做法就是开启多条线程,每个线程接收一类消息,如果每个线程都是采用阻塞式I/O模型,那么每个线程在消息未产生的时候就会阻塞,也就是说在多线程中使用阻塞式I/O。I/O复用就是基于上述的场景中,无需采用多线程监听消息的方式,进程直接监听所有的消息类型,这其中就涉及到select、poll、epoll等不同的方法。
可以将select复用机制看作是一个描述符集合的管理,进程通过向这个集合中放入不同的描述符,用来等待不同的消息产生,然后通过select统一的进行管理,让其可以同时等待这个集合中任意一个事件的产生。I/O复用和阻塞式I/O很相似,不同的是
- I/O复用等待多类事件,阻塞式I/O只等待一类事件
- 在I/O复用中,会产生两个系统调用(select和recvfrom),而阻塞式I/O只产生一个系统调用
那么这就涉及到具体的性能问题,当只存在一类事件的时候,使用阻塞式I/O模型的性能会更好,当存在多种不同类型的事件时,I/O复用的性能要好的多,因为阻塞式I/O模型只能监听一类事件,所以这个时候需要使用多线程进行处理。
另一种与I/O复用类似的是多线程中使用阻塞式I/O,这种模型使用多个线程(每个文件描述符一个线程),每个线程都可以自由的调用recvfrom之类的阻塞式I/O系统调用。
信号驱动式I/O模型
与阻塞式和非阻塞式有了一个本质的区别,那就是用户态进程不再等待内核态的数据准备好,直接可以去做别的事情。
先开启套接字的信号驱动I/O功能,并通过sigaction系统调用安装一个信号处理函数,该系统调用立即返回,进程继续工作,没有被阻塞。而当内核态中的数据准备好之后,内核立马发给用户态一个信号,用户态进程收到之后,立马调用在信号处理函数中调用recvfrom,等待数据从内核空间复制到用户空间(这段时间用户态是阻塞的),待完成之后recvfrom返回成功指示,用户态进程才处理别的事情。
无论如何处理SIGIO信号,这种模型的优势在于等待数据报到达期间进程不阻塞,主循环可以继续执行,只要等待来自信号处理函数通知:既可以是数据已准备好被处理,也可以是数据报已准备好被处理。
异步I/O模型
先用户态进程告诉内核态需要什么数据(上图中通过aio_read),然后用户态进程就不管了,并让内核在整个操作(内核等待用户态需要的数据准备好,然后将数据复制到用户空间),完成后通知我们,与信号驱动I/O区别是:信号驱动I/O通知我们何时执行一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。
我们调用aio_read函数,要给内核传递描述符,缓冲区指针,缓冲区大小(与read前三个参数同)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们,该系统调用立即返回,并在等待I/O完成期间,我们的应用进程不被阻塞,内核完成操作时才产生信号,该信号直到数据已复制到应用进程缓冲区才产生,这不同于信号驱动I/O模型。
I/O模型比较
五种不同的I/O模型中,前四种主要区别在第一阶段,因为第二阶段是一样:从内核复制数据到调用者的缓冲区期间,进程阻塞于系统调用。
select
#include <sys/select.h> #include <sys/time.h> int select(int max_fd, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout)
- max_fd指定被监听的文件描述符的总数,设置为监听的所有文件描述符中最大值加1,因为文件描述符是从0开始计数的。
- readfds、writefds和exceptfds参数分别指向可读、可写、异常事件对应的文件描述符集合。程序员通过这3个参数向该调用传入自己感兴趣的文件描述符。函数返回时,内核将修改它们来通知应用程序哪些文件描述符已经就绪,描述符集内任何未就绪的描述符对应的位返回时均清0,每次重新调用select时,得再次把关心的位设为1
- 每次返回用户注册的整个事件的集合,包括就绪的和未就绪的,所以索引就绪的文件描述符集合为O(n)
#define XFD_SETSIZE 256 #define FD_SETSIZE XFD_SETSIZE typedef long fd_mask; #define NBBY 8 #define NFDBITS (sizeof(fd_mask) * NBBY) #define howmany(x,y) (((x)+((y)-1))/(y)) #if defined(BSD) && BSD < 198911 typedef struct fd_set { fd_mask fds_bits[howmany(FD_SETSIZE, NFDBITS)]; } fd_set; #endif
fd_set结构体仅包含一个long型数组,根据推导可见该数组为,它仅仅是一个文件描述符集合,没有将文件描述符和事件绑定,因此需要提供三种这样的类型的集合来分别访问输入输出和异常,不能处理更多类型的事件,
long fds_bits[8];
fds_bit共占据32字节,即256位。该数组的每个元素的每一位(bit)标记一个文件描述符。fd_set能容纳的文件描述符由FD_SETSIZE指定,显然这限制了select()能同时处理的文件描述符的总数。该系统调用还提供了一些列宏方便程序员实现位操作:
void FD_CLR(int fd, fd_set *set); //清零set中的fd位 int FD_ISSET(int fd, fd_set *set); //测试set中的fd位是否被设置 void FD_SET(int fd, fd_set *set); //设置fd中的fd位 void FD_ZERO(fd_set *set); //清零set中所有位
3.timeout参数设置函数的超时时间。它是一个timeval的普通(非const)指针,内核可以修改此参数以告诉应用程序函数阻塞等待了多久。不过内核返回的该值不能完全信任,比如调用失败时timeout的值是不确定的。timeval结构体定义如下:
struct timeval
{ long tv_sec; /* seconds */ long tv_usec; /* microseconds */ };
此参数有三种可能:
- 永远等下去:尽在有一个描述符准备好I/O才返回,此时设为NULL
- 等待一段固定的时间:有一个描述符准备好时返回I/O,不超过指定的秒数和微妙数
- 不等待立即返回:此参数必须指向一个timeval结构体,该结构体中的秒数和微妙数必须为0
文件描述符就绪条件
描述符可读的依据是:
(1) socket内核接收缓冲区中字节数>=其低水位标记SO_RCVLOWAT时(对于tcp和udp而言,默认值为1),此时程序可以无阻塞地读该socket,返回读取到的字节数(>0)
(2) socket通信的对端关闭连接(即接受了FIN的tcp),此时对该socket的读操作将返回0表示对端关闭
(3) 该套接字是一个监听套接字,且已完成连接数不为0,对于这样的套接字的accept通常不会阻塞。
(4) socket上有未处理的错误,对于这样的套接字读操作不阻塞并返回-1,同时把errno设置为错误条件,此时可以使用getsockopt()读取和清除该错误(使用SO_ERROR标记)
文件描述符可写:
(1) socket内核发送缓冲区的空闲区域大于或等于其低水位标记SO_SNDLOWAT(对于TCP和UDP而言,通常默认2048),此时程序可以无阻塞的写该socket,返回写入的字节数(>0) ,或者该套接字已连接,或者该套接字不需要连接(如UDP套接字),如我们把套接字设置为非阻塞,写操作将不会阻塞,并返回个正值(由传输层接受的字节数)。
(2) socket的写操作被关闭(使用shotdown(fd, SHUT_WR))后再对socket写,会触发一个SIGPIPE信号
(3) socket使用非阻塞connect()连接成功或者失败(超时)之后,对于后者将会收到RST报文段,若收到RST报文段后继续往该socket写则会触发SIGPIPE信号
(4) socket上未处理的错误,同上
(5)如果一个套接字存在带外数据或者仍处于带外标记,那么他有异常条件待处理。
注意:
当某个套接字发生错误时,他将由select标记变为即可读又可写;接受低水位标记和发送低水位标记目的:允许应用进程控制在select返回即可读又可写条件之前有多少数据可读或有多大空间可写,例:如果我们知道除非存在64字节的数据,否则我们的应用程序没有任何有效的工作,那么可以把接受低水位标记设置为64,防止少于64字节的数据唤醒我们。
任何udp套接字只要其发送低水位标记小于等于发送缓冲区大小(默认是这种关系)就总是可写的,因为udp套接字不需要连接。
select不知道stdio使用了缓冲区,他只是从read系统调用的角度指出是否有数据可读,而不是从fgets角度之类的考虑,所以混合使用stdio和select容易出错
poll
poll的机制与select类似,处理流设备时能提供额外的信息,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但最大文件描述符限制为65535;
#include <poll.h> int poll(struct pollfd fds[], nfds_t nfds, int timeout); typedef struct pollfd { int fd; // 需要被检测或选择的文件描述符 short events; // 对文件描述符fd上感兴趣的事件 short revents; // 文件描述符fd上当前实际发生的事件,内核每次修改此结构体。events不变,所以无需反复从用户空间读入这些事件*/ } pollfd_t; /* 1.函数返回fds集合中就绪的读、写,或出错的描述符数量,返回0表示超时,返回-1表示出错; 2.fds:用于存放需要检测其状态的socket描述符,并且调用poll函数之后fds数组不会被清空;一个pollfd结构体表示一个被监视的文件 描述符,通过传递fds[]指示 poll() 监视的文件描述符。events域是监视该文件描述符的事件掩码,由用户来设置。revents是文件 描述符的操作结果事件掩码,内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回。 3.nfds:最多监听的文件描述符数 4.timeout:是调用poll函数阻塞的超时时间,单位毫秒; */
当不关心某个特定的描述符时,把它对应的结构体fd设置为负值,这样poll将忽略结构体中的events成员,返回时将revents成员设置为0
fd成员指定文件描述符。event成员告诉内核要监听fd上的哪些事件,它可以是一系列事件的按位或。revents成员由内核修改,以通知应用程序fd上实际发生了哪些事件。event的取值为:
上面事件选项中:
a. POLLRDNORM(普通数据可读)、POLLRDBAND(优先级带数据可读)和POLLWRNORM(普通数据可写)、POLLWRBAND(优先级带数据可写)将POLLIN(数据可读)和POLLOUT(数据可写)划分得更明显,以区分优先级带数据和普通数据,但是Linux并不完全支持。
b. 一般应用程序调用recv()时,要判断接收到的是有效数据还是对端关闭连接后触发的是根据recv()的返回值(如上面的select()示例程序),在poll()系统调用中,有更直接的方法,监听描述符的POLLRDHUP事件即监听对端关闭事件,不过需要在代码开始处定义”_GNU_SOURCE”
(2) fds数组成员的的个数由参数nfds指定(typedef unsigned long int nfds_t;)。显然,这个比select()的设计要灵活一点: 用户可以监测任意多数目文件描述符,但是poll()的实现也是依靠轮询的,从效率上来讲跟select()的实现是一致的。
(3) timeout参数指定函数的超时事件,单位为毫秒。当timeout为-1时,poll调用将一直阻塞直到监听的目标事件发生;当timeout为0时,poll()调用立即返回。
(4) poll()的返回值跟select()的返回值含义相同。
当poll调用之后检测某事件是否发生时,fds[i].revents & POLLIN进行判断。
注意:
- 所有正规的TCP和UDP都是普通数据
- TCP的外带数据是优先数据
- TCP的读半部分关闭时(如收到对端的FIN)也是普通数据,随后的读操作返回0
- TCP连接存在错误即可认为是普通数据也可认为是错误,无论哪种情况随后的读操作都返回-1,并设置errno,这适用于处理RST或超时等待条件
- 监听套接字上有新的数据即可认为是普通数据也可认为是优先数据, 大多数认为是普通数据
- 非阻塞的connect的完成被认为是相应的套接字可写
总结
阻塞I/O,I/O复用和信号驱动I/O都是同步I/O,因为在这三种I/O模型中,I/O的读写操作都是在I/O事件发生后,由应用程序来完成;对于异步I/O,用户可以直接对I/O进行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及I/O操作完成之后内核通知应用进程的方式,异步I/O操作总是立即返回,不论I/O是否阻塞,因为真正的读写操作已经由内核接管。即:同步I/O模型要求用户代码自行执行I/O操作(将数据从内核缓冲区读入用户缓冲区或从用户缓冲区写入内核缓冲区),异步I/O是由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区的移动是由内核在“后台”完成的)。同步I/O是应用程序通知I/O就绪事件,异步I/O是通知应用程序I/O完成事件。
作用:
- 非阻塞的connect
- 同时处理udp和tcp:1>一个socket只能监听一个端口,服务器如果要同时监听多个端口,就要创建多个socket,分别将他们绑定到多个socket上,此时服务器需要监听多个socket,就可以用I/O复用;2>一个端口同时处理该端口的tcp和udp请求——创建两个不同的socket,两个不同的流都绑定到相同的端口上,就可同时处理同一端口上的tcp和udp请求。
- 客户端同时首发数据
close
close 一个套接字的默认行为是把套接字标记为已关闭,然后立即返回到调用进程,该套接字描述符不能再由调用进程使用,也就是说它不能再作为read或write的第一个参数,然而TCP将尝试发送已排队等待发送到对端的任何数据,发送完毕后发生的是正常的TCP连接终止序列。
在多进程并发服务器中,父子进程共享着套接字,套接字描述符引用计数记录着共享着的进程个数,当父进程或某一子进程close掉套接字时,描述符引用计数会相应的减一,当引用计数仍大于零时,这个close调用就不会引发TCP的四路握手断连过程。
shutdown
#include<sys/socket.h> int shutdown(int sockfd,int howto); //返回成功为0,出错为-1.
该函数的行为依赖于howto的值
- SHUT_RD:值为0,关闭连接的读这一半。
- SHUT_WR:值为1,关闭连接的写这一半。
- SHUT_RDWR:值为2,连接的读和写都关闭。
终止网络连接的通用方法是调用close函数。但使用shutdown能更好的控制断连过程(使用第二个参数)。
区别
- close函数会关闭套接字ID,如果有其他的进程共享着这个套接字,那么它仍然是打开的,这个连接仍然可以用来读和写,并且有时候这是非常重要的 ,特别是对于多进程并发服务器来说
- shutdown会切断进程共享的套接字的所有连接,不管这个套接字的引用计数是否为零,那些试图读得进程将会接收到EOF标识,那些试图写的进程将会检测到SIGPIPE信号,同时可利用shutdown的第二个参数选择断连的方式
- close关闭本进程的socket id,但链接还是开着的,用这个socket id的其它进程还能用这个链接,能读或写这个socket id。
- shutdown--破坏了socket 链接,读的时候可能侦探到EOF结束符,写的时候可能会收到一个SIGPIPE信号,这个信号可能直到socket buffer被填充了才收到,shutdown有一个关闭方式的参数,0 不能再读,1不能再写,2 读写都不能。
客户端有两个进程,父进程和子进程,子进程是在父进程和服务器建连之后fork出来的,子进程发送标准输入终端键盘输入数据到服务器端,知道接收到EOF标识,父进程则接受来自服务器端的响应数据。
/* First Sample client fragment, * 多余的代码及变量的声明已略 */ s=connect(...); if( fork() ){ /* The child, it copies its stdin to the socket */ while( gets(buffer) >0) write(s,buf,strlen(buffer)); close(s); exit(0); } else { /* The parent, it receives answers */ while( (n=read(s,buffer,sizeof(buffer)){ do_something(n,buffer); /* Connection break from the server is assumed */ /* ATTENTION: deadlock here */ wait(0); /* Wait for the child to exit */ exit(0); }
子进程close套接字后,套接字对于父进程来说仍然是可读和可写的,尽管父进程永远都不会写入数据。因此,此socket的断连过程没有发生,因此,服务器端就不会检测到EOF标识,会一直等待从客户端来的数据。而此时父进程也不会检测到服务器端发来的EOF标识。这样服务器端和客户端陷入了死锁(deadlock)。如果用shutdown代替close,则会避免死锁的发生。
if( fork() ) { /* The child */ while( gets(buffer) write(s,buffer,strlen(buffer)); shutdown(s,1); /* Break the connection *for writing, The server will detect EOF now. Note: reading from *the socket is still allowed. The server may send some more data *after receiving EOF, why not? */ exit(0); }
IO分两阶段(一旦拿到数据后就变成了数据操作,不再是IO):
- 数据准备阶段
- 内核空间复制数据到用户进程缓冲区(用户空间)阶段
阻塞非阻塞、同步异步:
- 阻塞IO和非阻塞IO的区别在于第一步发起IO请求是否会被阻塞: 如果阻塞直到完成那么就是传统的阻塞IO,如果不阻塞,那么就是非阻塞IO。 一般来讲: 阻塞IO模型、非阻塞IO模型、IO复用模型(select/poll/epoll)、信号驱动IO模型都属于同步IO,因为阶段2是阻塞的(尽管时间很短)。
- 同步IO和异步IO的区别就在于第二个步骤是否阻塞: 如果不阻塞,而是操作系统帮你做完IO操作再将结果返回给你,那么就是异步IO