一、IO的类型
IO主要包括缓冲IO,直接IO,内存映射IO,零拷贝等几种类型,现对各种IO的概念进行介绍和梳理。
1.1缓冲IO
缓冲IO的原理图如图1-1所示,其中一个读/写操作会发生3次数据拷贝:
读:磁盘→ 内核缓冲区→ 用户缓冲区 → 应用程序内存;
写:应用程序内存→ 用户缓冲区 → 内核缓冲区 → 磁盘。
在磁盘与内核缓冲区之间进行数据拷贝时,不需要CPU的参与,用的时DMA拷贝技术。DMA译为Direct Memory Access(直接内存访问),它的实现是通过硬件上的芯片,可看作是微CPU来计算需要获取数据的地址、需要拷入的地址以及读写的字节数等等,从而释放CPU让它进行别的工作。但是内存之间的数据交换需要CPU的参与。Linux系统磁盘以扇区(Sector)为单位,一扇区为512K,操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个"块"(block),而磁盘上的数据以页(Page)为单位缓存在操作系统内核中,一页为4K(即一页=一块=8个扇区)。
图1-1缓冲IO
1.2直接IO
直接IO的原理图如图1-2所示,指的是没有用户级的缓冲,因此其中一个读写操作会发生2次数据拷贝:
读:磁盘→ 内核缓冲区→ 应用程序内存;
写:应用程序内存→ 内核缓冲区→ 磁盘。
图1-2直接IO
1.3内存映射文件
内存映射文件指的是,用户空间没有实际数据的物理内存,而是直接将应用程序逻辑内存地址映射到内核缓冲区,因此表面上应用程序读写的是自己的内存,但是这个内存只是一个"逻辑地址",实际读写的是内核缓冲区。因此一个读/写操作只会发生1次数据拷贝:
读:磁盘→ 内核缓冲区;
写:内核缓冲区→ 磁盘。
内存映射文件原理图如图1-3所示,它进一步优化了直接IO。
图1-3内存映射文件
1.4零拷贝
零拷贝技术指的是网络中的一种数据交换技术,如果本机用户程序不需要修改和使用任何信息时,就可以将数据原原本本的从本机的内核缓冲区拷贝到它机的内核缓冲区,从而减少拷贝次数,如图1-4所示。
图1-4零拷贝
其中从内核缓冲区到Socket缓冲区数据传递,若采用拷贝,则有3次拷贝过程;如果只做一个地址的映射,底层的网卡驱动程序在读取数据并发送到网络时,看似读的是Socket缓冲区数据,实际直接读的是内核缓冲区的数据,因此只需要两次拷贝(一次是磁盘到内核缓冲区,一次是内核缓冲区到网络)。之所以叫做零拷贝,是因为从内存的角度来看,数据在内存中没有发生过拷贝,只在内存与IO之间传输。
二、网络IO模型
对阻塞、非阻塞、异步、同步、IO多路复用等的概念与原理进行梳理。这些概念相互交叉比较容易混,常用的网络IO模型如表2-1所示。
表2-1网络IO模型
分类 |
IO模型 |
具体实现 |
同步IO |
同步阻塞IO |
阻塞式的read和write调用 |
同步非阻塞IO |
以O_NONBLOCK参数打开fd,并执行read和函数write调用 |
|
IO的多路复用(同步阻塞) |
Linux系统下IO多路复用的实现方式:
|
|
异步IO |
异步非阻塞IO |
|
注意:没有异步阻塞IO这种模型
2.1同步阻塞IO
这种就是调用Linux下的read和write函数,如果没有准备好数据,在调用时会被阻塞,直至数据准备好,才唤醒进程进行处理。如图2-1所示。
图2-1同步阻塞IO
2.2同步非阻塞IO
和同步IO的API相同,只是打开fd时带有O_NONBLOCK参数。于是在调用read和write函数时,如果数据没有准备好,会立刻返回,不会阻塞,然后让应用程序不断去轮询,直至内核中数据准备好,在向用户空间进行拷贝时才会被阻塞,拷贝完成被唤醒进行数据处理,如图2-2所示。
图2-2非阻塞IO
频繁的轮询会白白消耗的CPU资源,而且会造成大量的上下文切换,(进程在从内核到用户空间的复制过程中,也会造成阻塞?),性能提升方面以后在做实验吧。
2.3IO的多路复用
对于现在的服务器程序来说,需要处理很多的fd(连接数可达几十万到百万),而read,write等只阻塞在一个fd上,IO的多路复用是指调用Select,poll,epoll等函数代替read,write,accept等函数,它们可以阻塞在多个fd上。因此IO的多路复用也是阻塞的,它的原理图如图2-3所示。
图2-4IO的多路复用
所有的fd都被阻塞在Select函数上,Select可以设置等待超时时间(如果设置时间未0,则select不阻塞,会立即返回并且轮询检查是否就绪),等时间一到,Select会返回就绪描述符的个数,用户需要扫描描述符集来处理IO事件数。因此当扫描fd集合很大时,遍历一遍的耗时会很长,因此有一个FD_SETSIZE宏限制。
select函数形式为如下,可以监控自己关心的描述符集,具体分为读、写、出错三种描述符集。
int select (int maxfdp1,fd_set *readfds,fd_set *writefds,fd_set *expectfds, struct timeval *timeout)
总结一下select函数的特点:
1)fd_set集合不能重用,因此在select调用返回之后,在下一次调用之前,必须重新创建整个集合或通过FD_COPY从备份副本中恢复整个集合;
2)每次返回需要遍历整个集合,若有有1000个描述符时,只有其中一个处于就绪状态(可能是最后一个),则每次等待都在浪费CPU周期;
3)fd_set有数目限制,维护的最大连接数1024。
而poll函数与select类似,如下所示,但是它的pollfd非bit数组结构,而是链表结构,因此没有文件描述符数目的限制。
int poll(struct pollfd *fds,unsigned int nfds,int timeout); struct pollfd { int fd; short events; //每个fd,两个bit数组,一个进去,一个出来 short revents; //events进去时都设置为0,revents设置为1,若是有事件发生,将revents设置为0,events设置为1,扫描不会再扫描events为1的fd? }
除此之外,poll函数还有几个优势:
1)pollfd集合可重用, 因为它在调用过程中不会修改pollfd集合中传递的数据。 因此,只要将那些生成事件的描述符的revents成员设置为零,就可重用它;
2)与select相比,它允许对事件进行更细粒度的控制。
但是select和poll函数每次调用都需要应用程序将fd的数组传进去,这个数组每次都要在用户态和内核态之间传递,影响效率。
linux系统中的epoll可以解决这个问题。epoll的用法,首先epoll_creat创建一个epoll对象epfd,然后通过epoll_ctl将需要监听的Socket添加到epfd中,最后通过调用epoll_wait等待数据。
int epfd = epoll_creat(int size); //size用来告诉内核监听的数目由多少, //只是计划值,实际值可以多 int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);//将所有监听的Socket添 //加到epfd中 while(1){ int n = epoll_wait(int epfd,struct epoll_event *event,int naxevents,int timeout); for(接收到数据的Socket){ //处理数据 } }
由于epfd对象维护着一个就绪列表rdlist,数据就绪后socket,就会被添加到rdlist,当进程被唤醒后,只要获取rdlist的内容,就能知道那些socket收到数据。
可见eventpoll对象相当于socket和进程之间的中介,socket数据的接收并不直接影响进程,而是通过改变eventpoll的就绪列表来改变进程的状态。
2.4异步IO
异步IO 主要在将数据从内核空间拷贝到用户控制过程中,进程是非阻塞的,但是由于在此过程中是需要CPU参与的,因此会出现进程与异步调用函数争用CPU的情况,连接数越多,情况越严重,异步函数返回成功信号的速度就越慢。如果不能处理这个问题,异步IO也不一定好。
异步IO的原理图如图2-5所示,进程首先会发送一个异步调用,并立刻返回,这个异步调用将告诉内核,不仅要准备好数据,而且要将数据拷贝到用户空间,拷贝完成后将发送一个信号通知进程进行处理。
图2-5异步IO
2.5阻塞与非阻塞,同步与异步的差异
阻塞与非阻塞指的是第一步从硬盘到内核的数据拷贝过程,若是内核未满,用户进程一直等待为阻塞,用户进程轮询到内核就绪为止为非阻塞;
同步与异步指的是第二步从内核到用户的数据拷贝阶段,应用程序通过read/write函数完成拷贝过程为同步,此过程由操作系统完成后回调或事件通知应用程序为异步。
读写由应用程序完场为同步,读写由操作系统完成,完成之后,回调或事件通知应用程序为异步。
参考文献
1.https://mp.weixin.qq.com/s/Wk-0A_AyFMYMzYc5No5zyA