阻塞/非阻塞、同步/异步IO

一、阻塞和非阻塞

若对文件的一次I/O系统调用,读操作时无数据或写数据时可用空间不够,则阻塞模式下即会阻塞该进程,非阻塞模式下返回-1,并设置errno为EWOULDBLOCK(或者EAGAIN,这两个宏定义的值相同),表示本来应该阻塞在这里(would block,虚拟语气),事实上并没有阻塞而是直接返回错误,调用者应该试着再读一次(again)。

整体概括

  • 阻塞/非阻塞对应于打开文件的文件状态标志中的I/O操作方式,因此 阻塞和非阻塞是对于某个文件上的I/O操作而言的,而不是某个I/O系统调用。因此,阻塞/非阻塞决定了该文件上的所有I/O操作在可能发生阻塞时采取的方式:阻塞等待或出错返回
  • 有两种方式设置以这两种模式执行I/O操作:
    • 首次打开文件时,在open函数oflags参数中加上O_NONBLOCK文件状态标志;
    • 文件已打开时,调用fcntl函数修改指定文件描述符的文件状态标志(APUE set_fl)。

阻塞/非阻塞伪代码示例

/* 阻塞模式下I/O操作的伪代码 */
block read(fd1);
  处理数据(阻塞并等到了设备1的数据);
block read(fd2);
  处理数据(阻塞并等到了设备2的数据);

/* 非阻塞模式下轮询多个设备的伪代码 */
while(1) {
  non-block read(fd1);  //轮询fd1
  if(设备1有数据)
    处理数据;
  else
    EAGAIN;
  ...

  non-block read(fd2);  //轮询fd2
  if(设备2有数据)
    处理数据;
  else
    EAGAIN;
  ...
}

阻塞模式下,若fd1无数据,该进程会一直阻塞在fd1的block read操作上,即使fd2有数据也不能处理,但阻塞时内核可以调度其他进程。非阻塞模式下,可以使用“while循环+non-block I/O”的轮询(Poll)机制,在该进程上一直反复查询fd1和fd2是否有数据到达,有则处理,无则一直循环。

非阻塞轮询(Poll)的优缺点

  • 优点:轮询方式可以在一个进程中监视多个设备,不会阻塞 该进程
  • 缺点:若设备一直无数据,需要反复调用做无用功,若阻塞操作,内核即可以调度 其他进程。——因此,使用I/O多路复用由内核和文件驱动设备帮助同时监视多个设备,而不是直接用非阻塞I/O操作轮询文件设备。

二、同步/异步

内核缓冲区

宏观上,调用函数write()时,我们认为该函数一旦返回,数据便已经写到了文件中。实际上,Unix系统实现某些文件I/O时(如磁盘文件),为了保证I/O的效率,在内核通常会用到一片专门的区域(内存或独立的I/O地址空间)作为I/O数据高速缓存或页高速缓存。它用在输入输出设备和CPU之间,用来缓存数据,使得低速的设备和高速的CPU能够协调工作避免低速的输入输出设备长时间占用CPU,减少系统调用,提高了CPU的工作效率。

延迟写

传统的UNIX或LINUX系统在设计时使用了内核缓冲区,设有高速缓冲区或页面高速缓冲区,大多数磁盘I/O都通过缓冲区进行。当将数据写入文件时, ① 内核通常先将该数据复制到其中一个缓冲区,如果该缓冲区尚未写满,则并不将其排入输出队列, ② 而是等待其写满或者当内核需要重用该缓冲区以便存放其他磁盘块数据时,再将该缓冲区排入输出队列; ③ 然后待其到达队首时,才进行实际的I/O操作,这种输出方式被称为延迟写。

调用write()函数写出数据时,数据一旦写到该缓冲区(关键:只是写到缓冲区),函数便立即返回。此时写出的数据从用户进程角度来看,写入内核缓存即相当于已经“写入文件”,因此该进程可以用read()读回,也可以被其他进程读到,但是并不意味着它们已经被写到了外部永久存储介质上,即使调用close()关闭文件后也可能如此。因为缓冲区的数据可能还在等待输出。——此即“不同步的write()”

