IO概念
Linux的内核将所有外部设备都可以看做一个文件来操作。那么我们对与外部设备的操作都可以看做对文件进行操作。我们对一个文件的读写,都通过调用内核提供的系统调用;内核给我们返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有相应的描述符,称为socketfd(socket描述符)。描述符就是一个数字,指向内核中一个结构体(文件路径,数据区,等一些属性)。那么我们的应用程序对文件的读写就通过对描述符的读写完成。
linux将内存分为内核区,用户区。linux内核给我们管理所有的硬件资源,应用程序通过调用系统调用和内核交互,达到使用硬件资源的目的。应用程序通过系统调用read发起一个读操作,这时候内核创建一个文件描述符,并通过驱动程序向硬件发送读指令,并将读的的数据放在这个描述符对应结构体的内核缓存区中,然后再把这个数据读到用户进程空间中,这样完成了一次读操作;
但是大家都知道I/O设备相比cpu的速度是极慢的。linux提供的read系统调用,也是一个阻塞函数。这样我们的应用进程在发起read系统调用时,就必须阻塞,就进程被挂起而等待文件描述符的读就绪,那么什么是文件描述符读就绪,什么是写就绪?
读就绪:就是这个文件描述符的接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小;
写就绪:该描述符发送缓冲区的可用空间字节数大于等于描述符发送缓冲区低水位标记的当前大小。(如果是socket fd,说明上一个数据已经发送完成)。
接收低水位标记和发送低水位标记:由应用程序指定,比如应用程序指定接收低水位为64个字节。那么接收缓冲区有64个字节,才算fd读就绪;
综上所述,一个基本的IO,它会涉及到两个系统对象,一个是调用这个IO的进程对象,另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:
- 通过read系统调用想内核发起读请求。
- 内核向硬件发送读指令,并等待读就绪。
- 内核把将要读取的数据复制到描述符所指向的内核缓存区中。
- 将数据从内核缓存区拷贝到用户进程空间中。
IO模型
最流行的I/O模型是阻塞I/O模型,缺省情形下,所有文件操作都是阻塞的。我们以套接口为例来讲解此模型。在进程空间中调用recvfrom,其系统调用直到数据报到达且被拷贝到应用进程的缓冲区中或者发生错误才返回,期间一直在等待。我们就说进程在从调用recvfrom开始到它返回的整段时间内是被阻塞的。
2、非阻塞I/O模型
进程把一个套接口设置成非阻塞是在通知内核:当所请求的I/O操作不能满足要求时候,不把本进程投入睡眠,而是返回一个错误。也就是说当数据没有到达时并不等待,而是以一个错误返回。
3、I/O复用模型
linux提供select/poll,进程通过将一个或多个fd传递给select或poll系统调用,阻塞在select;这样select/poll可以帮我们侦测许多fd是否就绪。但是select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限。linux还提供了一个epoll系统调用,epoll是基于事件驱动方式,而不是顺序扫描,当有fd就绪时,立即回调函数rollback;
4、信号驱动异步I/O模型
首先开启套接口信号驱动I/O功能, 并通过系统调用sigaction安装一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据报准备好被读时,就为该进程生成一个SIGIO信号。随即可以在信号处理程序中调用recvfrom来读数据报,井通知主循环数据已准备好被处理中。也可以通知主循环,让它来读数据报。
5、异步I/O模型
告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核拷贝到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动I/O:由内核通知我们何时可以启动一个I/O操作;异步I/O模型:由内核通知我们I/O操作何时完成。
非阻塞IO详解
- int sd=socket(int domain, int type|O_NONBLOCK, int protocol);
- int fd=open(const char *pathname, int flags|O_NONBLOCK);
创建描述符后,通过调用fcntl函数设置描述符的属性为O_NONBLOCK
- #include <unistd.h>
- #include <fcntl.h>
- int fcntl(int fd, int cmd, ... /* arg */ );
- //例子
- if (fcntl(fd, F_SETFL, fcntl(sockfd, F_GETFL, 0)|O_NONBLOCK) == -1) {
- return -1;
- }
- return 0;
- }
IO复用详解
- 客户程序需要同时处理交互式的输入和服务器之间的网络连接。
- 客户端需要对多个网络连接作出反应。
- 服务器需要同时处理多个处于监听状态和多个连接状态的套接字
- 服务器需要处理多种网络协议的套接字。
select
- #include <sys/select.h>
- int select(int maxfdps, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);
- struct timeval{
- long tv_sec; //秒
- long tv_usec; //微秒
- };
- void FD_CLR(int fd, fd_set *set); //将一个给定的文件描述符从集合中删除
- int FD_ISSET(int fd, fd_set *set); // 检查集合中指定的文件描述符是否可以读写 ?
- void FD_SET(int fd, fd_set *set); //将一个给定的文件描述符加入集合之中
- void FD_ZERO(fd_set *set);//清空集合
- <span>返回:就绪描述字的正数目,0-超时,-1-出错</span>
struct timeval *time结构体告知内核等待所指定描述字中的任何一个就绪可花多少时间。参数取值:
(1)(struct timeval *)0:永远等待下去,仅在有一个描述字准备好I/O时才返回。
(2)struct timeval *time:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。如果超过时间,没有描述字准备好,那就返回0。如果秒=微秒=0,检查描述字后立即返回,此时相当于轮询。
中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。如果我们对某一个的条件不感兴趣,就可以把它设为空指针。fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过上面的四个宏进行设置(注意,fd_set不是struct fd_set。。。 刚刚开始调试程序就犯错误,返回storage size of 'fds' isn't known)。
第一个参数maxfdp1指定待测试的描述字个数,它的值是待测试的最大描述字加1(因此我们把该参数命名为maxfdp1),描述字0、1、2...maxfdp1-1均将被测试。
下面是一个从网上看到的一个比较好的测试程序,太长了,移到空间代码功能里面了,click me to see
pselect
- #include <sys/select.h>
- int pselect(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, const struct timespec *timeout,
- const sigset_t *sigmask);
- struct timespec{
- time_t tv_sec; //秒
- long tv_nsec; //纳秒
- };
比较select和pselect函数,我们发现在原型上面有两个不同:
2、pselect函数增加了第六个参数:一个指向信号掩码的指针。该参数允许程序先禁止递交某些信号,再测试由这些当前被禁止的信号处理函数设置的全局变量,然后调用pselect,告诉它重新设置信号掩码。
poll
- #include <poll.h>
- int poll(struct pollfd *fds, nfds_t nfds, int timeout);
- struct pollfd {
- int fd; /* file descriptor */
- short events; /* requested events to watch */
- short revents; /* returned events witnessed */
- };
和select()不一样,poll()没有使用低效的三个基于位的文件描述符set,而是采用了一个单独的结构体pollfd数组,由fds指针指向这个数组的第一个元素,其中nfds表示该数组的大小。每一个pollfd 结构体指定了一个被监视的文件描述符,每个结构体的events域是监视该文件描述符的事件掩码,由用户来设置这个域。revents域是文件描述符的操作结果事件掩码。内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回,而部分事件不能出现在events中,详细如下表。
常量 | 说明 |
POLLIN | 普通或优先级带数据可读 |
POLLRDNORM | 普通数据可读 |
POLLRDBAND | 优先级带数据可读 |
POLLPRI | 高优先级数据可读 |
POLLOUT | 普通数据可写 |
POLLWRNORM | 普通数据可写 |
POLLWRBAND | 优先级带数据可写 |
POLLERR | 发生错误 |
POLLHUP | 发生挂起 |
POLLNVAL | 描述字不是一个打开的文件 |
注意:后三个只能作为描述字的返回结果存储在revents中,而不能作为测试条件用于events中。
最后一个参数timeout是指定poll函数返回前等待多长时间。它的取值如下:
timeout值 | 说明 |
INFTIM | 永远等待 |
0 | 立即返回,不阻塞进程 |
>0 | 等待指定数目的毫秒数 |
成功时,poll()返回结构体中revents域不为0的文件描述符个数;如果在超时前没有任何事件发生,poll()返回0;失败时,poll()返回-1,并设置errno为下列值之一:
EBADF:一个或多个结构体中指定的文件描述符无效。EFAULT:fds指针指向的地址超出进程的地址空间。EINTR:请求的事件之前产生一个信号,调用可以重新发起。EINVAL:nfds参数超出PLIMIT_NOFILE值。ENOMEM:可用内存不足,无法完成请求。
- client[0].fd = listenfd; /*将数组中的第一个元素设置成监听描述字*/
- client[0].events = POLLIN;
- while(1)
- {
- nready = poll(client, maxi+1,INFTIM); //将进程阻塞在poll上
- if( client[0].revents & POLLIN/*POLLRDNORM*/ ) /*先测试监听描述字*/
epoll
在linux的网络编程中,很长的一段时间都在使用select来做事件触发。然而select逐渐暴露出了一些缺陷,使得linux不得不在新的内核中寻找出替代方案,那就是epoll。其实,epoll与select原理类似,只不过,epoll作出了一些重大改进,即:
1.支持一个进程打开大数目的socket描述符(FD)
select 最不能忍受的是一个进程所打开的FD是有一定限制的,由FD_SETSIZE设置,默认值是2048。对于那些需要支持的上万连接数目的IM服务器来说显然太少了。这时候你一是可以选择修改这个宏然后重新编译内核,不过资料也同时指出这样会带来网络效率的下降,二是可以选择多进程的解决方案(传统的 Apache方案),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。不过 epoll则没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
2.IO效率不随FD数目增加而线性下降
传统的select/poll另一个致命弱点就是当你拥有一个很大的socket集合,不过由于网络延时,任一时间只有部分的socket是"活跃"的,但是select/poll每次调用都会线性扫描全部的集合,导致效率呈现线性下降。但是epoll不存在这个问题,它只会对"活跃"的socket进行操作---这是因为在内核实现中epoll是根据每个fd上面的callback函数实现的。那么,只有"活跃"的socket才会主动的去调用 callback函数,其他idle状态socket则不会,在这点上,epoll实现了一个"伪"AIO,因为这时候推动力在os内核。在一些 benchmark中,如果所有的socket基本上都是活跃的---比如一个高速LAN环境,epoll并不比select/poll有什么效率,相反,如果过多使用epoll_ctl,效率相比还有稍微的下降。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/poll之上了。
3.使用mmap加速内核与用户空间的消息传递。
这点实际上涉及到epoll的具体实现了。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。
4.内核微调
这一点其实不算epoll的优点了,而是整个linux平台的优点。也许你可以怀疑linux平台,但是你无法回避linux平台赋予你微调内核的能力。比如,内核TCP/IP协议栈使用内存池管理sk_buff结构,那么可以在运行时期动态调整这个内存pool(skb_head_pool)的大小 --- 通过echo XXXX>/proc/sys/net/core/hot_list_length完成。再比如listen函数的第2个参数(TCP完成3次握手的数据包队列长度),也可以根据你平台内存大小动态调整。更甚至在一个数据包面数目巨大但同时每个数据包本身大小却很小的特殊系统上尝试最新的NAPI网卡驱动架构。
- #include <sys/epoll.h>
- #include <unistd.h>
- int epoll_create(int size); //epoll描述符
- int close(int fd);//关闭epoll描述符
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。需要注意的是,当创建好epoll句柄后,epoll本身就占用一个fd值,所以用完后必须调用close()关闭,以防止fd被耗尽。
- #include <sys/epoll.h>
- #include <unistd.h>
- int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);//添加监听事件
- typedef union epoll_data {
- void *ptr;
- int fd;
- uint32_t u32;
- uint64_t u64;
- } epoll_data_t;
- struct epoll_event {
- uint32_t events; /* Epoll events */
- epoll_data_t data; /* User data variable */
- };
epoll_ctl为事件注册函数,第一个参数是epoll_create()的返回值,第二个参数表示动作,用三个宏来表示:
- EPOLL_CTL_ADD:注册新的fd到epfd中;
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
- EPOLL_CTL_DEL:从epfd中删除一个fd;
第三个参数是需要监听的描述符fd,第四个参数是告诉内核需要监听什么事件,struct epoll_event结构如上所示,其中events为需要注册的事件,可以为下面几个宏的集合:
- EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
- EPOLLOUT:表示对应的文件描述符可以写;
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
- EPOLLERR:表示对应的文件描述符发生错误;
- EPOLLHUP:表示对应的文件描述符被挂断;
- EPOLLET:将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。详细见下面描述
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
- LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点,传统的select/poll都是这种模型的代表。
- ET (edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认。
- #include <sys/epoll.h>
- #include <unistd.h>
- int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);//阻塞等待所监听的事件发生
阻塞监听函数,类似于select()调用。参数events用来从内核得到事件的集合,返回的结构也是struct epoll_event,其中event为相应的事件,data为注册时,设置的值(常见情况,data设置为注册的描述符,这样就可以对相应的描述符进行IO操作)。maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。
- struct epoll_event ev, *events;
- int kdpfd = epoll_create(100);
- ev.events = EPOLLIN | EPOLLET; // 注意这个EPOLLET,指定了边缘触发
- ev.data.fd =listener;
- epoll_ctl(kdpfd, EPOLL_CTL_ADD, listener, &ev);
- for(;;)
- {
- nfds = epoll_wait(kdpfd, events, maxevents, -1);
- for(n = 0; n < nfds; ++n)
- {
- if(events[n].data.fd == listener)
- {
- client = accept(listener, (struct sockaddr *) &local, &addrlen);
- if(client < 0){
- perror("accept");
- continue;
- }
- setnonblocking(client);
- ev.events = EPOLLIN | EPOLLET;
- ev.data.fd = client;
- if (epoll_ctl(kdpfd, EPOLL_CTL_ADD, client, &ev) < 0)
- {
- fprintf(stderr, "epoll set insertion error: fd=%d0, client);
- return -1;
- }
- }else{
- do_use_fd(events[n].data.fd);
- }
- }
- }
信号驱动异步IO详解
- 1.注册SIGIO信号处理程序。(安装信号处理器)
- 2.使用fcntl的F_SETOWN命令,设置套接字所有者。
- 3.使用fcntl的F_SETFL命令,置O_ASYNC和O_NONBLOCK标志,允许套接字信号驱动I/O。
注意,必须保证在设置套接字所有者之前,向系统注册信号处理程序,否则就有可能在fcntl调用后,信号处理程序注册前内核向应用交付SIGIO信号,导致应用丢失此信号。
- struct sigaction sigio_action;
- memset(&sigio_action, 0, sizeof(sigio_action));
- sigio_action.sa_flags = 0;
- sigio_action.sa_handler = do_sigio;//信号发生时的处理函数
- sigaction(SIGIO, &sigio_action, NULL);
- fcntl(listenfd1, F_SETOWN, getpid());
- int flags;
- flags = fcntl(listenfd1, F_GETFL, 0);
- flags |= O_ASYNC | O_NONBLOCK;
- fcntl(listenfd1, F_SETFL, flags);