一、介绍
在许多代码编辑器中,如何高效地处理大型文本文件是一个重要问题。当你打开一个10M的代码文件时(大约10万行代码)或许你也曾好奇是什么支持着像VsCode,IDEA等这种强大的编辑器,为什么能做到毫不卡顿。本文将从VsCode的核心编辑器出发,分析其底层的数据结构的完善和实现。
二、基于数组的文本数据结构
编辑器的思维模型是基于行的。开发人员逐行读取和写入源代码,编译器提供基于行/列的代码诊断,函数调用堆栈跟踪包含行号信息,代码解析引擎逐行运行等。
下面这个例子展示了如何用数组表示编辑器的文本数据:
javascript
[
{
"text": "class PieceTable {"
},
{
"text": " original: string;"
},
{
"text": " added: string;"
},
{
"text": " nodes: Node[];"
},
{
"text": "}"
},
]
尽管这种方式很简单,但是其在2018一直作为VsCode的底层数据结构存储文本。并且效果很好,因为典型的文本文档相对较小。使用数组还有一个优势就是可以快速的行查找在数组中,你可以使用索引的形式,快速找到对应内容片段。
2.1 数组结构的性能问题
使用数组的一个劣势就是,当用户输入时,编辑器在数组中找到要修改的行并将其替换。当插入新行时,我们将一个新行对象拼接到行数组中,JavaScript 引擎将为我们完成这项繁重的工作。
下图展示了对数组进行删除/插入,数组中它下面的所有行都需要在内存中移动。对于大文件,这就会变得昂贵。随着文件变大,需要移动的数据也会增多。
使用数组作为底层数据结构时,VsCode团队不断收到报告称打开某些文件会导致 VS Code 内存不足崩溃。例如,一名用户无法打开 35 MB 的文件。根本原因是文件行数太多,1370万行。编辑器将为每一行创建一个 ModelLine 对象,每个对象使用大约 40-60 字节,因此行数组使用大约 600MB 内存来存储文档。这大约是初始文件大小的 20 倍!
另外,使用数组存储文本的另一个问题是打开文件的速度。为了构造行数组,我们必须通过换行符分割内容,这样我们每行都会得到一个字符串对象。拆分这个操作也是会在一定程度带来内存的压力。
2.2 寻找一个新的数据结构
使用数组来存储文本数据可能需要花费大量时间来创建并消耗大量内存,但它可以提供快速的行查找。通常来说,对于一个代码编辑器,我们只需要存储文件的文本,而不像富文本编辑器一样需要存储复杂的嵌套关系和元素类型等元数据。因此,VsCode开始寻找只需要处理较少元数据的数据结构。
数组的一个明显缺点就是需要在中间插入、删除时需要重新分配内存,如果我们将某些内容附加到数组的末尾,则不必移动任何数据来为其腾出空间,因此我们不会像插入到数组的中间那样遭受相同的性能损失。PieceTable就是这样的一种数据结构。它的一个关键特征是它以仅追加的方式记录我们对文件所做的所有插入。在审查了各种数据结构之后,VsCode团队发现PieceTable可能是一个不错的选择。
三、PieceTable
3.1 最基础的PieceTable
PiceceTable是一种数据结构,可以存储字符串文本以及一些列对该文本的操作。
PieceTable的TypeScript示例:
javascript
class PieceTable {
original: string; // 文档的初始内容
added: string; // 文档的新增内容
nodes: Node[];
}
class Node {
type: NodeType;
start: number;
length: number;
}
文件初始加载后,PieceTable在 original 字段中包含整个文件内容。 added 字段为空。
第一次PieceTable初始化后,PieceTable实例的内容如下:
javascript
{
"original": "Code editing\nRedefined\nFree.Runs everywhere",
"added": [],
}
PieceTable有以下几个特点:
- 有一个 NodeType.Original 类型的节点,存储第一次加载的文本。
- 当用户在文件末尾键入时,我们将新内容附加到 added 字段,并且我们将在节点列表的末尾插入一个 NodeType.Added 类型的新节点。
- 当用户在节点中间进行编辑时,我们将拆分该节点并根据需要插入一个新节点。
例如,在上面的文档中,当我们在Free后面加入文本OpenSource后就会变成:
Code editing\nRedefined\nFree.OpenSource.Runs everywhere
下面这个图展示了一个具有三个节点的PieceTable以及如何获取对应的文本:
- 访问第一个Nodes,type = original 表示在original字符串中查找文本,开始是0,结束是25,第一行的内容就是Code editring
- 访问第二个Nodes,type = add 表示在add字符串中查找文本,开始是0,结束是13,第一行的内容就是Redefined
- 访问第三个Nodes,type = add 表示在add字符串中查找文本,开始是0,结束是13,第一行的内容就是Free.Runs everywhere
可视化PieceTable提取所有字符串:
PieceTable的初始内存使用量接近于文档的大小,修改所需的内存与编辑和添加的文本数量成正比。因此,通常情况下,可以保证充分利用内存。
然而,低内存使用率的代价是访问逻辑行很慢。例如,如果要获取第 1000 行的内容,唯一的方法是迭代从文档开头开始的每个字符,找到前 999 个换行符,并读取每个字符,直到下一个换行符。
javascript
// 使用遍历找到模板行号
let currentBreak = 0
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
// 获取文本
const text = node.text;
// 计算当前的node有几个换行符
for(let i = 0;i<text.lenth;i++) {
if(text ==='\n') currentBreak++
}
}
3.2 使用缓存加速查找速度
传统的PieceTable节点仅包含当前节点在orgin或者add字符中的偏移量,但我们可以添加换行信息以使行内容查找更快。存储换行符位置的直观方法是存储节点文本中遇到的每个换行符的偏移量:
javascript
class PieceTable {
original: string;
added: string;
nodes: Node[];
}
class Node {
type: NodeType;
start: number;
length: number;
// 缓存当前的Piece的行数
lineStarts: number[];
}
例如,如果想访问给定 Node 实例中的第二行,可以读取 node.lineStarts[0] 和 node.lineStarts[1] ,它们将给出相对偏移量线的开始和结束。由于我们知道一个节点有多少个换行符,因此访问文档中的随机行非常简单:从第一个节点开始读取每个节点,直到找到目标换行符。
javascript
// 使用遍历找到模板行号
let currentBreak = 0
for (let i = 0; i < 1000; i++) {
const node = nodes[i]
currentBreak += node.lineStarts.length
}
这个算法虽然很简单,不过我们现在可以跳过整个文本块,而不是逐个字符地迭代。后续会对这个方法进行进一步的优化。
3.3 避免字符串溢出
VS Code 中使用 Node.js的fs.readFile 加载文本文件,该方法以每 64KB (与系统设置的缓冲区有关)文本作为一个块的形式加载文件内容。因此,当文件很大时,例如 64 MB,我们将收到 1000 个块。收到所有块后,我们可以将它们连接成一个大字符串并将其存储在片段表的 original 字段中。
这听起来很合理,直到你触碰到了V8引擎的极限。比如当你尝试打开一个 500MB 的文件,会出现异常,因为 V8 中,最大字符串长度为 256MB。在 V8 的未来版本中,此限制将提升至 1GB,但这并不能真正解决问题。
那么该如何解决这个问题?我们可以保存一个缓冲区列表,而不是保存 original 和 added 缓冲区。我们可以尝试保持该列表简短,或者我们可以从 fs.readFile 返回的内容中获得启发并避免任何字符串连接。每次我们从磁盘接收到 64KB 块时,我们都会将其直接推送到 buffers 数组并创建一个指向该缓冲区的节点:
javascript
class StringBuffer {
buffer: string;
constructor(buffer: string) {
this.buffer = buffer;
}
}
现在,假设我们加载了一段字符串:文本1文本2文本3,他的数据结构应该是这样的:
javascript
// 之前的结构
class PieceTreeBefore {
origin:'文本1文本2文本3',
added:[]
}
// 现在结构
class PieceTreeAfter {
// 按照一定大小分割原文本,避免字符串过长
buffers:['文本1','文本2','文本3']
}
我们现在将origin和added都统一称为为buffer(初始加载的文本可以被视为一次编辑操作),后续的操作都在buffers中,不需确定指定的Piece来自origin还是added,而是统一根据buffers的索引来找到对应的文本。使用这个方式,我们通过buffers来拼凑出完整的字符串内容,从而避免了字符串溢出的问题。
四、PiceceTree
4.1 二叉搜索树
避免了字符串的溢出陷阱,我们现在可以打开大文件,但这会导致另一个潜在的性能问题。
假设我们加载一个 64MB 的文件,piece 表将有 1000 个节点。即使我们在每个节点中缓存换行符位置,我们也不知道哪个绝对行号位于哪个节点中。要获取一行的内容,我们必须遍历所有节点,直到找到包含该行的节点。在我们的示例中,我们必须迭代最多 1000 个节点,具体取决于我们要查找的行号。因此,最坏情况的时间复杂度是 O(N)(N 是节点数)。
解决数组搜索效率的一个常见方式就是使用二叉搜索树。二叉搜索树是一种简单高效的数据结构,大部分情况下,可以将数组的查找效率降低到LogN。
示例:使用二叉搜索树在[1,5,7,9,12,15,20,25,30,40]这数组中查找数字7的过程
4.2 平衡二叉树与红黑树
缓存每个节点中的绝对行号并在节点列表上使用二分搜索可以提高查找速度,但是每当我们修改节点时,我们都必须访问所有后续节点以应用行号增量。另外,当我们在二叉搜索树中当连续插入时,二叉搜索树会退化成线性表,导致最终的数据结构和数组类似。这不是我们想要的。
以下两棵树都满足二叉搜索树的条件,下图左边是平衡树,右边是不平衡树的极端情况。在平衡树中,可以通过三步到达元素6,而在极度不平衡的情况下,需要六步才能找到元素6。
如上图所示,如果树的高度较小,搜索性能最佳。但是,如果没有任何进一步的措施,我们简单的二叉搜索树很快就会变形------或者一开始就永远不会达到良好的形状。
二分搜索的想法很好,为了达到同样的效果,我们可以利用平衡二叉树。
如果说任意节点的两个子子树的高度最多相差一,我们就说这个树的是平衡的,在平衡二叉树中,可以对插入的节点进行"旋转",使二叉搜索树都能保证其查找效率。
然而平衡树也有一个缺点,就是在旋转的时候可能导致所以后续子节点都要跟着旋转,导致多次对平衡二叉树进行插入、删除等操作时比较耗费性能。因为用户在使用编辑器时往往需要大量的编辑操作,我们这时候可以将其进一步优化成红黑树。
红黑树通过在节点上标记颜色来避免过多的"旋转"操作
红黑树也是平衡树,在红黑树插入或删除后维护二叉树平衡的成本较低,这些操作基本将在LogN的时间完成。
平衡二叉树 | 红黑树 | 备注 | |
---|---|---|---|
查询效率 | 较高 | 较低 | AVL是绝对平衡的,而红黑树最坏情况左右高度会差一倍 |
增删效率 | 较低 | 较高 | AVL要时刻保持左右子树绝对平衡,红黑树没有那么严格 |
存储空间 | 较高 | 较低 | AVL每个结点都要存储一个整数来记录平衡因子,红黑树只需要一个bit,来标识红/黑 |
常用于 | 数据库 | 语言库 | 数据库相对来说查询多,增删少。而用语言库,比如Java的HashMap,增删和查的几率基本上是一半一半 |
4.3 PieceTree
现在,我们将PieceTable的结数组构转化为一个红黑树的结构(PieceTree),来优化我们对PieceTable的查找。
首先要决定就是应该使用哪些元数据作为比较树节点的关键。如前所述,使用节点在文档中的偏移量或绝对行号将使编辑操作的时间复杂度达到 O(N)。如果我们想要 O(log n) 的时间复杂度,我们需要只与树节点的子树相关的东西。因此,当用户编辑文本时,我们重新计算修改节点的元数据,然后将元数据更改沿父节点一直到根节点进行冒泡。
如果 Node 只有四个属性( bufferIndex 、 start 、 length 、 lineStarts ),很快就能找到想要的结果。为了更快,我们还可以存储文本长度和节点左子树的换行计数。通过这种方式,通过从树根开始的偏移量或行号进行搜索可以非常高效。存储右子树的元数据是相同的,但我们不需要同时缓存两者。
现在我们的PieceTree的类和对应的节点是这样的:
javascript
class PieceTreeBase {
root: Node;
_buffers: StringBuffer[];
}
class Node {
// 当前节点在buffers中的位置
bufferIndex: number;
// 当前节点在对应buffer的开始位置
start: number;
// 当前节点在对应buffer的开始到结束位置的长度
length: number;
// 当前节点的换行符位置数组
lineStarts: number[];
// 当前节点的左子树字符串的长度
left_subtree_length: number;
// 当前节点的左子树换行符个数
left_subtree_lfcnt: number;
left: Node;
right: Node;
parent: Node;
}
现在我们以7个节点的PieceTree为例子,将节点的左子树换行符 + 自身的换行符作为节点的key。构造的红黑树结构如下:
- 每个节点的数字代表了当前的换行符 + 左子树的换行符个数
- 节点1:表示该节点有一个换行符
- 节点6:表示该节点有 6 - 1 = 5 个换行符(减去左子树的换行符得到自身的换行符个数)
- 节点9:表示该节点有 9 - 6 = 3 个换行符
- 节点16:表示该节点有 16 - 10 = 6 个换行符
查找第8行的内容:
在PieceTree中查找行的关键,首先是要找到这个行开始的对应的Piece的位置:
找到第8行的内容后,就可以从当前节点在buffers的位置中出发,根据start和length等节点的相关元信息找到对应的文本。
五、减少内存分配
假设我们在每个节点中存储换行符偏移量。每当我们更改节点时,我们可能都必须更新换行符偏移量。例如,假设我们有一个包含 999 个换行符的节点,则 lineStarts 数组有 1000 个元素。如果我们均匀地分割节点,那么我们将创建两个节点,每个节点都有一个包含大约 500 个元素的数组。由于我们不是直接在线性内存空间上进行操作,因此将数组分成两部分比仅移动指针的成本更高。
使用Piece前对数组的操作:
javascript
class PieceTree {
buffers:['\n'.repeat(1000)]
}
// 现在我们进行一个操作,在当前的节点的中间插入一个字符串G:pieceTree.insert(500,'G')
// 此时原本只有一个节点的树会变成三个节点,其中左右子节点分别对应以下更新:
let node1 = {
lineStars = buffers[0].slice(0,500)
}
let node2 = {
lineStars = buffers[0].slice(500)
}
好消息是,片段表中的缓冲区要么是只读的(原始缓冲区),要么是仅追加的(更改的缓冲区),因此缓冲区中的换行符不会移动。 Node 可以简单地保存对其相应缓冲区上的换行符偏移量的两个引用。对元数据的操作越少,性能就越好。后续的基准测试表明,应用此更改使Vscode实现中的文本缓冲区操作速度提高了三倍。
优化Piece结构后,根据行和列来计算出结点信息,无需关注lineStarts,而是根据start和end来计算出lineStarts。上述例子的完整的PieceTree如下:
下面代码表示更新后的左子节点:左节点的开始位置在第0个buffer的第0列,结束位置在第0个位置的第500列
javascript
const node_1 = {
"bufferIndex": 1,
"start": {
"line": 0,
"column": 0
},
"end": {
"line": 500,
"column": 0
},
"length": 500,
"lineFeedCnt": 500
}
六、优化结果
从理论上理解这种数据结构是一回事,现实世界的性能是另一回事。使用的编程语言、代码运行的环境、客户端调用 API 的方式以及其他因素可能会显着影响最终的结果。基准测试可以提供全面的了解,因此Vscode针对原始行数组实现和PieceTable实现对小/中/大文件运行了基准测试。
数据准备:
- checker.ts - 1.46 MB, 26k lines.
- sqlite.c - 4.31MB, 128k lines.
- Russian English Bilingual dictionary - 14MB, 552k lines.
内存优化结果
加载后的PieceTable的内存使用量非常接近原始文件大小,并且明显低于旧实现
文件打开时间
查找和缓存换行符比将文件拆分为字符串数组要快得多:
编辑
- 左图:对文本进行随机编辑
- 右图:对文本进行连续插入
正如预期的那样,当文件非常小时,行数组会获胜。访问小数组中的随机位置并调整大约 100~150 个字符的字符串非常快。当文件有很多行(100k+)时,数组开始阻塞。大文件中的顺序插入会使这种情况变得更糟,因为 JavaScript 引擎会做大量工作来调整大数组的大小。PieceTable以稳定的方式运行,因为每次编辑只是一个字符串附加和几个红黑树操作。
七、总结
在大多数情况下,PieceTree的性能优于行数组,但基于行的查找除外,这是可以预料的。但是PieceTree带来的收益远大于损失部分查找性能。
除了PieceTree,Vscode团队还尝试了使用原生语言,如C++和JavaScript的混合编译来提升软件的性能,但是没有成功。我们需要在实际应用过程中发现问题,分析各种方案,才能一步步对多种数据结构进行组合和演进,最终解决这些问题,达到一个相对最优的方案。
最后,其实很多时候性能问题并不是语言的瓶颈,而是数据结构或者说是工程师的瓶颈,Vscode的成功与其背后大佬们的顶尖能力息息相关。