而从数据被实际写到磁盘的角度来看,用write()写出的文件数据与外部存储设备并不是完全同步的。不同步的时间间隔非常短,一般只有几秒或十几秒,具体取决于写出的数据量和I/O数据缓冲区的状态。尽管不同步的时间间隔很短,但是如果在此期间发生掉电或者系统崩溃,则会导致所写数据来不及写至磁盘而丢失的情况。

注意:内核将缓冲区中的数据“写”到磁盘文件中,不是将缓冲区中的数据移动到磁盘文件中,而是拷贝到磁盘文件中,也就说此时磁盘文件中还保留一份缓冲区内容的备份。这一设计也是有其道理的,如果写出到磁盘文件上,磁盘坏了或满了等等,总之就是无法将数据送出,假如没备份,就会造成数据丢失。也就是说内核会等待写入磁盘动作完成后,才放心的将备份的数据删除掉。

写同步函数

为了保证磁盘上实际文件系统与内核高速缓存内容的一致性,Unix系统提供了sync、fsync和fdatasync函数。

#include<unistd.h>
void sync(void);

int fsync(int fd);
int fdatasync(int filedes);     //成功,返回0;出错,返回-1,同时设置errno
  • sync:将 系统缓冲区的数据 “写入”磁盘,以确保数据的一致性和同步性。
    • 只是将 系统所有修改过的块缓冲区 排入写队列 就返回,并不等待实际I/O操作结束
    • 所以即使调用了sync函数,数据不一定就已安全的送到磁盘文件上,有可能会出现问题,但是sync函数是无法得知的。
    • 通常,update系统守候进程周期性地(一般每隔30秒)调用sync函数,确保定期刷新内核的块缓存。
  • fsync:刷新fd给出的文件的所有数据和信息,并且等待写磁盘操作结束,然后返回
    • 与sync函数不同,fsync函数只对由文件描符fd指定的单一文件起作用,强制与描述字fd相连文件的所有修改过的数据(包括fd写到内核高速缓存的数据和文件的所有属性)传送到外部永久介质。
    • 调用 fsync()的进程将阻塞直到设备报告传送已经完成,这个fsync就安全点了。
    • 一个程序在写出数据之后,如果继续进行后续处理之前要求确保所写数据已写到磁盘,则应当调用fsync()。例如,数据库应用通常会在调用write()保存关键交易数据的同时也调用fsync(),这样更能保证数据的安全可靠。
  • fdatasync:类似于fsync函数,但它只影响文件数据部分,强制传送用户已写出的数据至物理存储设备,不包括文件本身的特征数据。这样可以适当减少文件刷新时的数据传送量。而除数据外,fsync还会同步更新文件的属性。
  • 错误代码:
    • EBADF:文件描述符无效,或文件已关闭。
    • EIO :读写的过程中发生错误。
    • EROFS,EINVAL:文件所在的文件系统不支持同步。
fflush()与fsync()

内核I/O缓冲区是由操作系统管理的空间,而流缓冲区是由标准I/O库管理的用户空间。fflush()只刷新位于用户空间中的流缓冲区。fflush()返回后,只保证数据已不在流缓冲区中而进入内核缓存,并不保证它们一定被写到了磁盘。此时,从流缓冲区刷新的数据可能已被写至磁盘,也可能还待在内核I/O缓冲区中,要确保流I/O写出的数据已写至磁盘,那么在调用fflush()后还应当调用fsync()。

三、接口函数

3.1 read/write

read():从文件读数据到自定义缓冲区(从当前偏移量开始读,若读成功则在返回之前当前偏移量增加实际读到的字节数)。

write():将自定义缓冲区的数据写入文件(从当前偏移量开始写,一次成功写返回之前当前偏移量增加实际写的字节数)。

read()/write()的函数原型如下

#include <unistd.h>
ssize_t read(int fd,void* buf,size_t nbytes);
    //成功,返回实际读到的字符数(0<ret≤nbytes);文件尾,返回0;出错,返回-1,并设置errno
