有效读取文件中特定行号的方法。(附加:Python手册印误)

6 投票
5 回答
6713 浏览
提问于 2025-04-16 03:25

我有一个100GB的文本文件,这是从数据库导出的BCP转储。当我尝试用BULK INSERT导入时,在第219506324行遇到了一个难以理解的错误。在解决这个问题之前,我想先看看这一行,但可惜我最喜欢的方法

import linecache
print linecache.getline(filename, linenumber)

却抛出了一个MemoryError。有趣的是,手册上说“这个函数永远不会抛出异常。”但在这个大文件上,我尝试读取第一行时却抛出了异常,而我大约有6GB的空闲内存……

我想知道有什么最优雅的方法可以到达那一行。可用的工具有Python 2、Python 3和C# 4(Visual Studio 2010)。是的,我明白我总是可以做一些像

var line = 0;
using (var stream = new StreamReader(File.OpenRead(@"s:\source\transactions.dat")))
{
     while (++line < 219506324) stream.ReadLine(); //waste some cycles
     Console.WriteLine(stream.ReadLine());
}

这样的事情,虽然这样可以工作,但我怀疑这不是优雅的方法。

编辑:我在等待关闭这个讨论,因为包含文件的硬盘现在正被另一个进程使用。我将测试两个建议的方法并报告时间。感谢大家的建议和评论。

结果出来了 我实现了Gabe和Alex的方法,看看哪个更快。如果我做错了什么,请告诉我。我正在使用Gabe建议的方法去找我100GB文件中的第10000000行,然后用Alex建议的方法,这个方法我大致翻译成了C#……我唯一添加的就是先将一个300MB的文件读入内存,以清除硬盘缓存。

const string file = @"x:\....dat"; // 100 GB file
const string otherFile = @"x:\....dat"; // 300 MB file
const int linenumber = 10000000;

ClearHDDCache(otherFile);
GabeMethod(file, linenumber);  //Gabe's method

ClearHDDCache(otherFile);
AlexMethod(file, linenumber);  //Alex's method

// Results
// Gabe's method: 8290 (ms)
// Alex's method: 13455 (ms)

Gabe的方法实现如下:

var gabe = new Stopwatch();
gabe.Start();
var data = File.ReadLines(file).ElementAt(linenumber - 1);
gabe.Stop();
Console.WriteLine("Gabe's method: {0} (ms)",  gabe.ElapsedMilliseconds);

而Alex的方法稍微复杂一些:

var alex = new Stopwatch();
alex.Start();
const int buffersize = 100 * 1024; //bytes
var buffer = new byte[buffersize];
var counter = 0;
using (var filestream = File.OpenRead(file))
{
    while (true) // Cutting corners here...
    {
        filestream.Read(buffer, 0, buffersize);
        //At this point we could probably launch an async read into the next chunk...
        var linesread = buffer.Count(b => b == 10); //10 is ASCII linebreak.
        if (counter + linesread >= linenumber) break;
        counter += linesread;
    }
}
//The downside of this method is that we have to assume that the line fit into the buffer, or do something clever...er
var data = new ASCIIEncoding().GetString(buffer).Split('\n').ElementAt(linenumber - counter - 1);
alex.Stop();
Console.WriteLine("Alex's method: {0} (ms)", alex.ElapsedMilliseconds);

所以除非Alex愿意评论,否则我将把Gabe的解决方案标记为接受。

5 个回答

1

你可以试试这个简单的命令:sed '42q;d',它可以用来获取第42行的内容。这不是用Python或C#写的,不过我想你在你的Mac上应该有sed这个工具。

6

内存随时都有可能用完,这种情况是不可预测的,所以“永远不会出现异常”的说法在这里并不适用。就像在Java中,每个方法都必须说明可能会抛出的异常一样,有些异常是例外,因为几乎任何方法都可能因为资源不足或其他系统问题而在任何时候抛出异常。

linecache会尝试读取整个文件。如果你不着急,简单的替代方法是从头开始逐行读取...:

def readoneline(filepath, linenum):
    if linenum < 0: return ''
    with open(filepath) as f:
        for i, line in enumerate(filepath):
            if i == linenum: return line
        return ''

这里的linenum是从0开始的(如果你不喜欢这样,而你的Python版本是2.6或更高,可以给enumerate传一个起始值1),对于无效的行号,返回值是空字符串。

稍微快一点(但复杂得多)的方法是一次读取,比如100MB的数据(以二进制模式),放到一个缓冲区里;然后计算缓冲区中换行符的数量(只需对缓冲区字符串对象调用.count('\n'));一旦换行符的总数超过你要找的行号,就在缓冲区中找到第N个换行符(这里的N是你要找的行号减去之前的换行符总数),如果第N+1个换行符不在缓冲区中,就再读取一点数据(因为这时就是你的行结束的位置),然后提取相关的子字符串。这个过程不仅仅是几行代码,还涉及到with和异常情况的返回...;-)

