穿透原理和方法详解 linux原理和方法


穿透原理和方法详解 linux原理和方法

文章插图
剧本起因无意间用 vim 打开了一个 10 G 的文件 。改了一行内容 。:w 保存了一下 。慢的我哟 。耗费的时间够泡几杯茶了 。这引起了我的奇怪 。vim 打开和保存到底做了啥?
vim — 写器之神
vim 号称写器之神 。以极其厉害的扩展性和功能闻名 。vi/vim 作为标准的写器存在于 Linux 的几乎每一种发行版里 。vim 的学习曲线有那么一点陡峭的 。前期一定有一个磨炼的过程 。
vim 是一个终端写器 。在可视化的写器横行的今天 。怎么 vim 还如此重要?
因为一些场景非它不可 。例如线上服务器终端 。除 vi/vim 这种终端写器 。你别无选择 。
vim 的简史很悠久 。Github 有个文档归纳了 vim 的简史进程:vim 简史 。Github 开源代码:代码仓库 。
笔者今天不讲 vim 的用法 。这种文章网络随便搜一大把 。笔者将从 vim 的存储 IO 原理的角度来剖析下 vim 这种神器 。
思考几个小问题 。读者如果有兴趣 。应该继续往下读哦:
vim 写文件的原理是啥 。用了啥黑科技吗?
vim 打开一个 10G 的大型文件 。怎么怎么这么慢 。里面做了啥?
vim 改写一个 10G 的大型文件 。:w 保存的时候 。感觉更慢了?怎么?
vim 貌似会发生多余的文件?~ 文件 ?.swp 文件 ?都是做啥的呢?
划重要时机:由于 vim 的功能过于厉害 。一篇共享开始说不完 。本文文章聚焦 IO 。从存储的角度剖析 vim 原理 。
vim 的 io 原理声明 。系统和 Vim 版本如下:操作面板系统版本:Ubuntu 16.04.6 LTSVIM 版本:VIM – Vi IMproved 8.2 (2019 Dec 12, compiled Jul 25 2021 08:44:54)测试文件名:test.txt
vim 只是一个二进制程序而已 。读者朋友也应该 Github 安装 。编译 。自己调试哦 。效果更优质 。
往往一般使用 vim 写文件很无脑 。只要 vim 后面跟文件名就可:
vim test.txt这样就打开了文件 。并且应该进行写 。这种命令敲下去 。往往一般状态下 。我们就能很快在终端很观看到的文件的内容了 。
Linux 写器之神 vim 的 IO 存储原理
这种过程发生了什么?先明确下 。vim test.txt 到底是啥意思?
本质只是运行一个叫做 vim 的程序 。argv[1] 参数是 test.txt 嘛 。跟你曾经写的 helloworld 程序没啥不一样 。只不过 vim 这种程序应该终端人机交互 。
所以这种过程无非只是一个进程初始化的过程 。由 main 开始 。到 main_loop(后台循环监听) 。
1 vim 进程初始化vim 有一个 main.c 的入口文件 。main 函数就定义在这里 。首先会做一下操作面板系统有关的初始化( mch 是 machine 的缩写):
mch_early_init();
之后会 。做一下赋值参数 。全局变量的初始化:
/*
* Various initialisations shared with tests.
*/
common_init(?ms);
举个举例 test.txt 这样的参数必定要赋值到全局变量中 。因为未来是要总是使用的 。
另外类似于命令的 map 表 。是静态定义好了的:
static struct cmdname
【穿透原理和方法详解 linux原理和方法】{
char_u*cmd_name;// name of the command
ex_func_T cmd_func;// function for this command
long_ucmd_argt;// flags declared above
cmd_addr_T cmd_addr_type; // flag for address type
} cmdnames [] = {
EXCMD(CMD_write, "write", ex_write,
EX_RANGE|EX_WHOLEFOLD|EX_BANG|EX_FILE1|EX_ARGOPT|EX_DFLALL|EX_TRLBAR|EX_CMDWIN|EX_LOCK_OK,
ADDR_LINES),
}
划重要时机::w 。:write 。:saveas 这样的 vim 命令 。其实是对应到定义好的 c 回调函数:ex_write。ex_write 函数是资料写入的核心函数 。再例如 。:quit 对应 ex_quit。用来退出的回调 。
换句话说 。vim 里面支持的类似 :w。的命令 。其实在初始化的时候就确认了 。人为的交互只是输入字符串 。vim 进程从终端读到字符串之后 。寻找对应的回调函数 。执行就可 。再来 。会初始化一些 home 目录 。目前目录等变量 。
init_homedir(); // find real value of $HOME
// 保存交互参数
set_argv_var(paramp->argv, paramp->argc);
配置一下跟终端窗口展现有关的东西 。这部分往往一般是一些终端库有关的:
// 初始化终端一些配置
termcapinit(params.term); // set terminal name and get terminal
// 初始化光标地点
screen_start(); // don't know where cursor is now
// 获取终端的一些消息
ui_get_shellsize(); // inits Rows and Columns
再来会加载 .vimrc 这样的配置文件 。让你的 vim 与众不一样 。
// Source startup scripts.
source_startup_scripts(?ms);
还会加载一些 vim 插件 source_in_path。使用 load_start_packages 加载 package。
下面这种只是第一个交互了 。等待客户敲下 enter 键:
wait_return(TRUE);
我们总是看见的:“Press ENTER or type command to continue“ 只是在这里执行的 。确认完 。就说明你真的是要打开文件 。并展现到终端了 。
怎么打开文件?怎么展现字符到终端屏幕?
这一切都来自于 create_windows 这种函数 。名字也较好理解 。只是初始化的时候创建终端窗口来着 。
/*
* Create the requested number of windows and edit buffers in them.
* Also does recovery if "recoverymode" set.
*/
create_windows(?ms);
这里其实涉及到两个方面:
把资料读出去 。读到内存;
把字符渲染到终端;
怎么把资料从磁盘上读出去 。只是 IO 。怎么渲染到终端这种我们不管 。这种使用的是 termlib 或者 ncurses 等终端编程库来实现的 。有兴趣的应该了解下 。
这种函数会调用到我们的第一个核心函数:open_buffer。这种函数做两个时间:
create memfile:创建一个 memory + .swp 文件的抽象层 。读写资料都会过这一层;
read file:读原始文件 。并解码(用来展现到屏幕);
函数调用栈:
-> readfile
-> open_buffer
-> create_windows
-> vim_main2
-> main
真正干活的是 readfile 这种函数 。评论一下 。readfile 是一个 2533 行的函数 。。。。。。
readfile 里面会择机创建 swp 文件(曾经一些话 。应该用来复原资料) 。调用的是 ml_open_file 这种函数 。文件创建好之后 。size 占用 4k 。里面往往一般是一些特殊的元资料(用来复原资料用的) 。
划重要时机:.{文件名}.swp 这种掩藏文件是有格式的 。前 4k 为 header 。后面的内容也是根据一个个block 团队的 。
再往后走 。会调用到 read_eintr 这种函数 。读取资料的内容:
long
read_eintr(int fd, void *buf, size_t bufsize)
{
long ret;
for (;;) {
ret = vim_read(fd, buf, bufsize);
if (ret >= 0 || errno != EINTR)
break;
}
return ret;
}
这是一个最底层的函数 。是系统调用 read 的一个封装 。读出去之后 。这里回答了一个关键问题:vim 的存储原理是啥?
划重要时机:本质上调用 read 。write 。lseek 这样朴素的系统调用 。而已 。
readfile 会把二进制的资料读出去 。之后进行字符转变编码(根据配置的模式) 。编码不对只是乱码喽 。每次都是根据一个特殊 buffer 读资料的 。例如 8192。
划重要时机:readfile 会读完文件 。这只是怎么当 vim 打开一个超大文件的时候 。会超级慢的原因 。
这里提一点题外话:memline 这种封装是文件之上的 。vim 改写文件是改写到内存 buffer。vim 根据策略来 sync memfile 到 swp 文件 。一个是以免丢弃未保存的资料 。第二是为了节省内存 。
mf_write 把内存资料写到文件 。在 .test.txt.swp 中的只是这样的资料结构:
穿透原理和方法详解 linux原理和方法

