阻塞I/O、非阻塞I/O、存储映射I/O(mmap)

1、阻塞I/O

2、非阻塞I/O

3、存储映射I/O(mmap)——参考这里和这里

4、为什么I/O多路复用最好使用非阻塞I/O(参考)

5、必须采用非阻塞 I/O的几种情形

6、listenfd阻塞还是非阻塞、是ET还是LT(参考)


1、阻塞I/O

    系统调用分成两类:“低速”系统调用和其它,低速系统调用是可能会使进程永远阻塞的一类系统调用,包括:

  • (1)如果某些文件类型(如读管道、终端设备和网络设备)的数据并不存在,读操作可能会使调用者永久阻塞;
  • (2)如果数据不能被相同的文件类型接收(如管道中无空间、网络流控制),写操作可能使调用者永久阻塞;
  • (3)在某种条件发生之前打开某些文件类型可能会发生阻塞(如要打开一个终端设备,需要先等待与之连接的modem的应答,又如以只写模式打开FIFO,那么在没有其他进程已用读模式打开该FIFO时也要等待;
  • (4)对已经加上强制性记录锁的文件进行读写;
  • (5)某些ioctl操作;
  • (6)某些进程间通信函数;

    在这些低速系统调用中,一个值得注意的例外是与磁盘I/O相关的系统调用,虽然读、写一个磁盘可能暂时阻塞调用者(在磁盘驱动程序将请求排入队列,然后在适当的时间执行请求期间),但是除非发生硬件错误,I/O操作总会很快返回,并使调用者处于不再阻塞状态。

2、非阻塞I/O

    非阻塞I/O使我们可以发出open、read和write这样的I/O操作,并使这些操作永远不会阻塞。如果这些操作不能立即完成,则调用立即出错返回,表示该操作如继续执行将阻塞。
    对于一个给定的描述符,有两种为其指定非阻塞I/O的方法:

  • (1)如果调用open获得描述符,则可以指定O_NONBLOCK标志;
  • (2)对于一个已经打开的描述符,则可以调用fcntl改变已经打开的文件属性,由该函数打开O_NONBLOCK标志。

3、存储映射I/O(mmap)——参考这里和这里

    首先说一下文件系统的三层结构,每个进程中都有一个用户文件描述符表,表项指向一个全局的文件表中的某个表项,文件表表项有一个指向内存inode的指针,每个inode唯一标识一个文件。如果同时有多个进程打开同一文件,他们的用户文件描述符表项指向不同的文件表项,但是这些文件表项会指向同一个inode。
    内核会为每个文件单独维护一个page cache,用户进程对于文件的大多数读写操作会直接作用到page cache上,内核会选择在适当的时候将page cache中的内容写到磁盘上(当然我们可以手工fsync控制回写),这样可以大大减少磁盘的访问次数,从而提高性能。Page cache是linux内核文件访问过程中很重要的数据结构,page cache中会保存用户进程访问过得该文件的内容,这些内容以页为单位保存在内存中,当用户需要访问文件中的某个偏移量上的数据时,内核会以偏移量为索引,找到相应的内存页,如果该页没有读入内存,则需要访问磁盘读取数据。为了提高页得查询速度同时节省page cache数据结构占用的内存,linux内核使用树来保存page cache中的页。在了解了以上的基础之后,我们就来比较一下mmap和read/write的区别。

(1)read/write系统调用会有以下的操作:

  • 1)访问文件,这涉及到用户态到内核态的转换;
  • 2)读取硬盘文件中的对应数据,内核会采用预读的方式,比如我们需要访问100字节,内核实际会将按照4KB(内存页的大小)存储在page cache中;
  • 3)将read中需要的数据,从page cache中拷贝到用户缓冲区中;

                       阻塞I/O、非阻塞I/O、存储映射I/O(mmap)_第1张图片

