尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux-文件缓冲区

Linux-文件缓冲区 摘要本文围绕 Linux 文件系统与缓冲区机制展开讲解。第一部分从一切皆文件的设计理念出发说明操作系统如何通过 struct file 与函数指针表统一管理键盘、显示器等各类 IO 设备并借助多态思想实现上层接口与底层实现的解耦。第二部分深入剖析缓冲区的本质与作用通过菜鸟驿站的类比解释缓冲区如何减少系统调用次数、提升进程效率并详细对比了全缓冲、行缓冲、无缓冲三种刷新策略以及进程退出、fflush 等特殊刷新时机。文章还通过 fork 与重定向的实战样例演示了语言级缓冲区与内核缓冲区在刷新行为上的差异最后介绍了标准输出与标准错误的重定向用法帮助读者在调试时快速定位问题。目录1.理解Linux下一切皆文件2.缓冲区2.1什么是缓冲区2.2为什么要引入缓冲区机制2.3缓冲类型2.4补充知识2.5标准错误1.理解Linux下一切皆文件我们在进程概念讲过计算机软硬件其实是层状结构的操作系统要不要管理硬件要的怎么管理先描述再组织。即未来我们能猜测操作系统内部一定要有能把所有硬件描述起来的数据结构描述起来之后再把所有描述的数据结构进行管理操作系统中怎么描述硬件呢我们可以称之为struct device即设备它里面包含该设备的各种属性。但是每种设备对应的读写方式肯定不同如读写键盘肯定是不一样的因此需要读键盘方法void readkeyboard();也有写键盘方法void writekeyboard();因为硬件本身是不一样的所以每个设备的读写方法一定是不一样的对于所有的读写设备我们都叫做IO设备。因此在计算机的外设当中有些设备只读有些设备只写有些设备读写都有。但是不同的设备访问方式一定是不同的那么是谁来访问这些IO设备呢访问设备全都是进程在访问。只要用户和操作系统交互都是通过外壳程序进行交互的外壳程序执行用户所对应的任务都是通过创建子进程的方式来完成的所以用户直接和操作系统打交道是通过以进程的方式在操作系统内创建任务的形式然后让操作系统执行进程所对应的代码来访问硬件的软件也同理。因为Linux下一切皆文件所以这些键盘鼠标显示器这类的设备都叫做文件因此在访问之前必须先把这些文件都先打开在操作系统看来其实是进程要打开对应的设备或者文件。在进程视角操作系统要给每一个进程维护一个文件描述符表在这个表里有一个数组代表所打开的文件这个数组可以动态扩容。因为我们默认会打开三个文件stdin/stdout/stderr而每个文件对应的描述结构体我们叫做struct file然后里面有读方法和写方法void(*read)();void(*write)();每个文件打开时的函数指针且都有函数指针变量。如我们需要使用键盘时就创建键盘的struct file里面的void(*read)的函数指针就指向键盘的读方法void readkeyboard();并且将void(*write))指向键盘的写方法。因此每个被创建的指针就指向对应的读/写方法。此时在进程视角我们用函数指针指向底层的函数即可不用区分每个设备的方法不同了因此这叫做一切皆文件是站在进程的视角在struct file结构体之上看待文件的视角在内核源代码的struct file有转到定义有struct file_operations { struct module *owner; loff_t (*llseek) (struct file *, loff_t, int); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t); ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t); int (*readdir) (struct file *, void *, filldir_t); unsigned int (*poll) (struct file *, struct poll_table_struct *); int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long); long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); long (*compat_ioctl) (struct file *, unsigned int, unsigned long); int (*mmap) (struct file *, struct vm_area_struct *); int (*open) (struct inode *, struct file *); int (*flush) (struct file *, fl_owner_t id); int (*release) (struct inode *, struct file *); int (*fsync) (struct file *, struct dentry *, int datasync); int (*aio_fsync) (struct kiocb *, int datasync); int (*fasync) (int, struct file *, int); int (*lock) (struct file *, int, struct file_lock *); ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int); unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long); int (*check_flags)(int); int (*flock) (struct file *, int, struct file_lock *); ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int); ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int); int (*setlease)(struct file *, long, struct file_lock **); };这些就是函数指针表Linux中打开文件要为我们创建struct file三个核心①文件属性②文件内核缓冲区③底层设备文件的操作表。这种上层一切皆文件下层有不同的文件显示器/磁盘这叫做多态C初阶一切皆文件就是基类struct file下面的那些文件就是子类键盘。当时我们讲解多态的时候就有一个指针这个指针会指向虚表虚表里面就是指针数组指针数组就是子类当中的方法。实际上计算机里面一切问题都可以通过添加一层软件层来解决对于上层一种read方法对应了下层非常多的如keyboardread……因此上层的read方法相当于操作系统给我们提供的系统调用而这些函数叫做拷贝函数相当于我拿文件描述符得到对应的方法就行。⾸先在windows中是⽂件的东西它们在linux中也是⽂件其次⼀些在windows中不是⽂件的东西⽐如进程、磁盘、显⽰器、键盘这样硬件设备也被抽象成了⽂件你可以使⽤访问⽂件的⽅法访问它们获得信息甚⾄管道也是⽂件将来我们要学习⽹络编程中的socket套接字这样的东西,使⽤的接⼝跟⽂件接⼝也是⼀致的。这样做最明显的好处是开发者仅需要使⽤⼀套 API 和开发⼯具即可调取 Linux 系统中绝⼤部分的资源。举个简单的例⼦Linux 中⼏乎所有读读⽂件读系统状态读PIPE的操作都可以⽤read函数来进⾏⼏乎所有更改更改⽂件更改系统参数写 PIPE的操作都可以⽤write函数来进⾏。2.缓冲区2.1什么是缓冲区之前我们学习进程终止时有_exit函数和exit函数的区别我们之前说过_exit会直接结束进程但是exit会刷新缓冲区关闭文件流这类的操作我们之前说过缓冲区不属于内核但是文件缓冲区为啥属于内核层面的东西其实缓冲区的本质就是一段内存空间。如张三和李四是好朋友但是张三在云南而李四在北京李四过生日了张三给李四买了礼物但是不可能让张三自己到北京去送礼物做为张三来说这样浪费大量张三的时间张三和李四楼下都有一个菜鸟驿站有了菜鸟驿站后张三只需要把礼物交给菜鸟驿站对于张三来说他已经把礼物送出去了但是实际上还没送到李四手里面因为对张三来说把礼物包装成快递相当于已经把礼物送出去了剩下的事情是快递公司的事情了。此时的菜鸟驿站本质上起到了缓存的角色。而礼物就是数据把礼物给菜鸟的过程称之为写入write本质是拷贝。但是把礼物交给菜鸟驿站送到李四手上之前还需要自己发送即从云南到北京这段时间总得有人走只不过不是张三来走罢了。因此缓存最大的意义提高使用缓存的人或者进程的效率因为这段送礼物的路不需要张三来走了因此缓存节省了张三的时间但是没有节省这段路程的时间而张三节省下来的时间就可以拿去干自己的事情去了。因为效率提高了所以允许了进程在单位时间内做更多的工作。因此这就变相提高了使用缓存的效率而把快递寄给李四的过程就叫做缓存的刷新但是对于菜鸟驿站来说如果来一个快递我们就发一个快递那么相当于一辆货车只运送一个快递那么这肯定不行。因此对菜鸟驿站来说它有个特点它并不会立即把它自己得到的快递立即发送出去因为缓冲区可以缓存足够大的空间它允许数据在缓冲区中积压。因此我们可以一次发送多个快递即我们可以以一次刷新多次数据从而变相减少IO的次数。这样对于菜鸟驿站/缓冲区来说这样也能提高效率批量化刷新对于菜鸟驿站的老板来说什么时候应该发送快递呢相当于菜鸟驿站的老板来决定使用什么样的刷新策略①无缓冲立即刷新②有缓冲行刷新如\n就刷新显示器就是这种③有缓冲写满再刷新普通文件采取这种方式。这三种是在语言层面上的。除了这三种策略缓冲区还有两种情况①进程退出会主动刷新缓冲区②进程强制刷新如fflush(stdout)。如#includestdio.h #includeunistd.h int main() { printf(hello boys and girls\n); sleep(3); return 0; }之前我们说过这样会让我们观察到先打印一串字符串过几秒后结束如果我们去掉\n则会有因为printf是先运行但是数据没刷新出来的原因是这个数据是在缓冲区当中这个缓冲区叫做FILE缓冲区不是内核的缓冲区因为在内核中肯定是直接刷新了那么FILE缓冲区在哪struct FILE就是一个结构体在C语言中我们可以对输入输出进行格式化C语言访问文件都是通过FILE来访问的包括stdin/stdout/stderr。那么struct FILE还有什么不仅有我们之前说过的fopen函数还有FILE结构体内部为我们提供语言级别的缓冲区类似这样struct FILE { int fd;//文件描述符 char* inbuffer;//输入缓冲区 char* outbuffer;//输出缓冲区 };如果是以读的方式打开那么这个inbuffer和outbuffer可能不需要但是它一定有对应的指针字段来维护自己的缓冲区实际上感兴趣的可以去shell下执行这两个指令vim /usr/include/stdio.h vim /usr/include/libio.h可以在上面搜索FILE/IO_FILE能看到对应的结构CentOS下。使用C语言文件IO的整个过程当我们启动进程时默认会打开012这三个文件stdin/stdout/stderr当我们调用printf时默认访问的是stdout当我们调用scanf时默认访问的是stdin。如果我们打开了另外一个文件此时返回的文件描述符就是3而这个文件也会封装成类似FILE* fp这样的结构用来指向FILE对象。如果我们要进行写操作我们需要获得数据如scanf或getline或自己定义的数组但是我们写的数据要从键盘来往往我们喜欢从stdout来读取数据到缓冲区里然后把缓冲区的数据如hello world如果我们想要把这个数据写到对应的文件里而FILE* fp内部会指向语言级别的缓冲区这就叫做把数据从用户拷贝给语言如果我们通过fputs来打印到显示器上那么我们就通过底层的write来写到显示器上因为write要接收文件描述符作为参数此时有了文件描述符因此把数据拷贝到语言级别的缓冲区然后再刷新到显示器上即可。我们自己也会打开文件FILE* fp fopen(,);而我们会得到fp这个就相当于我们在语言层面上得到了struct FILE对象在struct FILE有两个核心东西struct FILE { int fd;//文件描述符 buffer;//类型随意缓冲区 };当我们fopen打开文件时得到了文件描述符此时的文件描述符就被设置成int fd了同时创建一个buffer缓冲区如果我们想要读取数据如果是通过键盘来读取就会从0号文件来读取数据此时底层会通过类似fgets来得到数据然后通过fputs来把数据拷贝到buffer里面缓冲区而fputs底层封装了write而此时的write的参数是此时fp里的文件描述符然后通过文件描述符来找到对应的buffer此时就把数据拷贝到文件缓冲区当中了剩下的事情是操作系统的事情了然后底层通过刷新策略来决定什么时候刷新缓冲区只要把数据交给操作系统我们【用户】就认为数据写入完毕相当于我们如果没有在打印时加\n那么就会把数据放到buffer缓冲区里此时的数据不会立即写到内核而是在struct FILE暂存着直到后来进程运行结束了因为进程结束会主动刷新缓冲区所以我们才能看到打印出来的数据①我们之前所说的缓冲区全都是语言级的缓冲区和内核没有任何关系②这个缓冲区在哪struct FILE内部③为什么要有语言级别的缓冲区④如何重新理解printf和scanf的格式化过程③调用系统调用是有成本的。如我们之前学习STL时当某个容器的空间满了的时候它正常应该扩容1.5或2倍原来的空间。为什么因为如果多了一个字节就需要多调用一次new/malloc但是本质是要调用系统调用的那么就会浪费很多时间因此我们减少扩容的次数可以减少系统调用的次数从而提高效率这个缓冲区也是一样的只要不遇到进程结束或者缓存区空间满的情况就尽可能不调用系统调用从而提高了效率换句话说缓冲区的作用减少系统调用的次数。那么C语言自己为什么要提供缓冲区目前我们提供的缓冲区背景都是IO背景即谈论对应的FILE*结构体如果不提供对应缓冲区那么直接会把数据写到内核里这不就相当于张三直接把礼物交给李四这样的话效率很低。因为C语言提供了对应缓冲区所以能先把数据写到缓冲区里只要不符合刷新条件那么就不会进行底层的系统调用因此这可以加速IO函数的调用效率 ------ 提高C语言使用IO接口的效率—— 单位时间内能执行更多的代码④因此printf格式化输出是格式化12345 —— 12345到了缓冲区里读取也是类似的道理而是把读取的数据放到stdin的缓冲区里即顺序a.格式化b.格式化结果写到FILE缓冲区里c.检测是否需要刷新d.write(3,outbuffer);如果我们fflush相当于我们把语言缓冲区的数据交给内核了扩展①C呢因此C里的cin,cout,cerr是不是也有缓冲区肯定是的如cout hello 3.14 100;虽然这些类型不一样但是在显示器上它们都是字符因此可以执行格式化输出②一个样例。#includestdio.h #includeunistd.h #includestring.h #includesys/types.h #includefcntl.h #includesys/stat.h int main() { //C接口 const char* s1 hello printf\n; printf(%s,s1); const char* s2 hello fprintf\n; fprintf(stdout,%s,s2); const char* s3 hello fwrite\n; fwrite(s3,strlen(s3),1,stdout); //系统调用 const char* s4 hello write[syscall]\n; write(1,s4,strlen(s4)); //创建子进程 fork(); return 0; }我们运行有但是我们这样操作touch log.txt ./a.out log.txt有为什么当我们重定向的时候打印的消息变成7条了①对于printf/fprintf/fwrite这三个函数这些函数都是C语言的函数因此虽然它们把自己打印的字符串放到了缓冲区里但是他会立即刷新也就是说它会立即调用write函数从缓冲区到内核需要调用系统调用这三个函数底层都是write的系统调用因此如果按照顺序打印到显示器上就是它们按照顺序执行write函数因此会有这样的结果这个很好理解因为程序在局部上是从上往下执行的所以结果是这样为什么会有7行相同的框代表相同的语句②我们发现凡是C库函数它都会打印两次但是系统调用依旧只有一次系统调用只有一次因为当我们重定向往文件写入时系统调用依旧是直接写给操作系统了因此不管往显示器上写还是往文件写只会写一行调用一次。为什么C库函数会写两次因为我们程序打印时是往stdout去打印的当我们在往stdout写时其实是把数据写到stdout的缓冲区的。那不对啊之前我们说过缓冲区是按照行刷新的啊我们加了\n如果我们往行上写那么就是按行刷新但是我们现在重定向了重定向是往普通文件去写所以它在底层自动做了刷新策略的隐式调整变为全刷新了全缓冲只有把文件写满了才打印因为三个字符串并不会把stdout写满所以我们看不到。但是我们调用write时它会直接写到内核当中不需要经过缓冲区③当我们fork创建子进程时前三个字符串printf/fprintf/fwrite还在stdout内部但是write已经写到内核了因此子进程会继承下父进程的缓冲区数据后面父子进程都退出因为进程结束也会刷新自己的缓冲区所以父子各刷新三个字符串因此就有了7条字符串父子各自执行一次刷新。如何理解系统调用到最前面去打印了因为虽然系统调用在最后面但是系统调用会直接把数据写入到内核里它是第一个写到内核的语句剩下的三个语句只会在缓冲区里只有进程退出了才会调用write那么操作系统内核关于文件缓冲区的刷新方式是什么我们相当于把数据刷新到外设当中不同设备刷新策略不一样一般而言内核刷新文件缓冲区是全缓冲的写满才刷新但是如果是显示器那么它也会行刷新。但是操作系统刷新文件缓冲区往往更复杂它不是单纯写满行刷新策略在内核中它往往有个单独的执行流它会根据内存的使用情况来动态刷新即便刷新条件不满足它会根据不同的情况来刷新它刷新的策略很复杂那么如何把文件缓冲区的数据强制刷新到硬件上呢系统调用接口#include unistd.h int fsync(int fd);一般缓冲区由操作系统维护如果我们想立马把数据刷新到外设里因此fsync同步文件在内存当中的数据到存储设备传递的参数是文件描述符它根据文件描述符找到对应的缓冲区来刷新数据之后我们使用它。本质将数据从内核刷新到外设之前的fflush是从用户刷新到内核的缓冲区是内存空间的⼀部分。也就是说在内存空间中预留了⼀定的存储空间这些存储空间⽤来缓冲输⼊或输出的数据这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输⼊设备还是输出设备分为输⼊缓冲区和输出缓冲区。2.2为什么要引入缓冲区机制读写⽂件时如果不会开辟对⽂件操作的缓冲区直接通过系统调⽤对磁盘进⾏操作(读、写等)那么每次对⽂件进⾏⼀次读写操作时都需要使⽤读写系统调⽤来处理此操作即需要执⾏⼀次系统调⽤执⾏⼀次系统调⽤将涉及到CPU状态的切换即从⽤⼾空间切换到内核空间实现进程上下⽂的切换这将损耗⼀定的CPU时间频繁的磁盘访问对程序的执⾏效率造成很⼤的影响。为了减少使⽤系统调⽤的次数提⾼效率我们就可以采⽤缓冲机制。⽐如我们从磁盘⾥取信息可以在磁盘⽂件进⾏操作时可以⼀次从⽂件中读出⼤量的数据到缓冲区中以后对这部分的访问就不需要再使⽤系统调⽤了等缓冲区的数据取完后再去磁盘中读取这样就可以减少磁盘的读写次数再加上计算机对缓冲区的操作⼤ 快于对磁盘的操作故应⽤缓冲区可⼤ 提⾼计算机的运⾏速度。⼜⽐如我们使⽤打印机打印⽂档由于打印机的打印速度相对较慢我们先把⽂档输出到打印机相 应的缓冲区打印机再⾃⾏逐步打印这时我们的CPU可以处理别的事情。可以看出缓冲区就是⼀块内存区它⽤在输⼊输出设备和CPU之间⽤来缓存数据。它使得低速的输⼊输出设备和⾼速的CPU能够协调⼯作避免低速的输⼊输出设备占⽤CPU解放出CPU使其能够⾼效率⼯作。2.3缓冲类型标准I/O提供了3种类型的缓冲区。①全缓冲区这种缓冲⽅式要求填满整个缓冲区后才进⾏I/O系统调⽤操作。对于磁盘⽂件的操作通常使⽤全缓冲的⽅式访问。②⾏缓冲区在⾏缓冲情况下当在输⼊和输出中遇到换⾏符时标准I/O库函数将会执⾏系统调⽤操作。当所操作的流涉及⼀个终端时例如标准输⼊和标准输出使⽤⾏缓冲⽅式。因为标准I/O库每⾏的缓冲区⻓度是固定的所以只要填满了缓冲区即使还没有遇到换⾏符也会执⾏I/O系统调⽤操作默认⾏缓冲区的⼤⼩为1024。③⽆缓冲区⽆缓冲区是指标准I/O库不对字符进⾏缓存直接调⽤系统调⽤。标准出错流stderr通常是不带缓冲区的这使得出错信息能够尽快地显⽰出来。除了上述列举的默认刷新⽅式下列特殊情况也会引发缓冲区的刷新1.缓冲区满时2.执⾏flush语句3.进程结束2.4补充知识我们把代码改成这样#includestdio.h #includeunistd.h int main() { while(1) { sleep(1); } return 0; }此时的程序是一个死循环我们运行就会变成一个进程我们新开一个机器执行以下代码有ps ajx | grep mybuffer此时有实际上这个1212就是该进程的PID因此我们执行ls /proc/1212 -l有如果我们执行ls /proc/1212/fd -l有虽然我们没有打开任何文件但是仍然默认打开三个文件stdin/stdout/stderr而对应的/dev/pts/0代表我们进程启动时默认关联的设备。2.5标准错误我们一般用过以下语句进行输出#includestdio.h #includeunistd.h #includeiostream int main() { printf(hello printf\n); perror(hello perror); std::cout hello std::cout std::endl; std::cerr hello std::cerr std::endl; return 0; }我们的运行结果为用g编译那么它们都会默认往显示器上打印。但是如果我们这样./a.out log.txt然后再cat log.txt有为什么我们输出重定向到log.txt那些标准错误还是往显示器上打印呢因为我们输出重定向是把stdout对应的文件改为了log.txt文件即把一号文件改了管stderr什么事它又没让stderr输出重定向到log.txtstderr也是往显示器上打印的因为2文件没有被重定向所以不会把标准错误进行重定向因此我们将来可以把正常输出和错误输出分开类似这样./a.out ok.txt 2error.txt此时就形成了两个文件如果是标准输出是在ok.txt如果是标准错误是在error.txt程序员在Debug时需要关注的是error.txt即标准错误对应的信息这样就能够更快找到错误因此原来我们所说的重定向它是一种简写它应该为//原./a.out log.txt ./a.out 1log.txt表示的是把标准输出重定向到某个文件。那么如果我想把标准输出和标准错误都重定向到一个文件呢./a.out log.txt此时1号文件已经指向到log.txt文件了因此我们让2号文件指向1文件的地址./a.out log.txt 21此时就能重定向了
返回列表