Skip to content

locq 开发记录 ​

locq 是一个用 Go 编写的源码行数统计工具,基于 hhatto/gocloc 继续开发。它的工作是扫描目录、识别语言,再统计代码、注释和空白行。

在 Apple M3 上扫描 Go 源码仓库的一次默认参数测试中,locq 的平均耗时为 249 ms,tokei 为 247 ms,上游 gocloc 为 1457 ms。各工具的统计口径有所不同,这组结果主要反映该场景下的使用体验。

让性能接近 tokei,主要做了下面几类优化。

1. 将串行扫描改成并发流水线 ​

原来的实现先收集文件,再逐个分析。目录遍历和文件统计前后分离,文件分析也主要串行执行,多个 CPU 核心难以同时参与。

locq 将它拆成可以重叠执行的流水线:目录遍历负责发现文件,多个 worker 并行完成语言识别、读取和统计,最后统一收集结果。目录还没遍历完,已经发现的文件就能开始分析。

并发数量和等待中的任务数量都有上限,避免文件一多就创建大量 goroutine、打开过多文件或堆积结果。收集端按发现顺序处理,保证去重保留的文件不受 worker 完成顺序影响。

2. 合并语言识别、去重和统计的文件读取 ​

语言识别可能需要读取文件头,去重需要计算内容摘要,行数统计还要读取源码。如果各自独立处理,同一个文件就可能被反复打开和读取。

优化后,这些操作共享读取过程:识别语言时读过的内容继续交给行分析;开启去重时,在读取字节的同时计算摘要。这样可以减少重复读取、文件打开和缓冲初始化的成本。

3. 减少逐行分配,复用读取缓冲 ​

对于几百万行源码,即使每行只分配一个字符串,累计成本也不小。locq 将普通统计路径改成以 []byte 为主,直接在字节上判断空白、注释和代码,只有需要向回调或调试输出提供文本时才构造字符串。

读取普通短行时,直接借用 bufio.Reader 的缓冲;遇到超长行才拼接,并复用拼接空间。每个 worker 持有自己的读取器,处理下一个文件时重置输入,继续使用已有缓冲。异常大的长行缓冲会在文件处理结束后释放,避免长期占用内存。

在一次开启去重的对照测试中,每次完整扫描的累计分配量从约 281 MiB 降到 20 MiB,减少约 93%。减少短命对象,也减轻了分配器和垃圾回收的负担。

4. 减少目录遍历中的无效工作 ​

目录遍历改用 WalkDir,利用目录项已有的类型信息,减少逐个条目获取完整文件信息的需要。

对于确定排除的目录,直接停止向下遍历。大型仓库里的版本控制目录或用户排除目录,可能包含大量文件;在入口剪枝,可以省掉后续的路径处理、语言识别和文件访问。

5. 为常见源码行提供快速路径 ​

源码统计需要识别字符串和跨行注释,但很多普通代码行并不需要完整的逐字节状态处理。

locq 在确认当前不处于跨行字符串、块注释等复杂状态,并满足语言规则条件时,对常见行直接完成分类。遇到可能改变状态的标记,再进入完整的词法扫描逻辑。

这样可以减少普通行上的判断成本,同时保留对字符串中注释符号、原始字符串和多行注释的处理。

6. 将内容去重改成按需开启 ​

默认按文件统计,不计算内容摘要,也不维护去重检查。需要按内容去重时,再显式使用 --dedup。

这项调整省掉了普通扫描不需要的工作,但也改变了重复文件的统计方式:两个路径下即使内容相同,默认也会分别计数。因此,它属于默认行为的选择,收益需要与扫描实现本身的优化分开理解。

这些优化集中在几个反复发生的成本上:等待前一个文件处理完成、重复读取同一份内容、为每一行分配对象,以及访问不需要扫描的目录。把这些成本逐项削减后,locq 在这次 Go 仓库测试中的耗时已经接近 tokei。