(2)mmap的操作:

    mmap系统调用是将硬盘文件映射到用内存中,说的底层一些是将page cache中的页直接映射到用户进程地址空间中,从而进程可以直接访问自身地址空间的虚拟地址来访问page cache中的页,这样不会涉及page cache到用户缓冲区之间的拷贝 :

              阻塞I/O、非阻塞I/O、存储映射I/O(mmap)_第2张图片

  • 1)mmap()会返回一个指针ptr,它指向进程逻辑地址空间中的一个地址,这样以后,进程无需再调用read或write对文件进行读写,而只需要通过ptr就能够操作文件。但是ptr所指向的是一个逻辑地址,要操作其中的数据,必须通过MMU将逻辑地址转换成物理地址,如图1中过程2所示。这个过程与内存映射无关。
  • 2)建立内存映射并没有实际拷贝数据,这时,MMU在地址映射表中是无法找到与ptr相对应的物理地址的,也就是MMU失败,将产生一个缺页中断,缺页中断的中断响应函数会在swap中寻找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则会通过mmap()建立的映射关系,从硬盘上将文件读取到物理内存中,如图1中过程3所示。这个过程与内存映射无关。
  • 3)如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘上,如图 1中过程4所示。这个过程也与内存映射无关。 

(3) read和mmap区别:

    从代码层面上看,从硬盘上将文件读入内存,都要经过文件系统进行数据拷贝,并且数据拷贝操作是由文件系统和硬件驱动实现的,理论上来说,拷贝数据的效率是一样的。但是通过内存映射的方法访问硬盘上的文件,效率要比read和write系统调用高,这是为什么呢?原因是read()是系统调用,其中进行了数据拷贝,它首先将文件内容从硬盘拷贝到内核空间的一个缓冲区,如图2中过程1,然后再将这些数据拷贝到用户空间,如图2中过程2,在这个过程中,实际上完成了 两次数据拷贝 ;而mmap()也是系统调用,如前所述,mmap()中没有进行数据拷贝,真正的数据拷贝是在缺页中断处理时进行的,由于mmap()将文件直接映射到用户空间,所以中断处理函数根据这个映射关系,直接将文件从硬盘拷贝到用户空间,只进行了 一次数据拷贝 。因此,内存映射的效率要比read/write效率高。

(4)谈谈page cache:

    从上面所说我们从磁盘文件中读取的内容都会存在page cache中,但当我们关闭这个文件时,page cache中内容会立马释放掉吗?答案是否,磁盘的读取速度比内存慢太多,如果能命中page cache可以显著提升性能,万一后续又有对这个文件的操作,系统就可以很快速的响应。当然,这些文件内容也不是一直存在page cache中的,一般只要系统有空闲物理内存,内核都会拿来当缓存使用,但当物理内存不够用,内存会清理出部分page cache应急,这也就是告诉我们程序对于物理内存的使用能省则省,交给内核使用,作用很大。
    还有就是普通的write调用只是将数据写到page cache中,并将其标记为dirty就返回了,磁盘I/O通常不会立即执行,这样做的好处是减少磁盘的回写次数,提高吞吐率,不足就是机器一旦意外挂掉,page cache中的数据就会丢失。一般安全性比较高的程序会在每次write之后,调用fsync立即将page cache中的内容回写到磁盘中。

4、为什么I/O多路复用最好使用非阻塞I/O(参考)

    当采用select、poll、epoll等I/O多路复用时,当监听到一个sockfd可读(即缓冲区中有数据,假设为350B)时,我们可以用一个函数read从sockfd读取数据,假设read函数调用一次可以读取100B,则需要读取4次才能读取完(注意第四次不会阻塞,而是返回50B):

(1)如果采用非阻塞I/O:循环的read,直到读完所有的数据(抛出 EWOULDBLOCK 异常或返回EAGAIN);

(2)如果采用阻塞I/O:每次只能调用一次read,因为多路复用只会告诉你 fd 对应的 socket 可读了,但不会告诉你有多少的数据可读,当调用完read函数后,你无法知道下一次read会不会发生阻塞(例如本例中第5次read时就会阻塞),只有等到下一次循环select/poll/epoll函数通知该sockfd可读时,才能进行下一次读取。

    所以我们会发现阻塞I/O的的处理方式要复杂很多,稍不注意就会阻塞整个进程。

5、必须采用非阻塞 I/O的几种情形

