本文共 6322 字,大约阅读时间需要 21 分钟。
最近实验室的学弟们貌似对缓冲区很感兴趣,听到很多次在讨论缓冲区。今天也来写篇文章和大家讨论一下。从I/O,到缓冲区都会谈到。首先是所有语言都提供了执行I/O的较高级别的工具,例如ANSI C提供了标准I/O库,C++重载了<<和>>等,这些不依赖于系统内核,所以移植性强,而且这个缓冲区的分配长度和优化等细节都是代你处理好了。在Unix系统中,是通过使用有内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的。
高级别I/O函数工作很好,上面也提到了几点优点,为什么还要学习Unix I/O呢?
I/O 概念:
输入/输出(I/O)是在主存和外部设备(如磁盘驱动器、终端和网络)之间拷贝数据的过程。输入操作是从I/O设备拷贝数据到主存,而输出操作是从主存拷贝数据到I/O设备。
所有的I/O设备都被模型化为外文件,所有的输入输出都被当做对相应文件的读和写来执行。设备映射为文件,使得输入输出能够以一种统一且一致的方式来执行。
#include#include #include int open(char *filename, int flags, mode_t mode); //将filename转换为一个文件描述符, 成功则返回非负的新的文件描述符,出错返回-1//flags 指明了进程如何访问这个文件//mode 指定了新文件的访问权限位
#includeint close(int fd); //关闭此文件描述符对应的文件,成功返回09,出错返回-1
#includessize_t read(int fd, void *buf, size_t n); //从文件描述符为fd的当前文件位置,拷贝最多n个字节到存储器位置buf。//成功返回实际传送的字节数,若EOF则为0,出错返回-1
#includessize_t write(int fd, const char *buf, size_t n); //成功返回写的字节数,出错则为-1//从存储器位置buf拷贝至多n个字节到描述符fd的当前文件位置。//成功返回写的字节数,出错则为-1
A:size_t 被定义为 unsigned int,而ssize_t(有符号的大小)被定义为 int。read函数返回一个有符号的大小,而不是一个无符号的大小,是因为出错返回-1,这使得read的最大值减小了一半,从4G减小到了2G。
“不足值”的情况:指的是某些情况下,read和write传送的字节比应用程序要求的要少,这些不足值不表示有错误。
造成这种情况的原因有:在像网络程序这样容易出现不足值的应用中,RIO(Robust 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)
现在假设内核设的缓存是100字节,如果你使用write,且buff的size是10字节,当你要把10个同样的buff写到文件时,需要调用10次write,也就是10次系统调用,此时因为延迟写的技术,并没有写到硬盘,如果想立即写入硬盘,需调用fsync。(涉及写操作机制几个概念,同步写机制、延迟写机制、异步写机制,此处不说了,可以查一下)
标准I/O,也就是带缓存的IO,也称为用户态的缓存,区别于内核所设的缓存。假设缓存长度为50字节,把100字节的数据写到文件,只需2次系统调用,因为先把数据写到流缓存,当其满或者手动刷新之后才填入内核缓存,所以2次就够了。
至于究竟写到了文件中还是内核缓冲区中,对于进程来说是没有差别的,如果进程A和进程B打开同一文件,进程A写到内核缓冲区中的数据从进程B也能读到,因为内核空间是进程共享的。
C标准库这类缓冲区不具有这一特性,因为进程的用户空间是独立的。下面具体来看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 不会返回不足值}
每次调用 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; n0){ if((nread = rio_read(rp, bufp, nleft)) < 0) return -1; //出错 } else if( nread == 0 ) break; //EOF nleft -= nread; bufp += nread; } return (n - nleft);}
实现过程参考 CSAPP 第十章内容。
假设将一个HTTP请求写到对应socket的文件描述符的缓冲区,假设缓冲区大小为8K,该请求报文大小为1K,那么如果缓冲区被设置为只有填满才会真正被写入文件,那就是说如果没有提供一个刷新缓冲区的函数手动刷新,还需要额外发送7K的数据将缓冲区填满,这个报文才能真正被写入到socket中。
所以一般带有缓冲区的函数库都会有一个刷新缓冲区的函数,用于将在缓冲区的数据真正写入文件当中,即使缓冲区没有被填满,这正是C标准库的做法。然而如果程序开发人员不小心忘记在写入操作完成后手动刷新,那么该数据便一直驻留在缓冲区,进程也会被阻塞。
Unix对网络的抽象是一种称为套接字的文件类型,也是文件描述符。标准I/O流,程序能够在同一个流上执行输入和输出,因此从某种意义上来说是全双工的。然后,对流的限制和对套接字的限制,有时候会互相冲突。比如下面两个限制:
在开发网络应用中,对套接字使用 lseek 是非法的。因此对上面限制1 来说还可以采用刷新缓冲区来满足;然而对第二个限制的唯一办法是,对同一个打开的套接字描述符打开两个流,一个用来读,一个用来写。
但是它要求应用程序在两个流上都要调用 fclose,这样才能释放相关存储器资源,多个操作试图关闭同一个描述符,第二个close就会失败,在线程化程序中关闭一个已经关闭了的描述符是会导致灾难的。引用书上原文:
因此,我们建议你在网络套接字上不要使用标准I/O函数来进行输入和输出。而要使用健壮的RIO函数。如果需要使用格式化的输出,使用 sprintf 函数在存储器中格式化一个字符串,然后用 rio_writen 把它发送到套接口。如果需要格式化输入,使用 rio_readlineb 来读一个完整的文本行,然后用 sscanf 从文本行提取不同的字段。
转载地址:http://qmuoi.baihongyu.com/