一、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 <sys/select.h>
#include <sys/time.h>
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 <poll.h>
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 <sys/epoll.h>
int epoll_create(int size)
-size告诉内核需要的事件表的大小 -返回值为文件描述符,作为其他epoll系统调用的第一个参数,
2、epoll_ctl
#include <sys/epoll.h>
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 <sys/epoll.h>
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高效模式。
|