ssize_t write(int fd,void* buf,size_t nbytes);
    //成功,返回已写的字节数;出错,返回-1

read对于不同的文件类型具有不同的特性

  • 读普通文件:不可能发生阻塞,read只可能成功或EOF。
    • 成功,返回 **0
#include <unistd.h>  
#include <stdlib.h>    

int main(void)  {
   char buf[1024];
   int n;
   n = read(STDIN_FILENO, buf, 10);
   if (n < 0) {
    perror("read STDIN_FILENO");
    exit(1);
   }
   write(STDOUT_FILENO, buf, n);
   return 0;
}

执行结果如下:

$ ./a.out   hello(回车)  hello  
$ ./a.out   hello world(回车)  hello worl
$ d  bash: d: command not found

第一次执行a.out的结果很正常,而第二次执行的过程有点特殊,现在分析一下:

1)Shell进程创建a.out进程,a.out进程开始执行,而Shell进程阻塞等待a.out进程退出。

2)a.out调用read时阻塞等待,直到终端设备输入了换行符才从read返回,read只读走10个字符,剩下的字符仍然保存在内核的终端设备输入缓冲区中。(注意其中的换行符,read/fread都会读入,并解释为换行)

3)a.out打印10个字符并退出,这时Shell进程恢复运行,Shell继续从终端读取用户输入的命令,于是读走了终端设备输入缓冲区中剩下的字符d和换行符,把它当成一条命令解释执行,结果发现执行不了,没有d这个命令。

3.2 fcntl:改变已打开文件的属性(只需提供fd即可)

函数原型

#include <fcntl.h>
int fcntl(int fd,int cmd, /* int arg */);   //成功,依赖于cmd;出错,都返回-1

fcntl函数的功能

  1. 复制一个已有的描述符(cmd=F_FUPFD 或 F_DUPFD_CLOEXEC)
  2. 获取/设置文件描述符标志(cmd=F_GETFD 或 F_SETFD)
  3. 获取/设置文件状态标志(cmd=F_GETFL 或 F_SETFL)
  4. 获取/设置异步I/O所有权(cmd=F_GETOWN 或 F_SETOWN)
  5. 获取/设置记录锁(cmd=F_GETLK、F_SETLK 或 F_SETLKW)

设置非阻塞I/O:set_fl_nonblock.c

#include <fcntl.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>

/* 设置非阻塞,并返回原来的flag */
int set_fl_nonblock(int fd){
  int old_flag,new_flag;
  old_flag = fcntl(fd, F_GETFL, 0);     //需要先获取原来的标志位
  if(old_flag < 0){
    fprintf(stderr, "fcntl F_GETFL error:%s\n", strerror(errno));
    return -1;
  }
  new_flag = old_flag | O_NONBLOCK;
  int ret = fcntl(fd, F_SETFL, new_flag);
  if(ret < 0){
    fprintf(stderr, "fcntl F_SETFL error:%s\n", strerror(errno));
    return -1;
  }
  return old_flag;
}

附1:进程的不同状态

当进程调用一个阻塞的系统调用时,该进程就会内核置于睡眠(Sleep)状态,这时内核转区调度其它进程运行,直到该进程等待的事件发生了(比如网络上接收到数据包,终端设备上输入换行,或者调用sleep指定的睡眠时间到了)它才有可能继续运行。与睡眠状态相对的是运行(Running)状态,在Linux内核中,处于运行状态的进程分为两种情况:

1、正在被调度执行。CPU处于该进程的上下文环境中,程序计数器(eip)里保存着该进程的指令地址,通用寄存器里保存着该进程运算过程的中间结果,正在执行该进程的指令,正在读写该进程的地址空间。

2、就绪状态。该进程不需要等待什么事件发生,随时都可以执行,但CPU暂时还在执行另一个进程,所以该进程在一个就绪队列中等待被内核调度,此时该进程即处于就绪状态。系统中可能同时有多个就绪的进程,内核通过调度算法决定要运行哪个就绪状态的进程。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值