最近实验室的学弟们貌似对缓冲区很感兴趣,听到很多次在讨论缓冲区。今天也来写篇文章和大家讨论一下。从I/O,到缓冲区都会谈到。首先是所有语言都提供了执行I/O的较高级别的工具,例如ANSI C提供了标准I/O库,C++重载了<<和>>等,这些不依赖于系统内核,所以移植性强,而且这个缓冲区的分配长度和优化等细节都是代你处理好了。在Unix系统中,是通过使用有内核提供的系统级Unix I/O函数来实现这些较高级别的I/O函数的。
高级别I/O函数工作很好,上面也提到了几点优点,为什么还要学习Unix I/O呢?
- 了解Unix I/O可以帮你理解其他的系统概念。比如进程、存储器层次结构、链接和加载等。
- 有时候除了使用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传送的字节比应用程序要求的要少,这些不足值不表示有错误。
造成这种情况的原因有:
- 读时遇到EOF。要求的字节数超过了读缓冲区内未读的字节的数量。
- 从终端读取文本行。如果打开文件是与终端想关联的(比如键盘和显示器),那么每个read函数将一次传送一个文本行,返回的不足值等于文本行的大小。
- 读和写socket。内部缓冲约束和较长的网络延迟会引起read和write返回不足值。
用RIO包健壮地读写
在像网络程序这样容易出现不足值的应用中,RIO(Robust I/O)包提供了方便、健壮和高效的I/O。
RIO提供了两类不同的函数:
- 无缓冲的输入输出函数。直接在存储器和文件之间传送数据,没有应用级缓冲。对将二进制数据读写到网络和从网络中读写二进制数据尤其有用。
- 带缓冲的输入函数。允许你高效地从文件中读取文本行和二进制数据,这些内容缓存在应用级缓冲区内。带缓冲的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流,程序能够在同一个流上执行输入和输出,因此从某种意义上来说是全双工的。然后,对流的限制和对套接字的限制,有时候会互相冲突。比如下面两个限制:
- 如果中间没有插入对fflush、fseek、fsetpos或者rewind的调用,一个输入函数不能跟随在一个输出函数之后。fflush是清空流缓冲区,后三个函数使用 Unix I/O lseek 函数重置当前的文件位置。
- 如果中间没有插入对fseek、fsetpos或者rewind的调用,一个输出函数不能跟随在一个输入函数之后,除非输入函数入到EOF。
在开发网络应用中,对套接字使用 lseek 是非法的。因此对上面限制1 来说还可以采用刷新缓冲区来满足;然而对第二个限制的唯一办法是,对同一个打开的套接字描述符打开两个流,一个用来读,一个用来写。
但是它要求应用程序在两个流上都要调用 fclose,这样才能释放相关存储器资源,多个操作试图关闭同一个描述符,第二个close就会失败,在线程化程序中关闭一个已经关闭了的描述符是会导致灾难的。
引用书上原文:
因此,我们建议你在网络套接字上不要使用标准I/O函数来进行输入和输出。而要使用健壮的RIO函数。如果需要使用格式化的输出,使用 sprintf 函数在存储器中格式化一个字符串,然后用 rio_writen 把它发送到套接口。如果需要格式化输入,使用 rio_readlineb 来读一个完整的文本行,然后用 sscanf 从文本行提取不同的字段。