Linux的几种I/O模型

1、阻塞I/O

2、非阻塞I/O

3、存储映射I/O(mmap)

4、信号驱动I/O

5、异步I/O

6、I/O复用

7、为什么I/O多路复用最好使用非阻塞I/O

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

9、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标志。

        fcntl系统调用可以用来对已打开的文件描述符进行各种控制操作以改变已打开文件的的各种属性。函数原型:

#include<unistd.h>
#include<fcntl.h>

int fcntl(int fd, int cmd);
int fcntl(int fd, int cmd, long arg);
int fcntl(int fd, int cmd ,struct flock* lock);

    fcntl函数功能依据cmd的值的不同而不同。参数对应功能如下:

F_DUPFD:

复制文件描述符

F_GETFD:

获得fd的close-on-exec标志,若标志未设置,则文件经过exec函数之后仍保持打开状态

F_SETFD:

设置close-on-exec标志,该标志以参数arg的FD_CLOEXEC位决定

F_GETFL:

得到open设置的标志

F_SETFL:

改变open设置的标志

F_GETFK:

根据lock描述,决定是否上文件锁

F_SETFK:

设置lock描述的文件锁

F_SETLKW:

这是F_SETLK的阻塞版本(命令名中的W表示等待(wait))。如果存在其他锁,则调用进程睡眠;如果捕捉到信号则睡眠中断

F_GETOWN:

检索将收到SIGIO和SIGURG信号的进程号或进程组号
F_SETOWN:设置进程号或进程组号
  • F_DUPFD:与dup函数功能一样,复制由fd指向的文件描述符,调用成功后返回新的文件描述符,与旧的文件描述符共同指向同一个文件。
  • F_GETFD:读取文件描述符close-on-exec标志
  • F_SETFD:将文件描述符close-on-exec标志设置为第三个参数arg的最后一位
  • F_GETFL:获取文件打开方式的标志,标志值含义与open调用一致
  • F_SETFL:设置文件打开方式为arg指定方式

    文件记录锁是fcntl函数的主要功能。记录锁实现只锁文件的某个部分,并且可以灵活的选择是阻塞方式还是立刻返回方式。当fcntl用于管理文件记录锁的操作时,第三个参数指向一个struct flock *lock的结构体:

struct flock
{
    short_l_type;    /*锁的类型*/
    short_l_whence;  /*偏移量的起始位置:SEEK_SET,SEEK_CUR,SEEK_END*/
    off_t_l_start;     /*加锁的起始偏移*/
    off_t_l_len;    /*上锁字节*/
    pid_t_l_pid;   /*锁的属主进程ID */
}; 

       short_l_type:用来指定设置共享锁(F_RDLCK,读锁)还是互斥锁(F_WDLCK,写锁)。当short_l_type的值为F_UNLCK时,传入函数中将解锁。每个进程可以在该字节区域上设置不同的读锁。但给定的字节上只能设置一把写锁,并且写锁存在就不能再设其他任何锁,且该写锁只能被一个进程单独使用。这是多个进程的情况。单个进程时,文件的一个区域上只能有一把锁,若该区域已经存在一个锁,再在该区域设置锁时,新锁会覆盖掉旧的锁,无论是写锁还时读锁。

      l_whence:确定文件内部的位置指针从哪开始

      l_star:确定从l_whence开始的位置的偏移量,两个变量一起确定了文件内的位置指针先所指的位置,即开始上锁的位置,

      l_len:字节数就确定了上锁的区域。

    特殊的,当l_len的值为0时,则表示锁的区域从起点开始直至最大的可能位置,就是从l_whence和l_start两个变量确定的开始位置开始上锁,将开始以后的所有区域都上锁。为了锁整个文件,我们会把l_whence,l_start,l_len都设为0。 

  • F_GETFK:第3个参数lock指向一个希望设置的锁的属性结构,如果锁能被设置,该命令并不真的设置锁,而是只修改lock的l_type为F_UNLCK,然后返回该结构体。如果存在一个或多个锁与希望设置的锁相互冲突,则fcntl返回其中的一个锁的flock结构。
  • F_SETFK:此时fcntl函数用来设置或释放锁。当short_l_type为F_RDLCK为读锁,F_WDLCK为写锁,F_UNLCK为解锁。

    如果锁被其他进程占用,则返回-1; 这种情况设的锁遇到锁被其他进程占用时,会立刻停止进程。

  • F_SETLKW:此时也是给文件上锁,不同于F_SETLK的是,该上锁是阻塞方式。当希望设置的锁因为其他锁而被阻止设置时,该命令会等待相冲突的锁被释放。

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中拷贝到用户缓冲区中;

                       

