博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
系统级 I/O与缓冲机制
阅读量:4184 次
发布时间:2019-05-26

本文共 6322 字,大约阅读时间需要 21 分钟。

最近实验室的学弟们貌似对缓冲区很感兴趣,听到很多次在讨论缓冲区。今天也来写篇文章和大家讨论一下。从I/O,到缓冲区都会谈到。首先是所有语言都提供了执行I/O的较高级别的工具,例如ANSI C提供了标准I/O库,C++重载了<<和>>等,这些不依赖于系统内核,所以移植性强,而且这个缓冲区的分配长度和优化等细节都是代你处理好了。在Unix系统中,是通过使用有内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的。

高级别I/O函数工作很好,上面也提到了几点优点,为什么还要学习Unix I/O呢?

  1. 了解Unix I/O可以帮你理解其他的系统概念。比如进程、存储器层次结构、链接和加载等。
  2. 有时候除了使用Unix I/O 以外别无选择。有些重要情况下,使用高级I/O函数不能实现想要的功能,比如标准I/O库没有提供读取文件元数据的方式,比如文件大小或文件创建时间等。

I/O 概念:

输入/输出(I/O)是在主存和外部设备(如磁盘驱动器、终端和网络)之间拷贝数据的过程。输入操作是从I/O设备拷贝数据到主存,而输出操作是从主存拷贝数据到I/O设备。

所有的I/O设备都被模型化为外文件,所有的输入输出都被当做对相应文件的读和写来执行。设备映射为文件,使得输入输出能够以一种统一且一致的方式来执行。

一系列的Unix I/O函数

#include 
#include
#include
int open(char *filename, int flags, mode_t mode); //将filename转换为一个文件描述符, 成功则返回非负的新的文件描述符,出错返回-1//flags 指明了进程如何访问这个文件//mode 指定了新文件的访问权限位
#include 
int close(int fd); //关闭此文件描述符对应的文件,成功返回09,出错返回-1
#include 
ssize_t read(int fd, void *buf, size_t n); //从文件描述符为fd的当前文件位置,拷贝最多n个字节到存储器位置buf。//成功返回实际传送的字节数,若EOF则为0,出错返回-1
#include 
ssize_t write(int fd, const char *buf, size_t n); //成功返回写的字节数,出错则为-1//从存储器位置buf拷贝至多n个字节到描述符fd的当前文件位置。//成功返回写的字节数,出错则为-1

Q:ssize_t和size_t有些什么区别?

A:size_t 被定义为 unsigned int,而ssize_t(有符号的大小)被定义为 int。read函数返回一个有符号的大小,而不是一个无符号的大小,是因为出错返回-1,这使得read的最大值减小了一半,从4G减小到了2G。

什么是“不足值”?

“不足值”的情况:指的是某些情况下,read和write传送的字节比应用程序要求的要少,这些不足值不表示有错误。

造成这种情况的原因有:

  1. 读时遇到EOF。要求的字节数超过了读缓冲区内未读的字节的数量。
  2. 从终端读取文本行。如果打开文件是与终端想关联的(比如键盘和显示器),那么每个read函数将一次传送一个文本行,返回的不足值等于文本行的大小。
  3. 读和写socket。内部缓冲约束和较长的网络延迟会引起read和write返回不足值。

用RIO包健壮地读写

在像网络程序这样容易出现不足值的应用中,RIO(Robust I/O)包提供了方便、健壮和高效的I/O。

RIO提供了两类不同的函数:

  1. 无缓冲的输入输出函数。直接在存储器和文件之间传送数据,没有应用级缓冲。对将二进制数据读写到网络和从网络中读写二进制数据尤其有用。
  2. 带缓冲的输入函数。允许你高效地从文件中读取文本行和二进制数据,这些内容缓存在应用级缓冲区内。带缓冲的RIO输入函数是线程安全的,允许在同一个文件描述符上被交替地调用

带缓冲I/O和不带缓冲I/O有什么区别?

所谓不带缓冲,并不是内核不提供缓冲,系统内核对磁盘的读写都会提供一个块缓冲(也有人称为内核高速缓存),当调用一次read或write函数,直接进行系统调用,将数据写入到块缓冲进行排队。因此所谓的不带缓冲的I/O是指进程不提供缓冲功能,内核还是提供缓冲的。

带缓冲的I/O是指进程对输入输出流进行了改进,比如调用标准I/O库函数往磁盘写数据时,标准IO库提供了一个流缓冲,先把数据写入流缓冲区中,当达到一定条件,比如流缓冲区满了或者手动刷新了流缓冲,这时候才会把数据一次送往内核提供的缓冲,再经块缓冲写入磁盘。

