IO总结

作者: 简书徐小耳 | 来源:发表于2019-03-09 23:11 被阅读0次

    1.目前很多博客说的五种模型都是从read角度来描述的。

    2.我们也常会说Direct IO,或者其他文件IO。他们主要是从write角度,即是否经过pagecache来区分。

    3.引用一个网友的交流--人可以分为男人和女人,也可以分为好人和坏人。

    java io和操作系统io的区别

    • Java中的BIO、NIO和AIO理解为是Java语言对操作系统的各种IO模型的封装

    理解底层的send(sendto)和recv(recvFrom)方法

    • recv和send函数提供了和read和write差不多的功能.不过它们提供了第四个参数来控制读写操作.

    int send( SOCKET s, const char FAR *buf, int len, int flags );

    • 该函数的第一个参数指定发送端套接字描述符;
    • 第二个参数指明一个存放应用程序要发送数据的缓冲区;
    • 第三个参数指明实际要发送的数据的字节数;
    • 第四个参数一般置0 则代表和write一样。第四个参数可以是以下的情况

    | MSG_DONTROUTE | 不查找表 | 是send函数使用的标志.这个标志告诉IP.目的主机在本地网络上面,没有必要查找表.这个标志一般用网络诊断和路由程序里面.
    | MSG_OOB | 接受或者发送带外数据 |表示可以接收和发送带外的数据.关于带外数据我们以后会解释的.
    | MSG_PEEK | 查看数据,并不从系统缓冲区移走数据 |是recv函数的使用标志,表示只是从系统缓冲区中读取内容,而不清除系统缓冲区的内容.这样下次读的时候,仍然是一样的内容.一般在有多个进程读写数据时可以使用这个标志.
    | MSG_WAITALL | 等待所有数据 |L是recv函数的使用标志,表示等到所有的信息到达时才返回.使用这个标志的时候recv回一直阻塞,直到指定的条件满足,或者是发生了错误. 1)当读到了指定的字节时,函数正常返回.返回值等于len 2)当读到了文件的结尾时,函数正常返回.返回值小于len 3)当操作发生错误时,返回-1,且设置错误为相应的错误号(errno)

    同步socket的send方法

    • send发送的时候需要比较发送字节长度len是否大于socket指定的发送缓冲(这边的缓冲不是buf,相当于内核的缓冲)长度,大于就返回error。
    • 如果小于或者等于则再进一步检查当前缓冲区是否还有待发送的,然后len和缓冲区剩余空间比较。
    • 如果大于剩余空间,则当前send方法阻塞直到剩余空间足够存放len,如果小于剩余空间则直接将待发送的数据存放如缓冲区然后返回。
    • 此时返回的时候不代表数据已经发送到对端了。
    • 在等待缓冲区剩余数据发送完过程,或者在数据copy到缓冲区,或者后续把缓冲区数据发送到对端过程中出现异常,都会返回异常。
    • 不同之处在于前两个是同步返回异常,最后一个如果出现异常,则我们下一个socket调用的时候就会返回异常,因为我们每次通过
      一个socket发送数据,socket都要等上一次数据发送完成,如果上一个发生异常则当前的数据也是无法发送的。

    int recv( SOCKET s, char FAR *buf, int len, int flags );

    • 该函数的第一个参数指定接收端套接字描述符;

    • 第二个参数指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据;

    • 第三个参数指明buf的长度;

    • 第四个参数一般置0 代表和read操作一样。

    同步socket的recv方法

    • 首先等待socket中的发送缓冲数据被协议发送完成
    • 然后在等待接受缓冲中的数据,如果有数据再把缓冲数据拷贝buf中

    我们要做的就是 将数据在用户态(即上述方法中参数buf)和内核状态(socket内部的buffer或者页缓存)进行复制和拷贝

    IO分类(一)

    阻塞IO模型

    • 调用系统操作的recvfrom 时候,如果内核的(只有socket,因为pageCache属于磁盘IO 不会block)buffer没有数据则会阻塞。
    • 对应的内核缓冲区如果有数据了,则也需要阻塞等待数据拷贝到我们用户态的buffer

    非阻塞IO模型

    • 调用系统操作的时候检测内核(socket和pagecache)的缓冲区是否有数据,如果有则阻塞复制数据到用户空间的缓冲
    • 如果没有则直接返回(返回的是异常)

    IO复用模型

    • 多个进程的IO可以注册到同一个管道上,这个管道会统一和内核进行交互。当管道中的某一个请求需要的数据准备好之后,进程再把对应的数据拷贝到用户空间中。
    • 即先通过selector 轮询,如果一个io事件都没有准备好,则selector会阻塞。这个阻塞不仅仅是io的阻塞,如果我们select注册的是bio阻塞包含io本身和selector.
    • 如果注册的是nio,则阻塞本身只包含selector。注意这边的selector可以设置阻塞一段时间,不阻塞(所谓的不阻塞只是阻塞时间为0而已),一直阻塞到io事件准备好
    • 这里的IO复用模型,并没有向内核注册信号处理函数,所以他并不是非阻塞的。
    Selector(多路复用器的实现方式)

    select

    • int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

    • select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds

    • 当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符

    • 只能监听1024个链接

    • 并且没有返回具体可使用的 socket ,得挨个遍历

    • 线程不安全

    • 每次调用select都需要把所有socket从用户态传递到内核态

    • select返回后要挨个遍历fd,找到被“SET”的那些进行处理。这样比较低效。

    • select是无状态的,即每次调用select,内核都要重新检查所有被注册的fd的状态。

    poll

    • int poll (struct pollfd *fds, unsigned int nfds, int timeout);
    • 不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。pollfd并没有最大数量限制(但是数量过大后性能也是会下降)
    • 只解决了select的链接限制的问题

    epoll

    • 调用epoll_create创建实例
    • 调用epoll_ctl添加或删除监控的文件描述符(一个文件描述符只需要创建一次放入内核即可,不用每次都需要从用户态切换到内核态)
    • 调用epoll_wait阻塞住,直到有就绪的文件描述符(在netty中也需要阻塞,防止selector频繁运行导致cpu空转 进而cpu100%)。
    • 通过epoll_event参数返回就绪状态的文件描述符和事件(这边依赖mmap,内核将准备好的就绪文件描述符和事件放入mmap,用户进程可以去mmap直接取,所以不需要进行内核和用户空间的切换)。
    • 解决了上述两者的问题,且指定了可用socket的回调。
    • 增加mmap,进而不需要再内核和用户态频繁的切换
    epoll 具体的过程如下
    • int epoll_create(int size):创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大 生成一个 epoll 专用的文件描述符,其实是申请一个内核空间(mmap),用来存放想关注的 socket fd 上是否发生以及发生了什么事件。
    • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):控制某个 epoll 文件描述符上的事件:注册、修改、删除。其中参数 epfd 是 epoll_create() 创建 epoll 专用的文件描述符。
    • int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout):
    • 参数如下:

    epfd: 由 epoll_create() 生成的 Epoll 专用的文件描述符;
    epoll_event: 用于回传代处理事件的数组;
    maxevents: 每次能处理的事件数;
    timeout: 等待 I/O 事件发生的超时值

    • 即epoll_wait回去epoll_create时候创建的mmap中查看是否有就绪事件要处理
    水平触发和边沿触发
    • 默认情况下,epoll使用水平触发,这与select和poll的行为完全一致。在水平触发下,epoll顶多算是一个“跑得更快的poll”。

    DEMO

    有两个socket的fd——fd1和fd2。我们设定监听f1的“水平触发读事件“,监听fd2的”边沿触发读事件“。我们使用在时刻t1,使用epoll_wait监听他们的事件。
    在时刻t2时,两个fd都到了100bytes数据,于是在时刻t3, epoll_wait返回了两个fd进行处理。在t4,我们故意不读取所有的数据出来,只各自读50bytes。
    然后在t5重新注册两个事件并监听。在t6时,只有fd1会返回,因为fd1里的数据没有读完,仍然处于“被触发”状态;而fd2不会被返回,因为没有新数据到达。

    水平触发

    • 水平触发只关心文件描述符中是否还有没完成处理的数据,如果有,不管怎样epoll_wait,总是会被返回。简单说——水平触发代表了一种“状态”

    边沿触发

    • 边沿触发只关心文件描述符是否有新的事件产生,如果有,则返回;如果返回过一次,不管程序是否处理了,只要没有新的事件产生,epoll_wait不会再认为这个fd被“触发”了。简单说——边沿触发代表了一个“事件”

    边沿触发的优缺点

    • 优点:边沿触发把如何处理数据的控制权完全交给了开发者,提供了巨大的灵活性。比如,读取一个http的请求,开发者可以决定只读取http中的headers数据就停下来,然后根据业务逻辑判断是否要继续读
    • 缺点:一不留神,可能就会miss掉处理部分socket数据的机会

    区别总结

    (1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
    (2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,epoll 通过 mmap 把内核空间和用户空间映射到同一块内存,省去了拷贝的操作。

    信号驱动IO模型

    • 应用进程预先向内核注册一个信号处理函数,然后用户进程返回,并且不阻塞,当内核数据准备就绪时会发送一个信号给进程,用户进程便在信号处理函数中开始把数据拷贝的用户空间中。
    • 重点在于内核发出信号,让用户进程调用函数进行处理。
    • 信号驱动不常用的原因是因为信号是数字,而且是全局有效的。如果其他的lib也使用了相同数字则会干扰该IO

    异步IO模型。

    • 无论以上那种模型,真正的数据拷贝过程,都是同步进行的。
    • 所以异步IO和信号驱动IO的区别在于是数据拷贝完成之后再通知用户进程(线程)
    • 用户进程发起aio_read操作之后,给内核传递描述符、缓冲区指针、缓冲区大小等,告诉内核当整个操作完成时,如何通知进程,然后就立刻去做其他事情了。
    • 当内核收到aio_read后,会立刻返回,然后内核开始等待数据准备,数据准备好以后,内核直接把数据拷贝到用户空间,然后再通知进程本次IO已经完成。

    IO分类(二)

    • 上述把io分为五种类型,对应于我们java中其实就是bio,nio,aio。个人对于selector理解其不属于io的一种,只是一种更好管理io的方式。
    • 上述在分析io模型的时候把输入和输出都抽象化了,如果具体到现实中则大抵可以分为如下两类:磁盘io和网络io
    • 这里的分类主要讲解与BIO,NIO,AIO和磁盘IO以及网络IO之间的联系。
    • IO Blcok只有在对端是socket的情况下才有block,因为这个时候会去检测socket buffer 如果没有数据只能等待。
    • 对于磁盘的read,linux总认为不是BLock,就算中间有磁盘抖动等原因都不认为是block。
    • 基于上述原因虽然nio和io多路复用器对于标准输入输出描述符、管道和FIFO也都是有效的。但是像磁盘IO这种效果不大,所以一般这两种都是用于讨论网络IO。

    磁盘IO

    • 简单来说就是读取硬盘一类设备的IO。这类设备包括传统的磁盘、SSD、闪存、CD等。操作系统将其统一抽象为”块设备“。所以磁盘IO又可以叫做”块IO“。这些设备上的数据一般用文件系统来组织,所以又可以成为”文件IO“。本文统一用”磁盘IO“这个术语。

    簇(sector)和块(block)

    • sector是针对磁盘的驱动,该驱动操作的最小单位不能小于sector,一般磁盘簇512Byte,而CD上的簇是2KB。
    • 对于Linux来说,虚拟文件系统(VFS)抽象了磁盘设备,统一称为“块设备”(block device)。数据是按照一块块来组织的。操作系统可以随机的定位到某个“块”,读写某个“块”。
    • 即操作系统依靠文件系统去操作的时候最小单位都是块,而块一般是簇的倍数(大于等于簇)。块的大小一般有512Byte,1KB,2KB等。
    • 即使你只想读取1个Byte,磁盘也至少要读取1个块;要写入1个Byte,磁盘也至少要写入一个块。
    • 我们读写磁盘数据要求“块”对齐。

    pageCache

    • pageCache是介于应用程序和VFS之间,如果绕过pageCache那么就是DIO,其他的都需要经过pageCache。
    • pageCache在我们的内存里面,pageCache的基本单位是Page,一般是4KB,对应若干个页,也就是说我们调用write方法
      必须写的是页的倍数,不是的话就是浪费空间。

    用户空间的buffer

    • 就是我们一般java操作的内存容器比如集合等都是buffer
    • 我们经常要从pagecache和buffer里面来回复制数据,这就涉及到context switch。类似于网络IO将数据从用户buffer于socket buffer直接切换
    • 这些切换一般来回都是拷贝,是需要cpu执行的我们称之为cpu copy。而磁盘数据到pagecache 则是硬件协议执行(DMA copy)的。

    通过mmap和sendfile避免了两次copy

    mmap

    • 可以将PageCache中的内核空间内存地址直接映射到用户空间,于是应该程序可以直接对page cache中的数据进行读写。不需要cpu copy

    sendFile

    • 可以直接将在pageCache中的某个fd的一部分数据直接传递给另外一个fd,而不用先从Afd copy到用户bufffer,再从用户buffer copy到 Bfd
    • 需要注意的是sendFile的原始fd即我们上面说的Afd必须是磁盘文件对应的fd,Bfd可以是磁盘也可以是socket。我们称之为 zero copy
    • 这边说的zero copy 只是没有cpu copy 但是有DMAcopy。
    • sendfile的缺点就是无法在用户态修改文件内容,因为都是直接在内核状态下发送

    传统io的读写顺序 ,read =从磁盘的读取到pagecache(DMA) 从pageCache到用户空间(cpu copy)。write=用户空间到pageCache(cpu copy),从socket缓冲到磁盘(DMA)

    noremal的sendfile的流程=DMA copy 从磁盘到页缓存,然后才有cpu copy 将页缓存中数据拷贝到目标scoket的缓冲区,然后socket缓冲区到 磁盘(DMA)。

    优化后的sendFile流程=DMA copy 从磁盘到页缓存,然后才有cpu copy 将页缓存中的文件描述符(件位置和长度信息的缓冲区描述符添加socket缓冲区去)拷贝到目标scoket的缓冲区然后socket缓冲区到 磁盘(DMA)。

    DIO

    • 即数据原先是需要经过pageCache的,但是DIO直接从用户buffer copy数据到磁盘。
    • Direct IO必然会带来性能上的降低。
    • 在数据库的实现中,为了保证数据持久,写入新数据到WAL(Write Ahead Log)必须直接写入到磁盘不能等待
    • 用户自定义pagecache,原先pagecache采用的是LRU规则,而我们想自定义cache的规则,比如根据数据的大小来决定是否进入我们自定义的cache。
    • 自定义的pageCache 只需要保证cache的size是VFS中块的倍数这就是所谓的块对齐(即write时给的buffer的offset和size要刚好与VFS中的“块”对应)。
    • 系统的pagecache会自动块对齐。

    BIO

    • 磁盘没有bio

    NIO

    • 默认都是NIO

    AIO

    • Linux中有两套“AIO”接口。这两套接口都只支持磁盘IO,不支持网络IO。

    POSIX AIO

    • POSIX AIO用信号(signal)来通知进程IO完成了。所以要先注册一个IO完成时对应的信号的handler。
    • 用aio_read或者aio_write来发起要读/写的操作。这个接口会立刻返回。
    • IO完成后,信号被触发,相应的handler会执行。
    • 你也可以选择不使用信号,而主动调用aio_suspend来主动等待IO的完成,就像第一篇文章中的select那样。

    这套接口没有得到广泛的使用,原因是其有很大的局限性——这套接口并不能算是"真・AIO"。这套接口是完全在用户态实现的(libc),完全没有深入到操作系统内核中。

    此外,用信号做AIO的触发在工程中有很多问题。信号是一个“数字”,而且是全局有效的。所以比如你用POSIX AIO实现了一个lib,选用数字M做信号;但是你无法阻止其他人用POSIX AIO实现另外一个lib,也选用数字M做信号。这样如果一个程序同时用了两套lib,就会彼此干扰。POSIX AIO无法实现类似于epoll中可以创建多个epoll fd,彼此隔离的使用方式。

    此外POSIX AIO因为是POSIX指定的标准,所以其存在的一个重要意义是不同操作系统的实现要一致,便于跨平台使用。但实际上各个操作系统对此标准实现的相当不一致(尤其是MacOS)

    Linux AIO

    aio_context_t ctx;
    struct iocb cb;
    struct iocb *cbs[1];
    char data[4096];
    struct io_event events[1];
    int ret;
    int fd = /* 打开一个文件,获得fd */;
    ctx = 0;
    
    ret = io_setup(128, &ctx); // 初始化一个同时处理最大128个fd的aio ctx
        
    /* 初始化 IO control block */
    memset(&cb, 0, sizeof(cb));
    cb.aio_fildes = fd;
    cb.aio_lio_opcode = IOCB_CMD_PWRITE; // 设置要“写入”
    cb.aio_buf = (uint64_t)data; // aio用的buffer
    cb.aio_offset = 0; // aio要写入的offset
    cb.aio_nbytes = 4096; // aio要写入的字节个数
    
    cbs[0] = &cb;
    ret = io_submit(ctx, 1, cbs); // 提交io进行异步处理
    
    /* 等待aio完成 */
    ret = io_getevents(ctx, 1, 1, events, NULL);
    /* 对events进行处理 */
    io_destroy(ctx);
    
    • 使用io_setup创建一个AIO的上下文aio_context_t(就像epoll会有一个fd)
    • 初始化iocb结构体(io control block),每一个要进行AIO的操作都要一个对应的iocb数据
    • 用io_submit将iocb提交(支持提交多个)。接口会立刻返回。然后,你的程序就可以做其他事情了。
    • 希望处理IO事件时,调用io_getevents。该接口会阻塞。如果IO事件完成了,就能拿到events,于是可以后续处理数据了。
    • 最终调用io_destroy把ctx清理掉。

    它只支持Direct IO的IO操作意味着选择使用了Linux AIO就无法享受Page Cache带来的好处;此外,只要使用Linux AIO,就意味着必须自己做块对齐.

    这套接口支持的功能有限,比如对于fsync,stat等API,压根就不能真的做到异步

    第三个问题是io_getevents,它和epoll一起使用会让程序有两个阻塞点。这样程序就没法写了。Linux提供了eventfd解决这个问题。

    使用eventfd协调epoll和Linux AIO

    问题抛出:同时用到epoll和Linux AIO。但是epoll_wait和io_getevents就会引入两个阻塞点,这样,等待文件IO的时候,网络请求就会被延迟

    • eventfd可以帮助把两个阻塞点二合为一
    • 它的本意是利用fd来简化跨进程的通讯——比如AB两个进程共享同一个eventfd,A进程对eventfd写入,B进程就能感知到。当然,eventfd也能在同一个进程里用。
    • epoll支持监听eventfd
    • Linux AIO中被提交的events如果完成,就会触发eventfd,于是监听该eventfd的epoll就能察觉到
    • 把阻塞点统一到epoll_wait上,即Aio事件完成了只需要触发eventfd,epoll监听到了eventfd 唤醒沉睡的epoll_wait.

    总结

    • 操作系统的AIO接口只支持文件操作。对于网络,需要用epoll这样的IO多路复用技术。如果要统一网络和磁盘IO都可以AIO就必须在上层进行封装,屏蔽掉操作系统这么不一致的细节(比如libuv就是这么干的)
    • 由于系统调用并不只直接支持”回调”(“信号”在工程上难以应用于IO回调这个场景,不算数),程序员需要自行使用io_getevents这样的API来主动等事件。在操作系统层面上,能做的最舒服的就是统一用epoll_wait做这个“等事件”的核心。这时需要借助eventfd。POSIX AIO并不支持eventfd,所以虽然有这么套接口,但是一般没机会用。
    • Linux AIO只支持Direct IO,所以无法利用Page Cache。所以现实当中,用不用是要做取舍的0
    • Linux AIO不能100%实现所有文件操作api都能“异步”(比如调用io_getevents该接口会阻塞)。
    • 基于以上的这些问题,一般上层(nodejs,Java NIO)都会选择用线程池+BIO来模拟文件AIO。好处是:

    BIO这一套接口非常完备,文件IO除了read,write,还有stat,fsync,rename等接口在现实中也是经常需要”异步“的;
    编程容易。看看上面的例子,是不是非常容易晕。而这些已经是非常简化的例子了,现实中的代码要处理相当多的细节;
    不用在AIO和Buffered IO中做取舍。BIO天然可以利用Page Cache来提高性能;
    容易跨平台。不同操作系统的线程实现和BIO的实现基本上完备一致,不会像AIO那样细节差异相当巨大。

    网络IO

    BIO

    • 这边bio的缺点已经在上述介绍过了,就不重复了。

    NIO

    • NIO是指将IO模式设为“Non-Blocking”模式。在Linux下,一般是这样:
    void setnonblocking(int fd) {
        int flags = fcntl(fd, F_GETFL, 0);
        fcntl(fd, F_SETFL, flags | O_NONBLOCK);
    }
    
    • 再强调一下,以上操作只对socket对应的文件描述符有意义;对磁盘文件的文件描述符做此设置总会成功,但是会直接被忽略。

    AIO

    • 网络IO不支持AIO,需要用epoll这样的IO多路复用技术来实现。

    相关文章

      网友评论

          本文标题:IO总结

          本文链接:https://www.haomeiwen.com/subject/dftdpqtx.html