故事是因为不小心用vim打开了一个10G的文件,改了一行内容,:w然后保存,我手慢,时间够用泡几杯茶。这引起了我的好奇,vim的打开和保存到底是干什么的?vim——编辑器之神Vim被誉为编辑器之神,以极其强大的可扩展性和功能着称。vi/vim作为标准编辑器存在于几乎每个Linux发行版中。vim的学习曲线比较陡峭,前期肯定有一个磨练的过程。vim是一个终端编辑器。在可视化编辑器泛滥的今天,为什么vim如此重要?因为有些场景必须要用到,比如在线服务器终端,所以只能用vi/vim这样的终端编辑器。Vim有着悠久的历史。Github有个文档总结了vim的历史进程:vimhistory,Github开源代码:coderepository。笔者今天就不讲vim的用法了,随便在网上搜了一大堆这样的文章。笔者将从vim的存储IO原理角度分析vim的神器。思考几个小问题,如果读者有兴趣,可以继续阅读:vim编辑文件的原理是什么,有什么黑科技?Vim打开一个10G的大文件,为什么这么慢,里面做了什么?vim修改10G的大文件时,感觉:w保存的时候比较慢?为什么?Vim似乎生成了冗余文件??文件?.swp文件?他们在做什么?划重点:因为vim的功能太强大了,一个分享都说不完。本文以IO为中心,从存储的角度分析vim的原理。Vim的io原理声明、系统和Vim版本如下:操作系统版本:Ubuntu16.04.6LTSVIM版本:VIM-ViIMproved8.2(2019Dec12,compiledJul25202108:44:54)测试文件名:test.txtvim只是一个二进制程序。读者朋友也可以从Github上下载,自行编译调试,效果更佳。一般来说,使用vim编辑文件是非常简单的。你只需要在vim后面加上文件名:vimtest.txt,这样文件就打开了,可以编辑了。输入这条命令后,一般情况下,我们可以很快在终端看到文件的内容。这个过程中发生了什么?先澄清一下,vimtest.txt是什么意思?其实质就是运行一个叫vim的程序,argv[1]参数就是test.txt。和你之前写的helloworld程序没什么区别,只是vim程序可以和终端交互。所以这个过程无非是一个进程初始化过程,从main开始,到main_loop(后台循环监控)。1vim进程初始化vim有一个main.c入口文件,其中定义了main函数。首先做一些操作系统相关的初始化(mch是machine的缩写):mch_early_init();然后,做一些赋值参数,初始化全局变量:/**Variousinitialisationssharedwithtests.*/common_init(¶ms);例如test.txt等参数必须赋值给全局变量,因为以后会经常用到。另外,类似命令的映射表是静态定义的:staticstructcmdname{char_u*cmd_name;//nameofthecommandex_func_Tcmd_func;//functionforthiscommandlong_ucmd_argt;//flagsdeclaredabovecmd_addr_Tcmd_addr_type;//flagforaddresstype}cmdnames"]={write,w_rgtex_write,WHOLEF|EX_EX_BANG|EX_FILE1|EX_ARGOPT|EX_DFLALL|EX_TRLBAR|EX_CMDWIN|EX_LOCK_OK,ADDR_LINES),}highlight::w,:write,:saveas这样的vim命令其实对应定义的c回调函数:ex_write,ex_write函数是核心函数再举个例子,:quit对应ex_quit,ex_quit是exit的回调,换句话说,vim支持的类似:w的命令,其实都是在初始化的时候就确定的。人机交互只是输入一个字符串。vim进程从终端读取字符串后,找到对应的回调函数并执行。接下来,将初始化一些主目录、当前目录和其他变量。init_homedir();//findrealvalueof$HOME//保存交互参数set_argv_var(paramp->argv,paramp->argc);配置终端窗口显示相关的东西,这部分主要是一些终端库相关://初始化一些终端配置termcapinit(params.term);//setterminalnameandgeterminal//初始化光标位置screen_start();//don'tknowwherecursorisnow//获取一些终端信息ui_get_shellsize();//initsRowsandColumns将加载类似.vimrc的配置文件,以使您的vim独一无二。//Sourcestartupscripts.source_startup_scripts(¶ms);还会加载一些vim插件source_in_path,使用load_start_packages加载包。下面是第一次交互,等待用户按下回车键:wait_return(TRUE);我们经常看到的:“PressENTERortypecommandtocontinue”就是在这里执行的。确认后,表示你真的要打开文件,并显示到终端。如何打开文件?如何将字符显示到终端屏幕?这一切都来自create_windows函数。名字也很好理解,就是在初始化的时候创建终端窗口。/**创建请求的窗口数量并在其中编辑缓冲区。*如果设置了“恢复模式”,也会恢复。*/create_windows(¶ms);这里其实涉及到两个方面:读取数据,读入内存;将字符渲染到终端;如何从磁盘读取数据,也就是IO。我们不关心如何呈现到终端。这是使用终端编程库(例如termlib或ncurses)实现的。如果你有兴趣,你可以了解更多。这个函数会调用我们的第一个核心函数:open_buffer。这个函数做了两件事:creatememfile:创建内存+.swp文件的抽象层,读写数据都会经过这一层;读取文件:读取原始文件,并解码(显示到屏幕);函数调用栈:->readfile->open_buffer->create_windows->vim_main2->main真正起作用的是readfile函数,吐槽一下,readfile是一个2533行的函数。.....在readfile中,会在适当的时候创建一个swp文件(如果之前存在可以用来恢复数据),调用函数ml_open_file。文件创建后大小为4k,主要包含一些特定的元数据(用于恢复数据)。的)。重点:.{filename}.swp这个隐藏文件是有格式的,前4k是header,后面的内容也是按block组织的。更进一步,会调用函数read_eintr来读取数据的内容:>=0||errno!=EINTR)break;}returnret;}这是一个底层函数,是系统调用read的封装,读出来之后。这里回答了一个关键问题:vim的存储原理是什么?重点:本质上就是调用read、write、lseek等简单的系统调用,仅此而已。readfile会读取二进制数据,然后进行字符转换编码(根据配置的方式),如果编码不正确,会出现乱码。每次都是按照固定的buffer读取数据,比如8192。重要的一点:readfile会读完文件。这就是为什么vim打开一个非常大的文件时会很慢的原因。这里有点题外话:memline包在文件上面,vim修改文件到内存缓冲区,vim根据策略同步memfile到swp文件,一是防止未保存的数据丢失,二是是为了节省内存。mf_write将内存数据写入文件。这是.test.txt.swp中的数据结构:block0的header主要标识:vim的版本;编辑文件的路径;字符编码方法;这里有一个重要的知识点:swp文件存储的是block,block的管理是以树状结构进行管理的。block有3种:block0:4khead,主要存放一些文件的元数据,比如路径,编码方式,时间戳等;指针块:树内部节点;数据块:树叶节点,存放用户数据;2按:w进程初始化背后的原理我们已经讲完了,现在来看一下:w触发的调用。用户键入:w命令以触发ex_write回调(在初始化期间配置)。所有进程都在ex_write中,我们看看这个函数是干什么的。撇开代码实现不谈,用户键入:w命令以保存更改。那么第一个问题?用户的修改在哪里?在memline包中,只要还没有执行:w保存,那么用户的修改就不会修改到原文件中(注意保存前一定不能修改原文件),此时,用户的修改可能在内存中,也可能在swp文件中。存储的数据结构是block。因此,:w实际上只是将memline中的数据刷写到用户文件中。怎么刷?关键步骤如下(以test.txt为例):创建备份文件(test.txt~),复制原文件;将原文件test.txt的truancate截断为0,相当于清空原文件数据;+.test.txt.swp)复制数据并改写原文件test.txt;删除备份文件test.txt~;以上就是:w所做的全部,让我们看下面的代码。触发的回调是ex_write,核心函数是buf_write,1987行。在这个函数中,会使用mch_open创建一个备份文件,文件名后加一个~,比如test.txt~,bfd=mch_open((char*)backup获取备份文件的句柄,然后复制数据(是一个循环),每8K个操作,从test.txt复制到test.txt~进行备份。关键点:如果test.txt是一个非常大的文件,这里会很慢。备份循环如下://buf_writewhile((write_info.bw_len=read_eintr(fd,copybuf,WRITEBUFSIZE))>0){if(buf_write_bytes(&write_info)==FAIL)//如果失败,终止//否则直到文件结束}}我们看到工作的是buf_write_bytes,这是对write_eintr的封装函数,其实就是系统调用write的函数,负责向磁盘文件写入一个buffer数据longwrite_eintr(intfd,void*buf,size_tbufsize){longret=0;longwlen;while(ret<(long)bufsize){//封装的系统调用writewlen=vim_write(fd,(char*)buf+ret,bufsize-ret);if(wlen<0){if(errno!=EINTR)break;}elseret+=wlen;}returnret;}Aft呃备份文件拷贝完成,就可以准备原文件了。思考:为什么要先备份文件?留个退路,万一出错了,还有recovery,这才是真正的备份文件。修改原文件前的第一步,ftruncate原文件为0,然后从memline(内存+swp)复制数据写回原文件。重点:这里又是一个文件拷贝,当文件很大的时候,可能会巨大慢下来。for(lnum=start;lnum<=end;++lnum){//从memline中获取数据并返回一个内存缓冲区(memline实际上是对内存和交换文件的封装)ptr=ml_get_buf(buf,lnum,FALSE)-1;//将这个内存缓冲区写入原始文件if(buf_write_bytes(&write_info)==FAIL){end=0;//writererror:breakloopbreak;}//...}划重点:vim不会调用pwrite/调用这样的aspread修改原文件,但清空整个文件后,使用copy方式更新文件。增加了知识。这样就完成了文件更新,最后只需要删除备份文件即可。//Removethebackupunless'backup'optionissetortherewasa//conversionerror.mch_remove(backup);这就是我们数据写入的完整过程。有没有你想的那么简单!小结:修改test.txt文件调用:w写入保存数据时发生了什么?人机交互,:w触发ex_write回调函数,在do_write->buf_write中完成写入;具体操作是:先备份一个test.txt~文件(完整拷贝);然后将原文件test.txt截断为0,从memline中复制数据(即内存中最新的数据+.test.txt.swap的封装),写入test.txt(全量复制);数据组织结构说得太细了,还是从数据组织的角度来解释吧。对于用户对文件的修改,vim在原始文件之上封装了两层抽象:memline和memfile。分别对应文件memline.c和memfile.c。首先,什么是memline?对应文本文件中的每一行,memline是基于memfile的。Memline是基于memfile的,那么什么是memfile呢?这是一个虚拟内存空间的实现。Vim将整个文本文件映射到内存中并自行管理。这里的单位是block,memfile以二叉树的方式管理block。block不定长,block由page组成,page的长度固定为4k。这是一个典型的虚拟内存实现。编辑器的修改体现在memfile的修改上。修改都是在block上修改的。这是一个线性空间。每个块对应于文件的所需位置。有blocknumbernumber,vim会通过策略从内存中换出block,写入swp文件,从而节省内存。这就是交换文件名称的来源。块分为三种:块0:树的根,文件元数据;指针块:树的分支,指向下一个块;数据块:树的叶子节点,存放用户数据;swap文件组织:block0是特殊block和structure占用1024字节内存,写入文件是按照1页对齐的,所以是4096字节。如下图所示:其他两种区块:指针型:这是中间的分支节点,指向区块;数据类型:这是叶节点;#defineDATA_ID(('d'<<8)+'a')//datablockid#definePTR_ID(('p'<<8)+'t')//pointerblockid这个ID相当于一个幻数,很简单在swp文件中识别,例如下面文件中的第一个4k存放的是block0,第二个4k存放的是指针类型的块。三、第四个4k存放的是一个数据类型的block,存放的是原始文件数据。当用户修改一行时,对应于memline一行的修改,对应于该行在哪个block的修改,从而周期性的刷新swap文件。vim特殊文件~和.swp?假设原始文件名为:test.txt。1test.txt~文件test.txt~估计很多人都没见过这个文件,因为消失得太快了。该文件在修改原文件前生成,修改原文件后删除。该作用只存在于buf_write中,用于安全备份。重点:test.txt~和test.txt本质上是一样的,没有其他具体格式,都是用户数据。各位读者,尝试在vim中新建一个10G的文件,然后改一行内容,用:w保存,应该很容易找到这个文件(因为备份和回写时间巨大)。2.test.txt.swp文件这个文件估计大多数人都见过。.swp文件的生命周期存在于整个进程的生命周期中,句柄一直处于打开状态。很多人认为.test.txt.swp是一个备份文件。其实准确的说不是备份文件。这是为了实现虚拟内存空间的交换文件。test.txt~才是真正的备份文件。swp是memfile的一部分,前4k是headermetadata,后面是4k数据行的封装。不完全对应于用户数据。memfile=memory+swp就是最新的数据。思考与解答1vim存储的原理是什么?没什么,就是用read和write这样的系统调用来读写数据。2vim的进程有两个冗余文件?test.txt~:是真正的备份文件,在修改原文件前生成,修改成功后消失;.test.txt.swp:一个交换文件,由块组成,用户可以在不保存的情况下修改,等待:w这个调用会覆盖原来的文件;3为什么在编辑大文件时vim很慢?一般情况下,你可以直观地感觉到它慢在两个地方:打开vim时;当一行内容被修改时,:w被保存;先说第一个场景:vim中一个10G的文件,你的直观感受是什么?我的直观感受是:输入命令后,就可以去泡一杯茶了,等茶凉了,差不多就能看到界面了。为什么?进程初始化时,在窗口初始化之前,调用create_windows->open_buffer中的readfile,会读取整个文件(readitcomplete),并将编码后的字符显示在屏幕上。要点:在初始化期间,readfile将读取整个文件。10G的文件,可想而知有多慢。我们可以计算一下,以单盘硬件100M/s的带宽计算,需要102秒。再来说说第二个场景:喝完茶,改了个词,:w救了,我的天啊,输入命令后,我可以再去泡杯茶吗?为什么?先复制一个10G的test.txt~备份文件,102秒过去了;test.txt截断为0,然后把memfile(.test.txt.swp)复制回test.txt,数据量10G,102秒过去了(第一次可能会慢);4vim编辑大文件时,会不会有空间膨胀?是的,在某个时候vim中会存在一个test.txt10G的文件,需要>=30G的磁盘空间。原始文件test.txt10G备份文件test.txt~10G交换文件.test.txt.swp>10G总结vim编辑文件不使用黑魔法,但是读、写,朴实无华;vim编辑非常大的文件,打开很慢,因为会读取文件一次(readfile),保存很慢,因为会读写文件两次(备份一次,memfile覆盖原文件一次);memfile是vim抽象出的一层虚拟存储空间(物理上由内存块和swp文件组成)对应一个文件的最新修改,存储单元由block组成。:w保存时,是从memfile中读取,写入原文件的过程;memline是在memfile的基础上再进行一层封装,将用户文件抽象为“行”的概念;.test.txt.swp文件始终打开,memfile会定期将数据交换到其中以用于灾难恢复;test.txt~文件是真正的备份文件,在原文件被:w覆盖之前生成,在原文件覆盖成功后消失;vim基本上是对整个文件的处理,不是局部处理,大文件的编辑根本不适合vim。毕竟,谁会用vim来编辑一个10G的文件呢?vim只是一个文本编辑器;一个readfile函数有2533行代码,一个buf_write函数有1987行代码。..这不是我要劝阻你们,这个。..反正我不想再看到了。..后记对vim的好奇心让笔者阅读了源码,学习了其中的IO知识。我不想接受关于具有数千行的函数的教育。我不想再打飞机了。.你学飞了吗?