文章插图
block 0 的 header 主要标识:vim 的版本;
写文件的路径;
字符编码方法;
这里实现提一个重要知识点:swp 文件里存储的是 block 。block 的管理是以一个树形结构进行管理的 。block 有 3 种类别:
block0:头部 4k。往往一般是存储一些文件的元资料 。例如路径 。编码模式 。时间戳等等;
pointer block:树形内部节点;data block:树形叶子节点 。存储客户资料;
2 敲下 :w 背后的原理进程初始化我们讲完了 。现在来看下 :w 触发的调用吧 。客户敲下 :w 命令触发 ex_write 回调(初始化的时候配置好的) 。全部的流程皆在 ex_write。我们来看下这种函数做了什么 。
先撇开代码实现来说 。客户敲下 :w 命令其实只是想保存改写而已 。
那么第一个问题?客户的改写在哪里?在 memline 的封装 。只要没执行过 :w 保存 。那么客户的改写就没改写到原文件上(小心哦 。没保存曾经 。一定没改写原文件哦) 。这时候 。客户的改写可能在内存 。也很有可能在 swp 文件 。存储的资料结构为 block。所以 。:w 其实只是把 memline 里面的资料刷到客户文件而已 。怎么刷?
重要时机步骤如下(以 test.txt 举例):创建一个 backup 文件( test.txt~ ) 。把原文件拷贝出去;
把原文件 test.txt truancate 截断为 0 。等于清空原文件资料;
从 memline (内存 + .test.txt.swp)拷贝资料 。从头开始写入原文件 test.txt;
删除备份文件 test.txt~;以上只是 :w 做的全部事件了 。下面我们看下代码 。
触发的回调是 ex_write。核心的函数是 buf_write。这种函数 1987 行 。
在这函数 。会使用 mch_open 创建一个 backup 文件 。名字后面带个 ~。例如 test.txt~。
bfd = mch_open((char *)backup
拿到 backup 文件的句柄 。之后拷贝资料(只是一个循环喽), 每 8K 操作一次 。从 test.txt 拷贝到 test.txt~。以做备份 。
划重要时机:如果是 test.txt 是超大文件 。那这里就慢了哦 。
backup 循环如下:
// buf_write
while ((write_info.bw_len = read_eintr(fd, copybuf, WRITEBUFSIZE)) > 0)
{
if (buf_write_bytes(&write_info) == FAIL)
// 如果失败 。则终止
// 否则直到文件结束
}
}
我们观看到的 。干活的是 buf_write_bytes。这是 write_eintr 的封装函数 。其实也只是系统调用 write 的函数 。负责写入一个 buffer 的资料到磁盘文件 。
long write_eintr(int fd, void *buf, size_t bufsize) {
longret = 0;
longwlen;
while (ret < (long)bufsize) {
// 封装的系统调用 write
wlen = vim_write(fd, (char *)buf + ret, bufsize – ret);
if (wlen < 0) {
if (errno != EINTR)
break;
} else
ret += wlen;
}
return ret;
}
backup 文件拷贝完成之后 。就应该准备动原文件了 。
思考:怎么要先文件备份呢?留条后路呀 。搞错了还一些复原 。这种才是真正的备份文件 。
改写原文件曾经的第一步 。ftruncate 原文件到 0 。之后 。从 memline (内存 + swp)中拷贝资料 。写回原文件 。
划重要时机:这里又是一次文件拷贝 。超大文件的时候 。这里可能巨慢哦 。
for (lnum = start; lnum <= end; ++lnum)
{
// 从 memline 中获取资料 。返回一个内存 buffer( memline 其实只是内存和 swap 文件的一个封装)
ptr = ml_get_buf(buf, lnum, FALSE) – 1;
// 将这种内存 buffer 写到原文件
if (buf_write_bytes(&write_info) == FAIL)
{
end = 0;// write error: break loop
break;
}
// …
}
划重要时机:vim 并不是调用 pwrite/pread 这样的调用来改写原文件 。而是把整个文件清空之后 。copy 的方法来更新文件 。涨知识了 。
这样就完成了文件的更新啦 。末尾只要删掉 backup 文件就可 。
// Remove the backup unless 'backup' option is set or there was a
// conversion error.
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。
穿透原理和方法详解 linux原理和方法