因此,带缓冲I/O一般会比不带缓冲I/O调用系统调用的次数要少

ssize_t write(int filedes, const void *buff, size_t nbytes)
size_t fwrite(const void *ptr, size_t size, size_t nobj, FILE *fp)
拿这两个函数来说,首先要清楚,所谓的带缓冲并不是指上面两个函数的buff参数。

现在假设内核设的缓存是100字节,如果你使用write,且buff的size是10字节,当你要把10个同样的buff写到文件时,需要调用10次write,也就是10次系统调用,此时因为延迟写的技术,并没有写到硬盘,如果想立即写入硬盘,需调用fsync。(涉及写操作机制几个概念,同步写机制、延迟写机制、异步写机制,此处不说了,可以查一下)

标准I/O,也就是带缓存的IO,也称为用户态的缓存,区别于内核所设的缓存。假设缓存长度为50字节,把100字节的数据写到文件,只需2次系统调用,因为先把数据写到流缓存,当其满或者手动刷新之后才填入内核缓存,所以2次就够了。

至于究竟写到了文件中还是内核缓冲区中,对于进程来说是没有差别的,如果进程A和进程B打开同一文件,进程A写到内核缓冲区中的数据从进程B也能读到,因为内核空间是进程共享的。

C标准库这类缓冲区不具有这一特性,因为进程的用户空间是独立的。


下面具体来看RIO 是怎么实现的。

RIO的无缓冲输入输出函数