(2)mmap的操作:

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

              

  • 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

    信号提供了一种处理异步事件的方法。例如,终端用户键入中断键,会通过信号机制停止一个程序。每个信号都有一个名字,这些名字都以SIG开头,Linux和Mac OS支持31种信号,在<signal.h>中,信号名都被定义为正整数常量。在某个信号产生时,可以告诉内核按下列三种方式之一进行处理:

  • 忽略此信号,大多数信号都采用这种方式处理,但有两种信号不能被忽略,分别是SIGKILL和SIGSTOP;
  • 捕捉信号,为做到这一点,要通知内核在某种信号发生时,调用一个用户函数,在用户函数中,可执行用户希望对这种事件进行的处理;
  • 执行系统默认动作,对大多数信号的系统默认动作是终止该进程;

4.1 信号机制最简单的接口是signal函数

#include <signal.h>

void (*signal(int signo, void (*func)(int)))(int); // 若成功,返回以前的信号处理配置,若出错,返回SIG_ERR
  • signo是信号名 ;
  • func的值是常量SIG_IGN、常量SIG_DFL或者接到此信号后要调用的函数地址。如果指定SIG_IGN,则向内核表示忽略此信号(两种信号不能忽略),如果指定SIG_DFL则表示接到此信号后的动作是系统默认动作。当指定函数地址时,则在信号发生时,调用该函数。
  • signal函数的返回值是一个函数地址,该函数有一个整型参数。

4.2 信号集

    我们需要一个能表达多个信号的数据类型——信号集,以便告诉内核不允许发生该信号集中的信号。POSIX.1定义了数据类型sigset_t包含一个信号集,并且定义了下列5个信号集的处理函数:

#include<signal.h>

int sigemptyset(sigset_t *set); // 初始化set所指的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
int sigfillset(sigset_t *set); // 初始化set所指的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset(sigset_t *set,int signo); // 将一个信号添加到信号集中
int sigdelset(sigset_t *set,int signo); // 从信号集中删除一个信号
int sigismember(const sigset_t *set,int signo); // 判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含返回0,出错返回-1.

     每个进程都有一个信号屏蔽字,它规定了当前要阻塞递送到该进程的信号集,对于每种可能的信号,该屏蔽字都有一位与之对应。进程可以调用sigprocmask来检测和更改其当前信号屏蔽字。 

#include <signal.h>

int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oldset);// 成功返回0,失败返回-1
  • 若oset非空,进程当前的信号屏蔽字通过oset返回
  • 若set是一个非空指针,则参数how指定如何修改当前的信号屏蔽字。SIG_BLOCK是或操作,即当前信号屏蔽字和set相或,SIG_UNBLOCK是与操作,即当前信号屏蔽字和set相与,SIG_SETMASK表示该进程的信号屏蔽字是set指向的值 。
  • 若set是空指针,则不改变该进程的信号屏蔽字

4.3 sigaction函数取代了早期的signal函数

#include <signal.h>

int sigaction(int signo, const struct sigaction *restrict act, struct sigaction *restrict oact); // 成功返回0,失败返回-1
  • signo是信号编号
  • 若act非空,则要修改其动作
/* Type of a signal handler.   */
typedef void (*__sighandler_t)(int);
struct sigaction {
    __sighandler_t sa_handler;
    unsigned long sa_flags;
    void (*sa_restorer)(void);
    sigset_t sa_mask;   /* mask last for extensibility */
};

sa_handler:此参数代表信号处理函数
sa_mask :用来设置在处理该信号时暂时将sa_mask 指定的信号集阻塞
sa_flags :用来设置信号处理的其他相关操作,下列的数值可用。 
    SA_RESETHAND:当调用信号处理函数时,将信号的处理函数重置为缺省值SIG_DFL
    SA_RESTART:如果信号中断了进程的某个系统调用,则系统自动启动该系统调用
    SA_NODEFER :一般情况下, 当信号处理函数运行时,内核将阻塞该给定信号。但是如果设置了 SA_NODEFER标记, 那么在该信号处理函数运行时,内核将不会阻塞该信号
  • 若oact非空,则系统经由oact指针返回该信号的上一个动作 

5、异步I/O

 

6、I/O复用

    参考I/O复用:select、poll、epoll

7、为什么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的的处理方式要复杂很多,稍不注意就会阻塞整个进程。

8、必须采用非阻塞 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 住了。

9、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

 

  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值