文章插图
先说 memline 是啥?
对应到文本文件中的每一行 。memline 是基于 memfile 的 。
memline 基于 memfile 。那 memfile 又是啥?
这种是一个虚拟内存空间的实现 。vim 把整个文本文件映射到内存中 。通过自己管理的方法 。这里的单位为 block 。memfile 用二叉树的方法管理 block。block 不定长 。block 由 page 组成 。page 为定长 4k 大小 。
这是一个典型虚拟内存的实现方案 。写器的改写都体现为对 memfile 的改写 。改写都是改写到 block 之上 。这是一个线性空间 。每一个 block 对应到文件的要给地点 。有 block number 编号 。vim 通过策略会把 block 从内存中换出 。写入到 swp 文件 。从而节省内存 。这只是 swap 文件的名字由来 。
block 区分 3 种类别:
block 0 块:树的根 。文件元资料;
pointer block:树的分支 。指向下一个 block;
data block:树的叶子节点 。存储客户资料;
swap 文件团队:
穿透原理和方法详解 linux原理和方法

文章插图
block 0 是特别块 。结构体占用 1024 个字节内存 。写到文件是根据 1 个page 对齐的 。所以是 4096 个字节 。
如下图:
穿透原理和方法详解 linux原理和方法

文章插图
block 很多的两种类别:pointer 类别:这种是中间的分支节点 。指向 block 的;
data 类别:这种是叶子节点;#define DATA_ID(('d' << 8) + 'a') // data block id
#define PTR_ID(('p' << 8) + 't') // pointer block id
这种 ID 等于魔数 。在 swp 文件中很简无脑单查看出去 。例如在下面的文件中第一个 4k 存储的是 block0 。第二个 4k 存储的是 pointer 类别的 block 。
穿透原理和方法详解 linux原理和方法

文章插图
第三 。第四个 4k 存储的是一个 data 类别的 block。里面存储了原文件资料 。
穿透原理和方法详解 linux原理和方法

