原始数据结构
问题:当你打开一个文本文件时,首先从磁盘加载数据,这些数据会被保存在内存中,我们用怎样的数据结构来存储呢?
我们大家正常想到应该就是两个
字符串
contents = “第一行内容n第二行内容”
字符串数组
lines = [
“第一行内容n”, // 第一行
“第二行内容” // 第二行
]
直接使用字符串简单粗暴,我们知道每次改动字符串都会重新申请内存,这样会导致内存极度不稳定。
如果使用字符串数据,在长数组的情况,依然会存在移动的性能问题(2018年前VsCode其实一直都是使用的这种模式)。
针对这种情况业界提出了Append-Only的概览,也是我们后续说的PieceTable。
Piece Table
一种Append-Only的方式,更新的内容都是以追加的形式保存的,Piece Table整体可以归纳为原始内容(original),新增内容(added),内容顺序关系对象(pieces)。
让我们来看看Piece Table是如何工作的,首先当用户从磁盘读取文档内容到内存中时,我们会把文本内容记录为original字符串。
let PieceTable = {
"original": “ 第一行内容n第二行内容”,
...
}
当我们插入文本的时候,文本会append到一个added的字符串中,初始化为空字符串,后续无论我们在哪些位置增删改,我们都是把增加的文本放入到added字符串末尾的,通过这种方式,我们记录了用户插入到文本中的所有字符,避免了在中间插入文本的问题。
假如我们在第二行的位置插入一段文本,他在Piece Table中表示为:
let PieceTable = {
"original": “ 第一行内容n第二行内容”,
"added": “在第二行位置插入一行n", // 这个字符串只增不减
}
为了知道用户在哪里输入的文本,Piece Table需要记录哪个区域是从original来的,哪些是从added来的,这样需要一个Piece结构存储以下三个内容信息
type:属于哪个字符串
start:字符串中的开始位置
length:总共有多少个字符
{
"original": “ 第一行内容n第二行内容”,
"added": “在第二行位置插入一行n", // 这个字符串只增不减
"pieces": [
Piece(start=0, length=6, type="original"),
Piece(start=0, length=11, type=“added"),
Piece(start=6, length=5, type="original")
],
}
上述结构就是一个简单的PieceTable了,这样的结构真的就能满足我们的需求了吗?答案是否定的,我们来看看VsCode是怎样一步一步来优化的。
VsCode 版 PieceTable
快速查找行
基于上述的结构,我们每次查找行都需要遍历一次pieces,找到每个piece里面的字符串中包含多少个换行(类似用split('n')),这样效率就有点底下了,所有这里vscode这里把数据结构做了一下优化,增加一个lineStarts来缓存当前piece里面每个换行符的位置,如果我需要去第一个piece的第二行,就变成去original字符串中lineStarts[0]到lineStarts[1]的数据了。
{
"original": “ 第一行内容n第二行内容”,
"added": “在第二行位置插入一行n", // 这个字符串只增不减
"pieces": [
Piece(start=0, length=6, type="original", lineStarts=[5]),
Piece(start=0, length=11, type=“added", lineStarts=[10]),
Piece(start=6, length=5, type="original", lineStarts=[])
],
}
字符串上限带来的坑
V8里面的字符串的大小是有限制的256M~1G,假设我们要打开超过1G以上的大文件,当我们把文本数据从磁盘中加载出来拼接到original的时候,这时候就会超过V8的限制了。
VsCode通过把original和added都一起整合到一个buffers的字符串数组来解决这个问题,数组中每个字符串的最大值为64K,数据结构变化为
{
"buffers": [“ 第一行内容n第二行内容”, “在第二行位置插入一行n"], //
"pieces": [
Piece(start=0, length=6, bufferIndx=0, lineStarts=[5]),
Piece(start=0, length=11, bufferIndx=1, lineStarts=[10]),
Piece(start=6, length=5, bufferIndx=0, lineStarts=[])
],
}
基于lineStarts快速查找行内容性能再次迎来了新挑战
假设用户打开一个640M的文件,按照每个buffer为64k的大小,这时候初始化的buffers的长度就10000了,这时候就算存在lineStarts的缓存,查询某一行的内容的,也会变成一个最差的时间复杂度为O(N)的查询(这个取决于所查询行的大小)。
如果在每个Piece中缓存绝对的行号,这时候通过二分法查找就能变成log(N)复杂度,但是这时候又回来一个新问题,一旦用户在中间插入一行,就会导致后续所有的行号都要改变,就会变成一个编辑是O(N)的行为。
虽然二分法并不能帮我们解决问题,但是也能给我们一些启发,我们把二分法改为二叉查找树,问题就变成了,如何让二分查找树实现编辑也为log(N),如果我们存储绝对行号,用二叉树编辑也会是一个O(N)
如何设计一个二叉排序树,让其编辑和查找都为log(N),正常二叉排序树查找的时间复杂度为log(N)~ O(N),所以想要log(N)首先得二叉树是平衡的,VsCode这里就用的红黑树来实现的。我们已经解决查找的速度问题,还剩下编辑的时间复杂度的问题,这时候为了保证log(N),我们只能设计二叉树的跟行相关的元数据只能与子树相关,这样就能保证每次更新行的元数据信息,就需要更新父节点的信息了,vsCode这里引入了left_subtree_lfcnt:左子树换行符数.(当然这里如果想要查找具体某一个位置的字符,则可改为left_subtree_length,这里我们不重点讲了)
{
"buffers": string[10000], //
"pieces": [
Piece(start=0, length=, bufferIndx=0, lineStarts=[...], left_subtree_lfcnt=),
...,
Piece(start=0, length=, bufferIndx=0, lineStarts=[10000], left_subtree_lfcnt=),
],
}
我们这里做一次转换把绝对行号转化为left_subtree_lfcnt和cur_lfcnt(当前节点字符串中的换行符数,这个根据lineStarts转化而来,这个lineStarts后续还会再提到,**先mark下**),我们把绝对行号转化为(left_subtree_lfcnt,cur_lfcnt)结构:
这样设计为什么能解决编辑的时间复杂度呢?我们来看看 在21行这个位置新插入4行 的情况
很显然时间复杂度为log(N)了。
改成这样后,我们怎么查询呢?问题:查询第22行的位置
性能极致优化
上面我们有一个lineStarts的缓存行号,我们这里有一个很差的情况,假设我们一个lineStarts长度为10000,一旦在中间插入,这个lineStarts被对半分,这时候就变成了两个长为5000的lineStarts了,由于不是直接在线性内存上进行操作,因此将数组拆分为两个比仅移动指针的开销更大。
为了解决这个问题,VsCode用了一个很讨巧的方式,基于buffers Append-Only的特性,把linestarts绑定到只Append-Only Buffer上,每个buffer只要生成了,它就是不变的了。
{
"buffers": string[{ value: '', lineStarts: [] }],
"pieces": [
Piece(start=(0, 0), end=(line, column), bufferIndx=0, lineStarts=[...], left_subtree_lfcnt=),
...,
Piece(start=(line, column), end=(line, column), bufferIndx=0, lineStarts=[10000], left_subtree_lfcnt=),
],
}
这里其实是把start和end又之前的偏移number转化为:某一个buffer的第几行的第几列,我们来看到vsCode textBuffer中的源码截图
这些基本上就是整个VsCode文本编辑器pieceTable的进化的核心思想了,大家有不同看法,欢迎一起沟通,当然有说的不对的地方,也欢迎来指正。
参考文档:Text Buffer Reimplementation, a Visual Studio Code Story