18. August 2021
使用 Linux 时想从许多文件中搜索关键词怎么办?Linux 本身有着 grep
命令可以完成这一任务。但 grep
较为古老,使用不便且效率较低,本文介绍一个更为现代的搜索工具:ripgrep
。
ripgrep 是一个替代 grep
(或者说 GNU grep
)命令的搜索工具。其主要特点是命令的使用更为方便实用,以及搜索性能极高,在庞大的项目中有着出色的表现。并且默认可以忽略 .gitignore
文件中的内容,非常实用。
对网上的帖子的避雷: 官网是最好的。不知道为什么百度/谷歌到的第一条中文帖子居然教导大家先安装 Rust 再用
cargo
安装ripgrep
,愚蠢。虽然ripgrep
是用 Rust 写的,但早已可以在多种系统下直接安装,不需要安装 Rust。为了新手友好以下简略介绍一下基础使用。
所需背景知识: 本文前半部分(安装、常用用法)会基础的 Linux 操作即可。 后半部分(深入细节)是原理分析,需要一点计算机体系结构、操作系统、算法等基础知识,为对原理感兴趣的同学抛砖引玉。
详见 GitHub repo 安装说明。对于大部分系统可以直接使用包管理工具安装(例如 macOS 可用 Homebrew
)。除非你使用 Rust 否则不必使用 cargo
进行安装。
特别的,如果你使用 Debian 10 以下或 Ubuntu 18.10 以下,需要手动下载安装包 deb
文件进行安装。注意其中的版本号可以自行修改,可以去 Release 页面找到最新版本进行下载安装。
1$ curl -LO https://github.com/BurntSushi/ripgrep/releases/download/13.0.0/ripgrep_13.0.0_amd64.deb
2$ sudo dpkg -i ripgrep_13.0.0_amd64.deb
检查版本:
$ rg --version
ripgrep 13.0.0
-SIMD -AVX (compiled)
+SIMD +AVX (runtime)
我的日常基本只用到这些,比使用 grep
简短许多!
默认用法:在当前目录下的所有文件内容中,递归搜索字符串。例如,搜索 "hello":
$ rg hello
ripgrep
的输入认为是正则表达式,因此一些字符需要转义(并由于 bash/zsh 也识别正则表达式的原因需加单引号),例如,如果要搜索 "main()":
1$ rg 'main\(\)'
2test.cpp
313:int main() {
查看上下文, -A
表示 "after" 的行数, -B
表示 "before" 的行数:
$ rg somebody -A 2 -B 2
test.txt
4-They'd banish us, you know.
5-
6:How dreary to be somebody!
7-How public, like a frog
8-To tell your name the livelong day
在特定文件名模式中搜索,使用 -g
参数(全称 --glob
):
$ rg main -g '*.cpp'
在当前目录及其所有子目录中搜索文件名:
$ rg --files | rg test
test.txt
test.cpp
更多用法和细节可以参考文档或官网或是网络上其它帖子,但我个人使用过程中上述几种就足够了。
ripgrep
另一个重要优势是其非常高的性能。在其作者 Andrew Gallant 的这篇博客中说明了 ripgrep
性能高的主要原因及一些性能评估方法。这篇博客质量极高,思维严谨,论述详实,在此做简要概括。
ripgrep
使用 Rust 语言编写的,得益于许多 Rust 原生库的优势。在设计的时候需要重点考虑以下几个角度:
grep
是默认不进行任何递归搜索,输入什么就搜索什么,因此 grep
会更侧重于考虑对于单一大文件如何进行更快速的搜索。ack
是后来出现的搜索工具,默认就对当前目录进行递归搜索,于是需要更多考虑文件系统的递归遍历效率。 ripgrep
也是沿用了这一风格。文件的遍历看似简单,但一个没有精心设计的迭代过程会使用过多不必要的系统调用,导致性能退化。并且,由于相关逻辑十分底层,追踪困难,连 Python 也是 2014 年前才实现了 scandir() 的优化。
Rust 库中的递归迭代器已经实现好了这一部分,尽可能减少系统调用的次数, ripgrep
借此获得了遍历上的高效。此外,还要处理文件名筛选、忽略 .gitignore
中的文件等情况,博客中没有详细说明算法细节,如有可能之后补充。
ripgrep
会将文本划分到多个线程进行并行搜索。线程之间需要进行同步,相比有锁数据结构, ripgrep
使用了更为快速的无锁队列 Chase-Lev work-stealing queue 进行通信。ripgrep
做了这样的决定。ripgrep
需要支持正则搜索。正则搜索引擎主要有两个类型:回溯法和有穷自动机。回溯法可以很快、支持的语法更全面,但在最坏情况下会十分缓慢。有穷自动机相对不全面,但可以保证在线性时间内求解。
考虑到,如果进行普通的字符串搜索匹配(如 Boyer-Moore 算法)其效率是要高于正则匹配的,一方面是其算法本身已经设计的很好,一方面是还存在一些硬件优化(如 C 语言中使用了 SIMD 指令的函数 memchr)。那么能否利用这些优势加快我们的搜索?
由于 ripgrep
的使用场景下,通常是文本空间很大,但能成功匹配的很少。于是 ripgrep
使用了一个技巧:提取正则表达式中的一些常量关键词,如 "\w+foo\d+" 中的 “foo“。先使用字符串搜索匹配算法查找 “foo“,在能够匹配的字符串上,再进行较慢的正则匹配来确认。更多细节请自行参考博客。
虽然 ripgrep
的输出是按行输出,但不等于搜索时候就要按行搜索。理由很简单,因为按行分割文本本身就是一个比较耗时的事情,而成功匹配又是一个低概率事件。因此所有搜索工具都是直接对一个较大的文本块进行搜索,匹配成功后再将相应行打印输出。
ripgrep
使用的方案就是一块一块地读入文本进行匹配的增量搜索 (incrementally searching)。但这其中涉及非常多复杂的情况需要考虑,例如需要打印行号的时候、两块的分界在一行文字的中间、一行文字的大小比块还要大、显示上下文等等,程序的逻辑十分复杂。
其它工具有的解决方案是如将文件全部读入或是利用交换分区等,但这时就无法对标准输入流进行搜索了。 ripgrep
为了应对各种需求,选择了更为复杂的方式。
此外,这篇博客中有一半的篇幅在描述性能的评估,角度非常全面,值得学习。