文章插图
当客户改写一行的时候 。对应到 memline 的一个 line 的改写 。对应到这行 line 在哪个 block 的改写 。从而定时的刷到 swap 文件 。
穿透原理和方法详解 linux原理和方法

文章插图
vim 特别的文件 ~ 和 .swp ?
假设原文件名称:test.txt。
1 test.txt~ 文件test.txt~ 文件估计很多的人都没见过 。因为泯灭的太快了 。这种文件在改写原文件曾经生成 。改写原文件之后删除 。作用来只存在于 buf_write。是为了安全备份的 。
划重要时机:test.txt~ 和 test.txt 本质是一样的 。没有很多的特殊格式 。是客户资料 。
读者朋友试试 vim 一个 10 G的文件 。之后改一行内容 。:w 保存 。大概很简无脑单发现这种文件(因为备份和回写时间巨长 ) 。
2 .test.txt.swp 文件这种文件估计绝往往一般状态人都见过 。.swp 文件生命周期存在于整个进程的生命周期 。句柄是一直打开的 。很多的人认为 .test.txt.swp 是备份文件 。其实准确来讲并不是备份文件 。这是为了实现虚拟内存空间的交换文件 。test.txt~ 才是真正的备份文件 。swp 是 memfile 的一部分 。前面 4k 为 header 元资料 。后面的为 一个个 4k 的资料行封装 。和客户资料并不完整对应 。
memfile = 内存 + swp 才是最新的资料 。
思考解答1 vim 存储原理是啥?没啥 。只是用的 read 。write 这样的系统调用来读写资料而已 。
2 vim 的过程有两种冗余的文件?test.txt~ :是真正的备份文件 。诞生于改写原文件曾经 。泯灭于改写成功之后;.test.txt.swp :swap 文件 。由 block 组成 。里面可能由客户未保存的改写 。等待:w 这种调用 。就会覆盖到原文件;
3 vim 写超大文件的时候怎么慢?往往一般状态下 。你能直观感受到 。慢在两个地方:
vim 打开的时候;
改写了一行内容 。:w 保存的时候;
先说第一个场景:vim 一个 10G 的文件 。你的直观感受是啥?
我的直观感受是:命令敲下之后 。应该去泡杯茶 。等茶凉了一点 。差不多就能观看到的窗口了 。怎么?
在进程初始化的时候 。初始化窗口曾经 。create_windows -> open_buffer 里面调用 readfile会把整个文件读一次(完美的读一次) 。在屏幕上展示编码过的字符 。
划重要时机:初始化的时候 。readfile 会把整个文件读一次 。
10 G的文件 。你随便想想就了解了有多慢 。我们应该算一下 。根据单盘硬件 100 M/s 的带宽来算 。也要 102 秒的时间 。
再说第二个场景:喝了口茶 。改了一个单词 。:w 保存一下 。妈呀 。命令敲下之后 。又应该去泡杯茶了?怎么?
先拷贝出一个 10G 的 test.txt~ 备份文件 。102 秒就过去了;
test.txt 截断为 0 。再把 memfile( .test.txt.swp )拷贝回 test.txt。资料量 10 G 。102 秒过去了(第一次可能更慢哦);
4 vim 写大文件的时候 。会有空间膨胀?是的 。vim 一个 test.txt 10 G 的文件 。会存在某个时刻 。需要 >=30 G 的磁盘空间 。
原文件 test.txt 10 G
备份文件 test.txt~ 10G
swap 文件 .test.txt.swp >10G
总结vim 写文件并不没有用黑魔法 。还是用的 read 。write 。朴实无华;
vim 写超大文件 。打开很慢 。因为会读一次文件( readfile ) 。保存的时候很慢 。因为会读写两遍文件(backup 一次 。memfile 覆盖写原文件一次);
memfile 是 vim 抽象的一层虚拟存储空间(物理上由内存 block 和 swp 文件组成)对应一个文件的最新改写 。存储单元由 block 构成 。:w 保存的时候 。只是从 memfile 读 。写到原文件的过程;
memline 是基于 memfile 做的另一层封装 。把客户的文件抽象成“行”的概念;
.test.txt.swp 文件是一直 open 的 。memfile 会定时的交换资料进去 。以便容灾复原;
test.txt~ 文件才是真正的备份文件 。诞生于 :w 覆盖原文件曾经 。泯灭于成功覆写原文件之后;
vim 基础都是整个文件的处理 。并不是局部处理 。大文件的写开始不适合 vim。话说回去 。正经人谁会用 vim 写 10 G 的文件?vim 只是个文本写器呀;
一个 readfile 函数 2533 行 。一个 buf_write 函数 1987 行代码 。。。不是我压力各位的积极性 。这 。。。反正我不想再看见它了 。。。