(1)Epoll的边缘触发模式必须采用非阻塞I/O:

    想象这样一个场景:有一个pipe描述符 fd 按顺序发生了如下的动作:

  •  1) 读端的 fd 被注册到一个epoll的描述符当中, 监听读信号, 此时pipe中没有消息, 无论是边缘触发还是水平触发此刻都不会被触发;
  •  2) fd 的写端被写入2kb数据;
  •  3)读端调用epoll_wait,返回 fd, 此刻pipe中有2kb数据,并且从不可读变为可读,所以边缘触发和水平触发都会返回 ;
  •  4) 读端读取1kb的数据;
  •  5) 读端继续调用epoll_wait。

    在第五步的时候, 边缘触发和水平触发的差异就显现出来了, 此时pipe中仍然有数据,所以水平触发的epoll会立刻返回, 但是边缘触发的epoll_wait 并不会返回, 因为此时pipe一直可读, 并没有从不可读变为可读状态所以这里就会出现一个问题, 如果写端在等读端处理完数据返回, 而读端却在等写端的2kb数据中的另外1kb, 双方就会产生死锁。 因此, 在使用边缘触发的时候, 建议将描述符设置为nonblocking, 并且在read/write产生EAGAIN的错误之后再使用epoll_wait。

(2)多线程环境需要使用非阻塞I/O:

    惊群现象,就是一个典型场景,多个进程或者线程通过 select 或者 epoll 监听一个 listen socket,当有一个新连接完成三次握手之后,所有进程都会通过 select 或者 epoll 被唤醒,但是最终只有一个进程或者线程 accept 到这个新连接,若是采用了阻塞 I/O,没有accept 到连接的进程或者线程就 block 住了。

6、listenfd阻塞还是非阻塞、是ET还是LT(参考)

(1)listenfd阻塞还是非阻塞? 

    如果TCP连接被客户端夭折,即在服务器调用accept之前,客户端主动发送RST终止连接,导致刚刚建立的连接从就绪队列中移出,如果套接口被设置成阻塞模式,服务器就会一直阻塞在accept调用上,直到其他某个客户建立一个新的连接为止。但是在此期间,服务器单纯地阻塞在accept调用上,就绪队列中的其他描述符都得不到处理。 
    解决办法是把监听套接口listenfd设置为非阻塞,当客户在服务器调用accept之前中止某个连接时,accept调用可以立即返回-1,这时源自Berkeley的实现会在内核中处理该事件,并不会将该事件通知给epool,而其他实现把errno设置为ECONNABORTED或者EPROTO错误,我们应该忽略这两个错误。 

(2)ET还是LT? 

    ET:如果多个连接同时到达,服务器的TCP就绪队列瞬间积累多个就绪连接,由于是边缘触发模式,epoll只会通知一次,accept只处理一个连接,导致TCP就绪队列中剩下的连接都得不到处理。 解决办法是用while循环包住accept调用,处理完TCP就绪队列中的所有连接后再退出循环。如何知道是否处理完就绪队列中的所有连接呢?accept返回-1并且errno设置为EAGAIN就表示所有连接都处理完。
    LT:在nigix的实现中,accept函数调用使用水平触发的fd,就是出于对丢失连接的考虑(边缘触发时,accept只会执行一次接收一个连接,内核不会再去通知有连接就绪),所以使用水平触发的fd就不存在丢失连接的问题。但是如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。 

(3)归纳如下: 

  1)对于监听的sockfd要设置成非阻塞类型,触发模式最好使用水平触发模式,边缘触发模式会导致高并发情况下,有的客户端会连接不上。如果非要使用边缘触发,网上有的方案是用while来循环accept()。 
  2)对于读写的connfd,水平触发模式下,阻塞和非阻塞效果都一样,不过为了防止特殊情况,还是建议设置非阻塞。 
  3)对于读写的connfd,边缘触发模式下,必须使用非阻塞IO,并要一次性全部读写完数据。

参考https://blog.csdn.net/menlong/article/details/6648519

           https://blog.csdn.net/mg0832058/article/details/5890688

 

你可能感兴趣的:(Linux)