当前位置:网站首页>系统级 I/O与缓冲机制

系统级 I/O与缓冲机制

2020-12-07 17:13:44 osc_tsmpzerj

最近实验室的学弟们貌似对缓冲区很感兴趣,听到很多次在讨论缓冲区。今天也来写篇文章和大家讨论一下。从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 <sys/type.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(char *filename, int flags, mode_t mode);   

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

typedef 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<maxlen; n++){
        if((rc = rio_read(rp, &c, 1)) == 1){     //每次读取一个字符
            *bufp++ = c;
            if(c == '\n')
                break;
        } else if(rc == 0){
            if(n == 1)      //空文件
                return 0;
            else
                break;      //读到部分数据
        } else
            return -1;      //出错
    }

    *bufp = '\0';              //添加字符串结尾符
    return n-1;
}


//带缓冲输入函数
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n)
{
    size_t nleft = n;
    ssize_t nread;
    char *bufp = usrbuf;

    while(nleft > 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 从文本行提取不同的字段。

版权声明
本文为[osc_tsmpzerj]所创,转载请带上原文链接,感谢
https://my.oschina.net/u/4279909/blog/4779269