编辑:因为提问者怀疑按缓冲区读取而不是按行读取是否能提高性能,我找出了以前测量这两种方法的代码,任务是计数行数,使用缓冲区方法、逐行循环,或者一次性读取整个文件(通过readlines)。目标文件是kjv.txt,这是《圣经》英文国王詹姆斯版的标准文本,每行一节,ASCII编码:

$ wc kjv.txt 
  114150  821108 4834378 kjv.txt

测试的平台是MacOS Pro笔记本,OSX 10.5.8,Intel Core 2 Duo,2.4 GHz,Python 2.6.5。

测试模块是readkjv.py

def byline(fn='kjv.txt'):
    with open(fn) as f:
        for i, _ in enumerate(f):
            pass
    return i +1

def byall(fn='kjv.txt'):
    with open(fn) as f:
        return len(f.readlines())

def bybuf(fn='kjv.txt', BS=100*1024):
    with open(fn, 'rb') as f:
        tot = 0
        while True:
            blk = f.read(BS)
            if not blk: return tot
            tot += blk.count('\n')

if __name__ == '__main__':
    print bybuf()
    print byline()
    print byall()

这些print语句只是为了确认结果的正确性(确实是这样;-)。

测量结果是在进行几次干跑后进行的,以确保每个人都能平等地受益于操作系统、磁盘控制器和文件系统的预读取功能(如果有的话):

$ py26 -mtimeit -s'import readkjv' 'readkjv.byall()'
10 loops, best of 3: 40.3 msec per loop
$ py26 -mtimeit -s'import readkjv' 'readkjv.byline()'
10 loops, best of 3: 39 msec per loop
$ py26 -mtimeit -s'import readkjv' 'readkjv.bybuf()'
10 loops, best of 3: 25.5 msec per loop

这些数字是相当稳定的。正如你所看到的,即使在这样一个小文件(不到5MB!)上,逐行读取的速度也比基于缓冲区的慢——实在是浪费了太多精力!

为了检查可扩展性,我接下来使用了一个大四倍的文件,如下所示:

$ cat kjv.txt kjv.txt kjv.txt kjv.txt >k4.txt
$ wc k4.txt
  456600 3284432 19337512 k4.txt
$ py26 -mtimeit -s'import readkjv' 'readkjv.bybuf()'
10 loops, best of 3: 25.4 msec per loop
$ py26 -mtimeit -s'import readkjv' 'readkjv.bybuf("k4.txt")'
10 loops, best of 3: 102 msec per loop

正如预测的那样,基于缓冲区的方法几乎是线性扩展的。推测一下(当然这总是有风险的;-),每秒不到200MB的速度似乎是可预测的——可以说每GB大约需要6秒,或者100GB可能需要10分钟。

当然,这个小程序只是计数行数,但(只要有足够的输入输出以摊销常量开销;-))读取特定行的程序应该有类似的性能(尽管一旦找到“感兴趣”的缓冲区,它需要更多的处理,但对于给定大小的缓冲区,这是一种大致恒定的处理量——大概是不断对缓冲区进行二分,以识别出足够小的部分,然后再进行一些与“剩余缓冲区”大小成线性关系的处理)。

优雅吗?并不太...但是,为了速度,确实很难超越!-)

8

这是我在C#中的优雅版本:

Console.Write(File.ReadLines(@"s:\source\transactions.dat").ElementAt(219506323));

或者更通用一点:

Console.Write(File.ReadLines(filename).ElementAt(linenumber - 1));

当然,你可能想在给定的代码行之前和之后显示一些上下文:

Console.Write(string.Join("\n",
              File.ReadLines(filename).Skip(linenumber - 5).Take(10)));

或者更流畅一点:

File
.ReadLines(filename)
.Skip(linenumber - 5)
.Take(10)
.AsObservable()
.Do(Console.WriteLine);

顺便说一下,linecache模块在处理大文件时并没有什么聪明的办法。它只是把整个文件读进来,全部保存在内存里。它唯一能捕捉到的异常是和输入输出有关的(比如无法访问文件、文件未找到等等)。以下是代码中重要的部分:

    fp = open(fullname, 'rU')
    lines = fp.readlines()
    fp.close()

换句话说,它试图把一个100GB的文件塞进6GB的内存里!手册里应该说的是“如果无法访问文件,这个函数将永远不会抛出异常。”

撰写回答