一、Linux I/O
输入/输出(I/O)是指在主存和外部设备之间复制数据的过程。输入是设备到主存,输出是主存到设备。在Linux系统中,所有的I/O设备都被模型化为文件,所有的输入输出则是对应文件的读写操作。应用程序要求内核打开一个文件,即访问一个I/O设备,而内核则返回一个非负整数,成为文件描述符,用于标识该文件。Linux系统中,文件分为三种,普通文件、目录、套接字。
非缓存I/O与标准I/O (1)非缓存I/O Linux提供read和write系统调用,它们在用户空间中没有缓冲区,但在内核中有缓冲区。当执行一个write,数据写入内核缓冲区,缓冲区满后再写入到文件。 (2)标准I/O 也就是再用户层面存在缓冲区,进行写操作时,数据先写入标准I/O库的流缓冲区,写满后,调用write,将数据复制到内核缓冲区,再写入文件。流缓冲区的大小和分配空间由标准I/O库执行。 标准I/O的流缓冲区的目的是减少read和write的系统调用次数。假设内核缓冲区长100字节,每次写入10个字节,每次写入都要调用一次write。采用标准I/O,假设流缓冲区大小为50字节,每次写满后再调用write将数据写入。 实际上标准I/O为每个I/O流提供了缓存管理,共有3种类型的缓存: a、 全缓存。当流缓冲区写满后执行I/O操作 b、 行缓存。输入输出遇到换行符或者缓冲区写满时执行I/O操作。 c、 无缓存。相当于read和write。
I/O模式 考虑read或write系统调用时,数据实际上经历了两个过程。以read为例,首先等待数据准备完成;然后将准备好的数据拷贝到进程。根据这两个阶段,Linux系统中存在以下5种I/O模型。
(1) 阻塞式I/O Linux中,所有套接字默认情况下都是阻塞的。如下图所示,当进行recvfrom系统调用时,内核进行数据准备,并将数据从内核复制到用户空间,然后recvfrom返回。整个过程中进程都是阻塞的,直到recvfrom返回。
(2) 非阻塞式I/O Linux支持将socket设置成非阻塞的,即告诉内核,如果当前请求的I/O操作必须将进程休眠,那么不要将进程休眠,而是返回一个错误。如下图,前三次recvfrom调用时,没有数据准备好,recvfrom直接返回一个EWOULDBLOCK错误;第四次调用时,数据已准备好,内核进行数据复制,recvfrom成功返回。在这个过程中,进程需要循环调用recvfrom,以访问内核是否有数据准备好,也称为轮询。不过这样会浪费大量CPU时间。
(3)I/O复用 I/O复用,即一个进程能够处理多个I/O,也就是select、poll、epoll这三个系统调用的功能了。在没有I/O调用时,进程阻塞在select而非真正的系统调用上;当有socket的数据准备好了,select就会返回,通知进程调用read。
(4)信号驱动式I/O 首先需要开启socket的信号驱动I/O功能,并通过sigaction系统调用,安装一个信号处理函数,这个系统调用会直接返回,不阻塞进程。当数据准备好时,内核产生一个SIGIO信号,信号处理函数捕捉到这个信号,并在其中调用recvfrom。
(5)异步I/O 异步I/O的机制是,进程告知内核进行某个操作,令内核在操作完成后,通过递交信号通知进程操作已完成。如图所示,进程调用aio_read(POSIX),然后立即返回,并不阻塞;内核进行I/O操作,并在完成时递交一个信号。异步I/O与信号驱动I/O的区别是,异步I/O在操作完成时递交信号通知,而信号驱动I/O由内核通知何时开始一个操作。
二、I/O复用
1.select 函数原型:
# include # include int select ( int maxfdg1, fd_set * readset, fd_set * writeset , fd_set * exceptset, const struct timeval * timeout) ;
-maxfdp1为制定的待测试的描述符个数,它的值是待测试的最大描述符-1。 -readset、writeset、exceptset三个参数是指定让内核测试读、写和异常条件的描述符。支持的异常条件有:某个套接字的带外数据到达;某个已置为分组模式的伪终端存在可从其主端读取的控制状态信息。Select使用描述符集,通常是一个整数数组,其中每个整数的每一位对应一个描述符。当描述集中有描述符就绪时,select返回就绪描述符。 -timeout参数告知内核等待所指定描述符中的任何一个就绪可花多长时间,结构体形式为:
struct timeval { long tv_sec; long tv_usec; }
2.poll 函数原型:
# include int poll ( struct pollfd * fdarray, unsigned long nfds, int timeout) ;
-*fdarray参数是一个指向一个结构数组第一个元素的指针,每个元素都是一个pollfd结构,用于指定测试某个给定描述符fd的条件。要测试的条件由events成员指定,并在revents中返回描述符的状态。
struct pollfd { int fd; short events; short revents; }
-nfds指定了结构数组中元素的个数。 -timeout参数指定了poll函数返回前等待多长时间。(ms)
下表中是一些events、revents标志的常值和含义。
Event Revents 说明 POLLIN √ √ 普通或优先数据可读 POLLRDNORM √ √ 普通数据可读 POLLRDBAND √ √ 优先数据可读 POLLPRI √ √ 高优先级数据可读 POLLOUT √ √ 普通数据可写 POLLWRNORM √ √ 普通数据可写 POLLWRBAND √ √ 优先数据可写 POLLERR √ 发生错误 POLLHUP √ 发生挂起 POLLNVAL √ 描述符不是一个打开的文件 POLLRDHUP √ √ TCP连接被对方关闭,或者对方关闭了写操作(是Linux的非协议扩展)
注:关于POLLHUP与POLLRDHUP的区别,见下文: Q: According to the poll man page, the poll function can return POLLHUP and POLLRDHUP events. From what I understand, only POLLHUP is POSIX compliant, and POLLRDHUP is a Linux non-standard extension. Howerver, both seem to signal that the write end of a connection is closed, so I don’t understand the added value of POLLRDHUP over POLLHUP. Would someone please explain the difference between the two? A: No, when poll()ing a socket, POLLHUP will signal that the connection was closed in both directions. POLLRDHUP will be set when the other end has called shutdown(SHUT_WR) or when this end has called shutdown(SHUT_RD), but the connection may still be alive in the other direction. You can have a look at net/ipv4/tcp.c the kernel source:
if ( sk-> sk_shutdown == SHUTDOWN_MASK || state == TCP_CLOSE) mask |= EPOLLHUP; if ( sk-> sk_shutdown & RCV_SHUTDOWN) mask |= EPOLLIN | EPOLLRDNORM | EPOLLRDHUP;
SHUTDOWN_MASK is RCV_SHUTDOWN|SEND_SHUTDOWN. RCV_SHUTDOWN is set when a FIN packet is received, and SEND_SHUTDOWN is set when a FIN packet is acknowledged by the other end, and the socket moves to the FIN-WAIT2 state.
[except for the TCP_CLOSE part, that snippet is replicated by all protocols; and the whole thing works similarly for unix sockets, etc]
There are other important differences – POLLRDHUP (unlike POLLHUP) has to be set explicitly in .events in order to be returned in .revents.
And POLLRDHUP only works on sockets, not on fifos/pipes or ttys
3.epoll epoll是Linux特有的I/O复用函数,与select和poll不同的是,epoll采用一组函数来实现功能,并且epoll将调用者关心的描述符事件维护在内核的一个事件表中,因此不需要传入描述符集;但需要传入一个标识时间表的描述符。
1、epoll_create
# include int epoll_create ( int size)
-size告诉内核需要的事件表的大小 -返回值为文件描述符,作为其他epoll系统调用的第一个参数,
2、epoll_ctl
# include int epoll_ctl ( int epfd, int op, int fd, struct epoll_event * event)
-epfd内核事件表的描述符 -op为指定的操作类型,包含3种: EPOLL_CTL_ADD: 向注册表中注册fd上的事件 EPOLL_CTL_MOD: 修改fd上的注册事件 EPOLL_CTL_DEL: 删除fd上的注册事件 -fd 为要操作的文件描述符 -*event为指定的事件,是epoll_event结构体指针
struct epoll_event { __uint32_t events; epoll_data_t data; } typedef union epoll_data{ void * ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t
3、epoll_wait 该系统调用在一段超时时间内等待一组文件描述符上的事件。
# include int epoll_wait ( int epfd, struct epoll_event * events, int maxevents, int timeout) ;
-epfd 内核事件表的描述符 -*events 所有就绪的事件将从内核事件表复制到该指针指向的数组中。 -maxevents 指定最多监听多少事件 -timeout参数指定了epoll_wait函数返回前等待多长时间。(ms)
int ret = poll ( fds, MAX_EVENT_NUMBER, - 1 ) ; for ( int i = 0 ; I < MAX_EVENT_NUMBER; i++ ) { if ( fds[ i] . revents & POLLIN) { int sockfd= fds[ i] . fd; } } int ret = epoll_wait ( epollfd, events, MAX_EVENT_NUMBER, - 1 ) ; for ( int i = 0 ; I < ret ; i++ ) { if ( fds[ i] . revents & POLLIN) { int sockfd= events[ i] . data. fd; } }
Epoll对文件描述符的操作有两种模式,Level Trigger和Edge Trigger。LT为默认的工作模式,相当于效率较高的poll,当事件发生时,应用程序可以不立即处理事件,当下次调用epoll_wait时,会再次通告该事件;ET模式下,应用程序需要立即处理epoll_wait检测到的事件,因为后续epoll_wait不会再次通知重复事件。
Epoll相较于poll,新增了EPOLLET和EPOLLONESHOT两个事件。当epoll为一个文件描述符注册EPOLLET事件时,epoll会以ET模式操作该文件描述符。对于注册了EPOLLONESHOT的文件描述符,操作系统最多触发其上注册的一个可读、可写或异常事件,且只触发一次。这样一个线程对一个socket操作时,不会有其他线程同时操作该socket。不过,当第一个线程对该socket完成操作时,应立即重置EPOLLONESHOT,以保证该socket在下次可读时能够正常触发EPOLLIN事件。
void addfd ( int epollfd, int fd , bool oneshot) { epoll_event event; event_data. fd = fd; event. events = EPOLLIN; if ( oneshot) { event. events |= EPOLLONESHOT; } epoll_ctl ( epollfd, EPOLL_CTL_ADD, fd, & event) ; setnonblocking ( fd) ; } void reset_oneshot ( int epollfd, int fd) { epoll_event event; event. data. fd = fd; event. events = EPOLLIN | EPOLLET | EPOLLONESHOT; epoll_ctl ( epollfd, EPOLL_CTL_MOD, FD, & event) ; }
三、select、poll、epoll对比
select、poll、epoll这三个I/O复用系统调用,都能够实现同时监听多个文件描述符的功能,在timeout时间范围内,等待一个或多个文件描述符上的事件,当事件发生时返回。但是从多个方面看,这三个系统调用存在着差异。
1、事件集合 select:通过维护可读、可写、异常三个事件的文件描述符集合,因此select不能处理更多类型的事件;由于内核对描述符集合进行在线修改,应用程序进行下次select调用前,需要重置这三个描述符集合;
poll:将每个描述符与事件绑定,定义一个pollfd结构,内核进行修改的是revents变量,events变量不会被修改,也就是说下次调用poll时,进程无需对pollfd的参数进行重置。
epoll:在内核中维护一个事件表,通过一个独立的系统调用epoll_ctl来对事件表进行添加修改删除。这样epoll_wait每次都从事件表中读取注册的事件,而非反复从用户空间读入事件。
由于select和poll每次调用均需要返回整个注册事件表的集合,因此进程索引就绪文件描述符的时间复杂度为O(n);而epoll_wait的events参数仅用来返回就绪的事件,因此进程索引就绪文件描述符的时间复杂度为O(1)。
2、描述符数量 poll和epoll_wait分别通过nfds和maxevents参数来指定最多监听多少个文件描述符和事件,这两个数值都能达到系统允许打开的最大文件描述符数目,即65535。
而select允许监听的最大文件描述符数量通常有限制。在最初设计select时,操作系统通常对每个进程可用的最大描述符进行了限制;但在现在的Unix版本中,允许每个进程使用事实上无限数目的描述符,具体数量的限制来自于内存总量和管理性限制。 目前许多实现中有如下声明:
# ifndef FD_SETSIZE # define FD_SETSIZE 256 # endif
但是,仅仅修改宏定义值,并不能够改变select描述符集的大小,因为还需要重新编译内核。有些厂家允许FD_SETSIZE值修改为更大的值,但这样的改动,可能会导致可移植性问题。
3、实现原理与工作模式
实现原理上讲,select与poll都采用的时轮询的方式,每次调用都要扫描整个注册描述符集合,因此它们的时间复杂度是O(n)。epoll_wait采用的是回调的方式,内核检测到描述符就绪则触发回调函数,回调函数将该文件描述符上对应的事件插入内核就绪事件队列,内核在适当的时机将就绪队列内容复制到用户空间。所以epoll_wait的时间复杂度为O(n)。但当活动连接较多时,回调函数会频繁触发,所以epoll_wait效率未必高于select和poll。 select和poll工作模式为LT,epoll则支持ET高效模式。