赞
踩
文章很长,但是很用心!
在计算机操作系统中,所谓的I/O就是输入(Input)和输出(Output),也可以理解为读(Read)和写(Write),针对不同的对象,I/O模式可以划分为磁盘IO模型和网络IO模型。
IO操作会涉及到用户空间和内核空间的转换,先来理解以下规则:
再来看看所谓的读(Read)和写(Write)操作:
磁盘IO的流程如下图所示:
(1)读操作
当应用程序调用read()方法时,操作系统检查内核缓冲区中是否存在需要的数据,如果存在,那么就直接把内核空间的数据copy到用户空间,供用户的应用程序使用。如果内核缓冲区没有需要的数据,通过通过DMA方式(一种IO设备控制方式,下面会讲解)从磁盘中读取数据到内核缓冲区,然后由CPU控制,把内核空间的数据copy到用户空间。
这个过程会涉及到两次缓冲区copy,第一次是从磁盘的缓冲区到内核缓冲区,第二次是从内核缓冲区到用户缓冲区(或应用缓冲区),第一次是cpu的copy,第二次是DMA的copy。
(2)写操作
当应用程序调用write()方法时,应用程序将数据从用户空间copy到内核空间的缓冲区中(如果用户空间没有相应的数据,则需要从磁盘—>内核缓冲区—>用户缓冲区依次读取),这时对用户程序来说写操作就已经完成,至于什么时候把数据再写到磁盘(从内核缓冲区到磁盘的写操作也由DMA控制,不需要cpu参与),由操作系统决定。除非应用程序显示地调用了sync命令,立即把数据写入磁盘。
如果应用程序没准备好写的数据,则必须先从磁盘读取数据才能执行写操作,这时会涉及到四次缓冲区的copy,第一次是从磁盘的缓冲区到内核缓冲区,第二次是从内核缓冲区到用户缓冲区,第三次是从用户缓冲区到内核缓冲区,第四次是从内核缓冲区写回到磁盘。前两次是为了读,后两次是为了写。这其中有两次cpu拷贝,两次DMA copy。
(3)磁盘IO的延时
为了读或写,磁头必须能移动到所指定的磁道上,并等待所指定的扇区的开始位置旋转到磁头下,然后再开始读或写数据。磁盘IO的延时分成以下三部分:
网络IO的流程如下:
(1)读操作
网络IO的既可以从物理磁盘中读数据,也可以从socket中读数据(从网卡中获取)。当从物理磁盘中读数据的时候,其流程和磁盘IO的读操作一样。当从socket中读数据,应用程序需要等待客户端发送数据,如果客户端还没有发送数据,对应的应用程序将会被阻塞,直到客户端发送了数据,该应用程序才会被唤醒,从Socket协议找中读取客户端发送的数据到内核空间(这个过程也由DMA控制),然后把内核空间的数据copy到用户空间,供应用程序使用。
(2)写操作
为了简化描述,我们假设网络IO的数据从磁盘中获取,读写操作的流程如下:
网络IO的写操作也有四次缓冲区的copy,第一次是从磁盘缓冲区到内核缓冲区(由cpu控制),第二次是内核缓冲区到用户缓冲区(DMA控制),第三次是用户缓冲区到内核缓冲区的Socket Buffer(由cpu控制),第四次是从内核缓冲区的Socket Buffer到网卡设备(由DMA控制)。四次缓冲区的copy工作两次由cpu控制,两次由DMA控制。
(3)网络IO的延时
网络IO主要延时是由:服务器响应延时+带宽限制+网络延时+跳转路由延时+本地接收延时 决定。一般为几十到几千毫秒,受环境影响较大。所以,一般来说,网络IO延时要大于磁盘IO延时。
以前传统的IO读写是通过中断由cpu控制的,为了减少CPU对I/O的干预,引入了直接存储器访问方式(DMA)方式。在DMA方式下,数据的传送是在DMA的控制下完成的,不需要cpu干预,所以CPU和I/O设备可以并行工作,提高了效率。现在来看看它们各自的原理:
(1)IO中断原理
中断IO缺点:每次IO请求都需要CPU多次参与。
(2)DMA原理
跟IO中断模式相比,DMA模式下,DMA就是CPU的一个代理,它负责了一部分的拷贝工作,从而减轻了CPU的负担。
需要注意的是,DMA承担的工作是从磁盘的缓冲区到内核缓冲区或网卡设备到内核的soket buffer的拷贝工作,以及内核缓冲区到磁盘缓冲区或内核的soket buffer到网卡设备的拷贝工作,而内核缓冲区到用户缓冲区之间的拷贝工作仍然由CPU负责。
在上述IO中,读写操作要经过四次缓冲区的拷贝,并经历了四次内核态和用户态的切换。 零拷贝(zero copy)IO技术减少不必要的内核缓冲区跟用户缓冲区之间的拷贝,从而减少CPU的开销和状态切换带来的开销,达到性能的提升。
在zero copy下,如果从磁盘中读取文件然后通过网络发送出去,只需要拷贝三次,只发生两次内核态和用户态的切换。
下图是不使用zero copy的网络IO传输过程:
零拷贝的传输过程:硬盘 >> kernel buffer (快速拷贝到kernel socket buffer) >>Socket协议栈(网卡设备中)
这里,只经历了三次缓冲区的拷贝,第一次是从磁盘缓冲区到内核缓冲区,第二次是从内核缓冲区到kernel socket buffer,第三次是从kernel socket buffe到Socket协议栈(网卡设备中)。只发生两次内核态和用户态的切换,第一次是当应用程序调用read()方法时,用户态切换到内核到执行read系统调用,第二次是将数据从网络中发送出去后系统调用返回,从内核态切换到用户态。
零拷贝(zero copy)的应用:
注意:零拷贝要求输入的fd必须是文件句柄,不能是socket,输出的fd必须是socket,也就是说,数据的来源必须是从本地的磁盘,而不能是从网络中,如果数据来源于socket,就不能使用零拷贝功能了。我们看一下sendfile接口就知道了:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
in_fd必须指向真实的文件,不能是socket和管道;而out_fd则必须是一个socket。由此可见,sendfile几乎是专门为在网络上传输文件而设计的。
在Linxu系统中,一切皆文件,因此socket也是一个文件,也有文件句柄(或文件描述符)。
现在,我们就来讲解BIO、NIO、IO多路复用、AIO,在这之前,我必须强调,这些IO大多用于网络IO,并且这里主要介绍用户程序从网络中获取数据那一部分。一方面是为了方便描述,另一方式,更能体现出这些IO的区别。
网络IO从Socket获取数据的步骤:
BIO、NIO、AIO的主要区别在于:
如果用户进程在步骤1执行后的状态是阻塞的,且步骤3过程中,进程也是阻塞的,那么是BIO(同步阻塞IO)。
如果用户进程在步骤1执行后的状态是非阻塞的,且步骤3过程中,进程是阻塞的,那么是NIO(同步非阻塞IO)。
如果用户进程在步骤1执行后的状态是非阻塞的,且步骤3过程中,进程也是非阻塞的,也就是说真正读(或写)时,进程的状态是非阻塞的,那么是AIO(异步IO)。
至于多路复用IO和BIO、NIO、AIO的区别,后面会细细讲解。
那么,我们就开始吧!
BIO (Blocking I/O),称之为同步阻塞I/O,其IO模型传输如下图所示:
上图红色表示进程处理阻塞状态,绿色表示进程处于非阻塞状态
我相信BIO模型的传输过程上图已经描述很清楚了,可以看到,BIO模型的用户进程在执行系统调用后,一直处于阻塞状态,等待内核数据到位后,进程继续阻塞,直到内核数据拷贝到用户空间。
该模式下,一个线程只能处理一个Socket IO连接,高并发时,服务端会启用大量的线程来处理多个Socket,由于是阻塞IO,会导致大量线程处于阻塞状态,导致cpu资源浪费,且大量线程会导致大量的上下文切换,造成过多的开销。
当前绝大操作系统都支持多线程,当操作系统引入多线程之后,进程的执行实际就是进程中的多个线程在执行,同一时刻,cpu只能执行一个线程,多个线程通过轮询的方式交替执行。
这时你可能会有疑问,用户进程都被阻塞(或挂起)了,在内核态还怎么操作呢?事实上,read和write都是内核级的操作,只要用户进程调用相应的系统调用接口后,内核进程(或线程)在真正执行读和写操作硬件时,与用户进程就没什么关系了。
NIO (Non-blocking IO),称之为非阻塞IO,其传输过程如下:
在NIO模式下,当用户进程执行系统调用后,如果当前数据还没有准备好,则会立即返回(NIO的非阻塞就提现在这里),然后再次进行系统调用,不断测试数据是否准备好。如果数据准备好了,当前进程会进入阻塞转态,直到数据从内核空间拷贝到用户空间,进程才会被唤醒,就可以处理数据了。
NIO模式下,一个线程就可以处理多个Socket连接,没必要开启多线程进行处理(如果多个NIO,会有多个线程一起执行多次系统调用,结果会很可怕)。但是,当有1000个Socket连接时,用户进程会以轮询的方式执行1000次系统调用判断数据有没有准备好,即会发生1000次用户态到内核态的切换,成本几何上升。即使当前只有一个Socket连接,也会重复进行系统调用,因为此时的用户进程不仅要接收新的Socket连接并把它拷贝到内核,还要判断已有的Socket连接是否准备好数据,这都会有系统调用,极大的浪费cpu资源。
IO多路复用的传输过程如下:
由于NIO会多次执行系统调用进行测试,大大浪费系统的资源,而多路复用IO把轮询多个Socket文件句柄的事情放在内核空间里执行,即让内核负责轮询所有socket(这样就不会有用户态和系统态的切换),当某个或几个socket有数据到达了,返回所有就绪的Socket文件句柄给用户进程,然后用户进程执行read系统调用接口,并进入阻塞状态。内核进程(或线程)把数据从内核空间拷贝到用户空间,用户进程读取到数据就可以进行处理了。
多路复用IO在执行系统调用后,进程就处于阻塞状态,所以多路复用IO本质上也是同步阻塞IO,只不过它是在内核态轮询所有socket,大大提高了IO的处理速度,也减少了系统状态切换的开销。此外,它与同步阻塞的BIO不同,多路复用IO可以使用一个线程同时处理多个Socket的IO请求,这是BIO做不到的。而在BIO中,必须通过多线程的方式才能达到这个目的。
另外,大家可以思考一下,为什么用户进程从网络中获取数据的第一步就要执行系统调用,我举一个例子来说明。
假如一个服务端上的用户进程要读取客户端发来的数据,此时用户进程在用户态,当进程执行了accept()方法获取客户端的链接,此时就得到了客户端Socket的文件句柄(或文件描述符),但是该用户进程并不知道该Socket的文件句柄是否就绪(即是否可读),这就要执行系统调用进入内核态,并把当前网络连接的Socket文件句柄(或文件描述符)复制到内核态。为什么要进入内核态呢?因为数据是从Socket协议栈(或网卡设备)发过来的,要操作硬件设备才能读取数据,所以必须在内核态下判断客户端的Socket是否发来消息。进入内核态以后,内核进程会判断该Socket是否可读(即是否准备好数据),如果准备好了数据,就把数据从Socket协议栈(或网卡设备)拷贝到内核缓冲区,再把内核缓冲区的数据拷贝到用户缓冲区。所以只要有一个客户端的Socket连接到来,就会进入一次系统调用判断Socket的文件句柄是否就绪。这里可能不好理解,但对下面多路复用模式的理解很有用处。
多路复用模式包含三种,即select、poll和epoll,这几种模式主要区别在于获取可读Socket文件句柄的方式。
为了更清楚的进行说明,这里给出selet函数的定义:
int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。**调用后select函数后用户进程会阻塞,直到有文件描述符就绪(有数据可读、可写、或者有exception)**或者超时(timeout指定等待时间),函数返回。当select函数返回后,可以通过遍历fd_set(文件描述符的集合),来找到就绪的描述符。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024(可以修改)。
select机制的详细过程如下:
使用select以后最大的优势是用户可以在一个线程内同时处理多个socket的IO请求。当有多个Socket连接时,把它们放入select创建的3个文件描述符集中的一个即可,然后由内核分别轮询这3个文件描述符集,即可达到在同一个线程内同时处理多个IO请求的目的。
在select机制中,为了减少数据拷贝带来的性能损坏,内核对被监控的fd_set
集合大小做了限制(默认最大为1024),且想要做到多次监控或者有未就绪的文件句柄,select机制会不断地将文件描述符集从用户态向内核态进行拷贝,会大大浪费系统的资源。此外,select机制只能知道有socket就绪(有数据可读或可写、或者有exception),无法知道具体是哪一个socket接收到了数据,所以需要用户进程进行遍历,才能知道具体是哪个socket接收到了数据。
不同于select使用3个文件描述符集合,poll使用一个pollfd结构体的指针实现。
# poll函数定义
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
# pollfd指针的结构体
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events to watch */
short revents; /* returned events witnessed */
};
poll函数参数说明:
struct pollfd *fds
:fds
是一个struct pollfd
类型的数组,用于存放需要检测其状态的socket文件描述符,并且调用poll函数之后fds
数组不会被清空,这一点与select()函数不同,调用select()函数之后,select() 函数会清空它所检测的socket描述符集合,导致每次调用select()之前都必须把socket描述符重新加入到待检测的集合中。一个pollfd
结构体表示一个被监视的Socket文件描述符,通过给poll函数传递pollfd
数组来监视多个socket文件描述符。nfds
:是监控的socket文件句柄数量。timeout
是等待的毫秒数,这段时间内无论IO是否准备好,poll都会返回。timeout为负数表示无线等待,timeout为0表示调用后立即返回。pollfd
的revents
不为0的文件描述符个数。接下来,我们就介绍一下,结构体pollfd
的events域和revents域。events
域是监视该文件描述符的事件,由用户来设置这个域,revents
域是文件描述符的操作结果事件,由内核在调用返回时设置这个域。events域中请求的任何事件都可能在revents域中返回,events域合法的事件如下:
POLLIN
:有数据可读。POLLRDNORM
:有普通数据可读。POLLRDBAND
:有优先数据可读。POLLPRI
:有紧迫数据可读。POLLOUT
:写数据不会导致阻塞。POLLWRNORM
:写普通数据不会导致阻塞。POLLWRBAND
:写优先数据不会导致阻塞。POLLMSG、SIGPOLL
:消息可用。此外,revents域中还可能返回下列事件,这些事件在events域中无意义,因为它们在合适的时候总是会从revents中返回。
POLLER
:指定的文件描述符发生错误。POLLHUP
:指定的文件描述符挂起事件。POLLNVAL
:指定的文件描述符非法。举一个例子,events=POLLIN | POLLPRI
等价于select()的读事件,events=POLLOUT | POLLWRBAND
等价于select()的写事件。此外,POLLIN
等价于POLLRDNORM |POLLRDBAND
,而POLLOUT
则等价于POLLWRNORM
。
当我们要同时监视一个文件描述符是否可读和可写,可以设置 events=POLLIN |POLLOUT
(由用户设置)。在poll返回时,可以检查revents中的标志(由内核在调用返回时设置)。如果revents=POLLIN
,表示Socket文件描述符可以被读取而不阻塞。如果revents=POLLOUT
,表示Socket文件描述符可以写入而不导致阻塞。注意,POLLIN
和POLLOUT
并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。
现在,我们来总结一下poll机制的过程:
可以看到,poll的实现和select非常相似,只是文件描述符集合不同,poll使用pollfd结构体,select使用的是fd_set结构,其他的都差不多,管理多个文件描述符也都是采用轮询的方式,然后根据文件描述符的状态进行处理。由于poll使用的链表,故没有最大文件描述符数量的限制,而select监控文件描述符的最大默认数量为1024。
poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的内存空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的文件描述符数量的增长,其效率也会线性下降。
poll还有一个特点是“水平触发”,如果报告了某个就绪的Socket文件描述符后,没有被处理,那么下次poll时会再次报告该Socket fd。
epoll是在Linux2.6内核中提出的,是之前的select和poll的增强版本,先来看下epoll系统调用的三个函数
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
解释:
int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,这里的size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议,也就是说,size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。当创建好epoll句柄后,它就会占用一个fd值,所以在使用完epoll后,必须调用close()关闭,否则可能导致该进程的fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
该函数是对上面建立的epoll文件句柄执行op操作。例如,将刚建立的socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll不再监控它等等。
epfd
:是epoll的文件句柄。
op
:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD
,删除EPOLL_CTL_DEL
,修改EPOLL_CTL_MOD
,分别表示添加、删除和修改对fd的监听事件。
fd
:是需要监听的fd(文件描述符)
epoll_event
:告诉内核需要监听什么事件,struct epoll_event
结构如下:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时(即有Socket就绪时),就返回用户态的进程。
epfd
:是epoll的文件句柄。events
:从内核得到事件的集合maxevents
:events的大小timeout
:超时时间,timeout为负数表示无线等待,timeout为0表示调用后立即返回。select机制的详细过程如下:
从上面的调用方式就可以看到epoll比select/poll的优越之处:
epoll的工作模式
epoll对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
另外,还需要补充一点,epoll机制下,用户进程在执行epoll_create进入系统调用之后,并没有进入阻塞状态,因为它还要执行后面的epoll_ctl和epoll_wait方法来监控多个Socket连接,这点与select和poll不同,用户进程在第一次执行系统调用后就进入阻塞状态,等待就绪的Socket来唤醒它。但是,epoll机制下,用户进程在真正执行read或write系统调用接口时,还是会进入阻塞状态。所以这方面来讲,epoll是同步非阻塞IO,select和poll是同步阻塞IO(个人见解)。
AIO ( Asynchronous I/O):异步非阻塞I/O模型。传输过程如下:
可以看到,异步非阻塞I/O在判断数据有没有准备好(即Socket是否就绪)和真正读数据两个阶段都是非阻塞的。AIO在第一次执行系统调用后,会注册一个回调函数,内核在检测到某Socket文件句柄就绪,调用该回调函数执行真正的读操作,将数据从内核空间拷贝到用户空间,然后返回给用户使用。在整个过程,用户进程都是非阻塞状态,可以做其它的事情。
没有Linux系统采用AIO模型,只有windows的IOCP是此模型。
IO可以分为两个阶段,第一阶段,判断有没有事件发生(或判断数据有没有准备好,或判断Socket是否就绪),第二阶段,在数据准备好以后,执行真正的读(或写)操作,将数据从内核空间拷贝到用户空间。
这几个阶段:
另外,不得不提的是,上述的阻塞和非阻塞指的是IO模型,用户进程获取数据后执行业务逻辑的时候,也分异步和同步。比如,进程执行一段很复杂的业务逻辑,需要很长的时间才能返回,也可以注册一个回调函数,等待此段代码执行完毕后,就通知用户进程。例如,nginx在Linux2.6以后的内核中用的IO模型是epoll,即同步IO,而Nginx的worker进程的处理请求的时候是异步的。
业务逻辑的同步和异步概念如下:
【参考文档】
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。