ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【Linux系统编程】理解“缓冲区”

【Linux系统编程】理解“缓冲区” 文章目录引入1. 什么是缓冲区2. 缓冲类型与刷新策略3. FILEC语言文件IO全流程4. 为什么要有缓冲区5. 解决历史遗留问题6. 研究一段有趣的代码7. 内核缓冲区何时刷新8. 聊聊标准错误之前我们简单了解过缓冲区知道有用户级缓冲区和内核级缓冲区。今天这篇文章我们来更加深入地了解一下缓冲区。引入这张图我们已经很熟悉了。这里我们来强调三个要素第一点通过struct file我们可以直接或间接地找到文件相关的属性诸如f_flags打开标志。如O_RDONLY、O_NONBLOCK、O_SYNC。由open系统调用传递。f_mode文件模式访问权限。表示FMODE_READ、FMODE_WRITE。与文件系统inode权限分开。f_pos文件偏移量。当前读写位置逻辑文件中的偏移量。对于顺序I/O至关重要。锁/更新需要谨慎。等等。第二点我们上一篇文章讲到的通过struct file还可以找到文件操作方法集。第三点内核中一定维护了与文件IO相关的内核缓冲区。【Linux系统编程】基础IO第二讲——文件描述符文章中2.3的补充了解大家可以再次回顾一下。再来回忆一下之前我们进程退出的时候我们对比了exit函数 和 系统调用_exit的区别exit() 是库函数它会在调用 _exit() 系统调用之前先执行用户态的清理工作其中就包括刷新所有标准I/O流的缓冲区。然后再调用 _exit() 陷入内核进行相关的各种操作比如进程相关资源的释放等_exit() 是系统调用它直接进入内核不会进行任何用户态的缓冲区刷新。因此如果程序直接调用 _exit() 退出仍在用户态缓冲区中的数据就会丢失。甚至当时我们给出了结论这个缓冲区一定不在内核中那怎么回事呢刚才说了内核维护了缓冲区这里有提到这个缓冲区一定不在内核。原因就在于一个是用户缓冲区、一个是内核缓冲区1. 什么是缓冲区缓冲区的本质就是一段内存空间也就是说在内存空间中预留了⼀定的存储空间这些存储空间用来缓冲输入或输出的数据这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设备分为输入缓冲区和输出缓冲区。下面我们举一个例子来理解缓冲区假如你的朋友马上要过生日了你想要送他一个礼物比如一个键盘。但是现在你在宁夏上学、你朋友在河南上学。那你要怎么送过去呢难道你要骑一个小电驴过去送吗那这样你要耗费大量的时间和精力。那我们知道其实并不需要这么麻烦。因为在你和你朋友学校都有菜鸟驿站那么此时你只需下楼把礼物交给菜鸟驿站邮寄即可然后就可以直接回去了。回去你的室友问你你的键盘去哪了你说送给我朋友了但是此时你真的把礼物送到朋友手里了嘛。其实并没有还在菜鸟驿站暂存着呢。但是站在你的角度你就可以认为礼物已经送出去了因为后面菜鸟驿站会通过某种快递把礼物真的送到你朋友那里。那这里的菜鸟驿站就对应了缓冲区送的礼物就对应IO的数据我们把礼物拿给菜鸟驿站就相当于调用了诸如fwrite这样的函数本质就是拷贝。那对于进程来说调用完fwrite就可以继续执行后面的代码了。相比于第一种方式就节省了时间就有时间去做更多其它的事情了。所以缓冲区最大的意义就是可以提高使用缓冲区的进程的效率。等到了某个时刻菜鸟驿站把礼物寄送出去这就对应缓冲区的刷新。对于菜鸟驿站来说你来邮寄键盘假设现在只有这一件包裹菜鸟驿站会单独只把这一件货物进行运输嘛来一个人邮寄东西就单独运输一次会这样做吗那这样成本就太高了这个菜鸟驿站干不了多久就得倒闭。正常应该怎么做你把键盘拿过去菜鸟驿站并不会立即发出去而是先暂存起来等到满足某种条件比如仓库放满了/货物够装一车了然后一次运输多件货物这样效率不就更高了嘛。即缓冲区允许数据在缓冲区中暂存积压然后一次刷新更多的数据减少IO的次数。所以目前对于缓冲区两点理解第一点站在使用者进程的角度可以提高使用者进程的效率第二点缓冲区可以暂存挤压数据然后做批量化刷新基于这两点缓冲区就可以实现提高效率的功能。2. 缓冲类型与刷新策略那菜鸟驿站应该在什么时候发快递呢即缓冲区即刷新策略这与缓冲类型密切相关。。标准I/O提供了3种类型的缓冲区全缓冲这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用write刷新缓冲区。对于磁盘文件的操作通常使用全缓冲的方式访问。行缓冲在行缓冲情况下当在输入和输出中遇到换行符时会触发缓冲区刷新。当流涉及终端如stdin/stdout时使用行缓冲。由于标准I/O库每行的缓冲区长度是固定的一旦缓冲区填满即使没有换行符也会执行I/O系统调用刷新。无缓冲无缓冲区是指标准I/O库不对数据进行缓存立即调用系统调用刷新。除了上述列举的默认刷新方式下列特殊情况也会引发缓冲区的刷新1.缓冲区满时2.进程强制刷新如执行fflush3.进程退出3. FILE下面我们来写个代码这个例子其实我们之前已经演示过了。运行看看结果先打印然后休眠3秒结束。然后我们把\n去掉重新运行会发现这次是先休眠再打印。那来问大家一下这里printf和sleep谁先运行毋庸置疑是printf先运行那为什么去掉\n我们看到的现象是先休眠再打印呢不管我们有没有加\n我们的这个字符串都会被暂存到缓冲区里面。那为什么两个程序打印的时间不一样呢原因其实是因为两个程序的缓冲区刷新的时机不同。上面我们提到stdin/stdout使用行缓冲所以第一次有\n就触发了缓冲区刷新底层调用系统调用把数据从用户缓冲区拷贝到内核缓冲区接着在合适的时机刷新到终端。我们就看到是先打印再休眠。而去掉\n就不会触发缓冲区刷新字符串就一直暂存在缓冲区直到休眠完最后程序退出刷新缓冲区然后我们才看到。那sleep期间这个数据在哪呢当然就是在缓冲区中是哪种缓冲区呢我们上面提到有用户级缓冲区和内核级缓冲区。这里当然是用户级缓冲区或者叫语言级缓冲区那这个缓冲区在哪呢我们之前有提过在FILE结构体内部FILE结构体内部封装了用户级缓冲区并且必定封装了文教描述符fd。C语言标准库中所有文件访问函数fopen、fread、fwrite、fprintf、fclose都必须通过 FILE* 流指针来操作。这是C标准规定的抽象接口保证了代码在不同操作系统Windows、Linux、macOS上的可移植性。你无法在标准C中直接使用文件描述符因为那是操作系统特有的概念。我们可以来看一下Linux平台下FILE结构体的定义C语言文件IO全流程一个进程被成功创建默认打开三个流标准输入、标准输出、标准错误。假设现在这个进程要向一个文件中写入一个字符串比如一个字符数组存的字符串/一个常量字符串存在常量区都在用户空间那首先打开这个文件会获得一个FILE*的文件指针。然后比如我们调用fputs向打开的文件中写入字符串那此时就直接写入到文件中了嘛并不是而是先会把数据从用户空间拷贝到FILE中维护的用户级缓冲区。FILE内部封装了文件描述符fd而fputs底层又封装了write系统调用。等到合适的时机比如缓冲区满或者进程退出磁盘文件此时用户级缓冲区会触发刷新那么就会调用write系统调用把数据从用户及缓冲区拷贝到内核缓冲区。然后再由操作系统在合适的时机将数据刷新到文件中。那么再来改一下上面的代码结果肯定还是先休眠再打印大家看到我们这里调用了好多次printf但是实际是调用一次缓冲区就刷新一次嘛。并不是很明显这里是所有的数据都先挤压到了用户级缓冲区没有\n也没有填满缓冲区所以不会触发刷新直到休眠完进程退出底层调用系统调用刷新了用户缓冲区到内核缓冲区然后由操作系统刷新到显示器。这是什么不就是我们上面说的菜鸟驿站并不会来一个货物单独邮寄一次而是会先暂存堆积起来然后一次运输大量货物提高效率。即缓冲区允许数据挤压然后批量化刷新提高效率C库函数底层封装了系统调用这样就减少了系统调用的次数系统调用是有成本的所以这样做性能自然大幅提升。4. 为什么要有缓冲区所以我们之前经常提到的缓冲区是指用户级缓冲区他在哪呢在FILE结构体中维护那为什么需要缓冲区呢第一点就刚才上面提到的缓冲区允许数据挤压然后批量化刷新提高效率C库函数底层封装了系统调用这样就减少了系统调用的次数系统调用是有成本的所以这样做性能自然大幅提升第二点我们这里提到的用户级缓冲区是C语言提供的也叫语言级缓冲区那C语言为什么要提供呢如果C语言不提供缓冲区那么我们调用库函数进行IO数据就没地方暂存就需要直接调用底层系统调用你自己送礼物。但有了缓冲区的话我调用库函数只需先把数据拷贝到缓冲区然后就可以直接返回了等到缓冲区刷新的时候由操作系统调用系统调用一次IO多组数据使用菜鸟驿站送礼物。这就加快了IO函数的调用速度单位时间内就可以执行更多的C代码了。之前我们提过printf和scanf是格式化输入输出格式化后的内容放到哪里了直接赋给变量或直接打印到显示器了嘛。不是的也是先放到了缓冲区里然后看如果你的数据带了\n那就触发刷新调用write系统调用如果没有就先暂存起来。总结读写文件时如果不会开辟对文件操作的缓冲区直接通过系统调用对磁盘进行操作(读、写等)那么每次对文件进行一次读写操作时都需要使用读写系统调用来处理此操作即需要执行一次系统调用执行一次系统调用将涉及到CPU状态的切换即从用户空间切换到内核空间实现进程上下文的切换这将损耗一定的CPU时间频繁的磁盘访问对程序的执行效率造成很大的影响。5. 解决历史遗留问题文件描述符那篇文章我们遗留了一个问题当时我们写了这样一个代码先把1号文件描述符关闭然后我们打开一个文件那这个文件就被分配到了1号文件描述符所以fd变量的值就是1。然后我们printf打印这个fd但是我们发现什么都没有打印为什么呢因为printf固定是往1号文件描述符对应的文件打印的它认为1号文件描述符对应的就是标准输出。正常情况下这没有问题但是我们现在把1关闭了然后新打开一个文件所以文件描述符1就不再指向标准输出而是指向我们打开的log.txt文件。所以我们打印的信息就不会写入到显示器了而是写到我们自己新打开的文件了。但是我们会发现log.txt里面也没有任何东西。为什么呢今天我们就可以解释这个原因了。现在文件描述符1确实指向log.txt了但是我们调用printf它会立即写入到文件中吗现在我们知道这里会先放到FILE中的用户缓冲区中而现在1指向的是一个磁盘文件全缓冲不是显示器行缓冲了。所以虽然有\n但是不会触发刷新而且也没有把缓冲区写满。那就要等到程序结束时候才会刷新。但是程序结束之前我们又把这个文件描述符关掉了那程序结束的时候还怎么往这个文件刷新呢所以最后我们看到的结果是这个文件确实被创建存在了但是fd并没有写入进去。所以我们当时做了这样的事情第一种方法把close(fd)注释掉就可以了。因为这样没有关闭文件描述符所以进程退出的时候就可以刷新到这个文件中了。第二种方法close(fd)之前强制刷新stdout的缓冲区stdout 所封装的整数文件描述符就是 1那这样当然也是可以的了相信现在大家就明白了。扩展在C中cin、cout、cerr分别对应标准输入流对象、标准输出流对象、标准错误流对象它们的类定义中必定也封装了文件描述符维护了用户级缓冲区6. 研究一段有趣的代码下面我们来看这样一段代码#includestdio.h#includeunistd.h#includesys/types.h#includesys/stat.h#includefcntl.h#includestring.hintmain(){// Cconstchar*s1hello printf\n;printf(%s,s1);constchar*s2hello fprintf\n;fprintf(stdout,%s,s2);constchar*s3hello fwrite\n;fwrite(s3,strlen(s3),1,stdout);// 系统调用constchar*s4hello write[syscall]\n;write(1,s4,strlen(s4));// fork 创建子进程fork();return0;}我们一起来看一下很简单首先分别用C语言的三个IO函数向标准输出打印了三个字符串然后又用系统调用write向标准输出打印了一个字符串。最后fork创建了一个子进程。我们来编译运行一下看看结果没有问题四个打印。好像很简单啊这有什么好看的接下来如果把运行结果重定向到文件中为什么是这样的内容呢系统调用打印我们放在最后了正常打印它就是在最后但是但是重定向到文件的时候它却跑到最前面了并且C语言IO函数的打印好像每个都打印了两次我们来分析一下首先正常执行往显示器打印的情况前三个C语言的打印显示器标准输出使用的是行缓冲所以这三个字符串虽然都是先写到用户级缓冲区中但最后遇到\n就立即调用底层的write刷新到内核缓冲区了。最后又执行了一个write打印一个字符串。所以往显示器打印的时候就是按照顺序依次执行write系统调用操作系统按顺序刷新到显示器因此最后打印的结果和打印的顺序一样。那重定向到文件呢首先我们看到里面有7行因为C语言的三个打印重复了。我们先来分析系统调用为什么打印一次因为直接调用write的话就直接把这个字符串从用户空间拷贝到内核缓冲区了那最后操作系统向文件或显示器刷新的时候也就只有只有这一个字符串刷新一次。那为什么使用c库函数打印的都是出现两次呢因为我们使用C语言IO函数打印都是先把数据放到stdout的用户级缓冲区中了那\n不是会触发刷新嘛那是往显示器写但是现在重定向了是往磁盘文件写所以此时底层的刷新策略就变成了全缓冲所以这三个字符串就依次被添加到了stdout的缓冲区中并且这三句字符串不足以填满缓冲区因此就先被暂存在这里面不会被刷新。那下面又调了write打印它直接把字符串从用户空间拷到了内核缓冲区然后操作系统就可以在合适的时机把它刷新到文件中了。所以我们看到write的打印跑到了最前面。此时那三个字符串还在用户级缓冲区中放着呢。那再往下我们又调用了fork父子进程两个执行流但是后面什么也没做两进程依次退出。那进程退出会刷新自己的缓冲区啊那创建子进程后父子进程如果看待这个stdout的缓冲区呢缓冲区里的内容属于进程的数据刷新缓冲区本质也是修改所以——写时拷贝。因此这三个字符串被父子进程各刷新一次最后我们看到重复出现了两次7. 内核缓冲区何时刷新数据放到了内核缓冲区之后操作系统何时把数据刷新到文件/外设呢操作系统把内核缓冲区中的数据刷新到外设/文件中由什么完成不就是上一篇文章一切皆文件中讲的struct file中有一个const struct file_operations *f_op;里面存放了一组函数指针这些函数指针指向的是“针对特定文件类型/设备类型”所实现的读写例程。 对于磁盘文件它指向文件系统模块负责缓存和日志对于键盘/串口它指向字符设备驱动负责直接操作硬件寄存器。最终不同的设备就调用到自己的不同的读写方法把数据从内核刷新到指定设备/文件。不同设备刷新策略是不一样的所以底层指向的操作函数也是不一样的一般而言内核缓冲区的刷新是全缓冲的当然如果是显示器这种设备就也是行刷新。当然实际操作系统对内核缓冲区的刷新策略是更复杂的不是单纯的全缓冲或者行缓冲。内核中通常有一个单独的执行流来根据内存的使用情况动态刷新即使刷新条件不满足。那如果想手动强制刷新内核缓冲区的数据到指定的存储设备呢可以使用fsync这个系统调用。像很多的数据库比如MySQL、redis数据库本质也是文件/目录要把数据存盘/落盘持久化存储的时候通常在底层就会使用这个系统调用。8. 聊聊标准错误看下面这段代码使用C语言、C分别向标准输出、标准错误打印字符串标准输出、标准错误对应的都是显示器没问题都打印出来了但是这样看两者好像没什么区别啊如果这样呢如果执行程序同时输出重定向到文件中我们发现向标准错误输出的字符串为什么没有重定向到文件中还是到显示器了为什么呢很简单因为输出重定向修改的是1号文件描述符的执行跟2号没关系啊2号文件描述符还是指向标准错误对应显示器文件所以printf和cout是往1号文件描述符指向的文件打印的perror是往2号文件描述符指向的文件打印的。那这就使得在必要的时候我们可以把正常信息和错误信息进行分离输出到不同的文件中。怎么做呢所以我们之前这样的写法其实是一种简写这是让1号文件描述符指向log.txt所以完整应该写法如果把标准输出把标准错误重定向到一个文件中不要这样写
返回列表