/* * 无缓冲输入函数  * 成功返回收入的字节数  * 若EOF返回0 ,出错返回-1 */ssize_t rio_readn(int fd, void *usrbuf, size_t n){   size_t nleft = n;   ssize_t nread = 0;   char *pbuf = usrbuf;   while(nleft > 0){       //在某些系统中,当处理程序捕捉到一个信号时,被中断的系统调用(read write accept)       //在信号处理程序返回时不再继续,而是立即返回给客户一个错误条件,并将errno设置成为EINTR       if((nread = read(fd, pbuf,nleft)) == -1){           if(errno == EINTR){                 nread = 0;   //中断造成的,再次调用read           } else{               return -1;   //出错           }       }       else if(nread == 0)  //到了文件末尾           break;       nleft -=nread;       pbuf += nread;   }   return n-nleft;}/* * 无缓冲输出函数 * 成功返回输出的字节数,出错返回-1*/ ssize_t rio_writen(int fd, void *usrbuf, size_t n){    size_t nleft = n;    ssize_t nwritten;    char *bufp = usrbuf;    while(nleft > 0){        //这里是小于等于,磁盘已满或者超过一个给定进程的文件长度限制就出错了        if((nwritten = write(fd, bufp, nleft )) <= 0)        {            if(errno == EINTR)                nwritten = 0;            else                return -1;        }        nleft -=nwritten;        bufp += nwritten;    }    return n;   // write 不会返回不足值}

RIO的带缓冲输入函数

每次调用 read 都会陷入内核态,频繁的调用效率不是很高。更好的方法就是调用一个包装函数,它从一个内部读缓冲区拷贝数据,当缓冲区变空时,会自动地调用 read 重新填满缓冲区。

#define RIO_BUFSIZE 8192typedef struct {    int rio_fd;                 //内部读缓冲区描述符    int rio_cnt;                //内部缓冲区中未读字节数    char *rio_bufptr;           //内部缓冲区中下一个未读的字节    char rio_buf[RIO_BUFSIZE];  //内部读缓冲区}rio_t;     //一个类型为rio_t的读缓冲区// 初始化rio_t结构,创建一个空的读缓冲区// 将fd和地址rp处的这个读缓冲区联系起来void rio_readinitb(rio_t *rp, int fd){    rp -> rio_fd = fd;    rp -> rio_cnt = 0;    rp -> rio_bufptr = rp -> rio_buf;}//是 RIO 读程序的核心,是Unix read函数的带缓冲的版本,供内部调用ssize_t rio_read(rio_t *rp, char *usrbuf, size_t n){    int cnt;    while(rp -> rio_cnt <= 0){  //内部缓冲区空了,重新填        rp -> rio_cnt = read(rp -> rio_fd, rp -> rio_buf, sizeof(rp -> rio_buf));        if(rp -> rio_cnt < 0){            if(errno != EINTR)  //出错返回                return -1;        }         else if(rp -> rio_cnt == 0)  //EOF            return 0;        else            rp -> rio_bufptr = rp -> rio_buf;   //重置指针位置    }    //从内部缓冲区拷贝到用户缓冲区中    cnt = (rp -> rio_cnt < n) ? rp -> rio_cnt : n;    memcpy(usrbuf, rp -> rio_bufptr, cnt);    rp -> rio_bufptr += cnt;    rp -> rio_cnt -= cnt;    return cnt;}//带缓冲输入函数,每次输入一行//从文件rp读出一个文本行(包括结尾的换行符),将它拷贝到usrbuf,并且用空字符来结束这个文本行//最多读maxlen-1个字节,余下的一个留给结尾的空字符ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen){    int rc,n;    char c,*bufp = usrbuf;    for(n = 1; n
0){ if((nread = rio_read(rp, bufp, nleft)) < 0) return -1; //出错 } else if( nread == 0 ) break; //EOF nleft -= nread; bufp += nread; } return (n - nleft);}

实现过程参考 CSAPP 第十章内容。

为什么rio_writen()不需要缓冲呢?

假设将一个HTTP请求写到对应socket的文件描述符的缓冲区,假设缓冲区大小为8K,该请求报文大小为1K,那么如果缓冲区被设置为只有填满才会真正被写入文件,那就是说如果没有提供一个刷新缓冲区的函数手动刷新,还需要额外发送7K的数据将缓冲区填满,这个报文才能真正被写入到socket中。

所以一般带有缓冲区的函数库都会有一个刷新缓冲区的函数,用于将在缓冲区的数据真正写入文件当中,即使缓冲区没有被填满,这正是C标准库的做法。然而如果程序开发人员不小心忘记在写入操作完成后手动刷新,那么该数据便一直驻留在缓冲区,进程也会被阻塞。

做网络应用程序应该用什么I/O函数?

Unix对网络的抽象是一种称为套接字的文件类型,也是文件描述符。标准I/O流,程序能够在同一个流上执行输入和输出,因此从某种意义上来说是全双工的。然后,对流的限制和对套接字的限制,有时候会互相冲突。比如下面两个限制:

  1. 如果中间没有插入对fflush、fseek、fsetpos或者rewind的调用,一个输入函数不能跟随在一个输出函数之后。fflush是清空流缓冲区,后三个函数使用 Unix I/O lseek 函数重置当前的文件位置。
  2. 如果中间没有插入对fseek、fsetpos或者rewind的调用,一个输出函数不能跟随在一个输入函数之后,除非输入函数入到EOF。

在开发网络应用中,对套接字使用 lseek 是非法的。因此对上面限制1 来说还可以采用刷新缓冲区来满足;然而对第二个限制的唯一办法是,对同一个打开的套接字描述符打开两个流,一个用来读,一个用来写。

但是它要求应用程序在两个流上都要调用 fclose,这样才能释放相关存储器资源,多个操作试图关闭同一个描述符,第二个close就会失败,在线程化程序中关闭一个已经关闭了的描述符是会导致灾难的。

引用书上原文:

因此,我们建议你在网络套接字上不要使用标准I/O函数来进行输入和输出。而要使用健壮的RIO函数。如果需要使用格式化的输出,使用 sprintf 函数在存储器中格式化一个字符串,然后用 rio_writen 把它发送到套接口。如果需要格式化输入,使用 rio_readlineb 来读一个完整的文本行,然后用 sscanf 从文本行提取不同的字段。

转载地址:http://qmuoi.baihongyu.com/

你可能感兴趣的文章
vue项目打包后无法运行报错空白页面
查看>>
1136 . 欧拉函数
查看>>
面试题:强制类型转换
查看>>
Decorator模式
查看>>
Template模式
查看>>
Observer模式
查看>>
高性能服务器设计
查看>>
图文介绍openLDAP在windows上的安装配置
查看>>
Pentaho BI开源报表系统
查看>>
Pentaho 开发: 在eclipse中构建Pentaho BI Server工程
查看>>
android中SharedPreferences的简单例子
查看>>
android中使用TextView来显示某个网址的内容,使用<ScrollView>来生成下拉列表框
查看>>
andorid里关于wifi的分析
查看>>
Hibernate和IBatis对比
查看>>
Spring MVC 教程,快速入门,深入分析
查看>>
Ubuntu Navicat for MySQL安装以及破解方案
查看>>
在C++中如何实现模板函数的外部调用
查看>>
HTML5学习之——HTML 5 应用程序缓存
查看>>
HTML5学习之——HTML 5 服务器发送事件
查看>>
SVG学习之——HTML 页面中的 SVG
查看>>