Python花式讀取大文件(10g/50g/1t)遇到的性能問題(面試向)

原文轉載自「劉悅的技術博客」https://v3u.cn/a_id_97

最近無論是面試還是筆試,有一個高頻問題始終陰魂不散,那就是給一個大文件,至少超過10g,在內存有限的情況下(低於2g),該以什麼姿勢讀它?

所有人都知道,用python讀文件有一套”標準流程“:

def retrun_count(fname):
    """計算文件有多少行
    """
    count = 0
    with open(fname) as file:
        for line in file:
            count += 1
    return count

爲什麼這種文件讀取方式會成爲標準?這是因爲它有兩個好處:

with 上下文管理器會自動關閉打開的文件描述符
在迭代文件對象時,內容是一行一行返回的,不會佔用太多內存

但這套標準做法並非沒有缺點。如果被讀取的文件裏,根本就沒有任何換行符,那麼上面的第二個好處就不成立了。當代碼執行到 for line in file 時,line 將會變成一個非常巨大的字符串對象,消耗掉非常可觀的內存。

如果有一個 5GB 大的文件 big_file.txt,它裏面裝滿了隨機字符串。只不過它存儲內容的方式稍有不同,所有的文本都被放在了同一行裏

如果我們繼續使用前面的 return_count 函數去統計這個大文件行數。那麼在一臺pc上,這個過程會足足花掉 65 秒,並在執行過程中吃掉機器 2GB 內存

爲了解決這個問題,我們需要暫時把這個“標準做法”放到一邊,使用更底層的 file.read() 方法。與直接循環迭代文件對象不同,每次調用 file.read(chunk_size) 會直接返回從當前位置往後讀取 chunk_size 大小的文件內容,不必等待任何換行符出現。

所以,如果使用 file.read() 方法,我們的函數可以改寫成這樣:

def return_count_v2(fname):

    count = 0
    block_size = 1024 * 8
    with open(fname) as fp:
        while True:
            chunk = fp.read(block_size)
            # 當文件沒有更多內容時,read 調用將會返回空字符串 ''
            if not chunk:
                break
            count += 1
    return count

在新函數中,我們使用了一個 while 循環來讀取文件內容,每次最多讀取 8kb 大小,這樣可以避免之前需要拼接一個巨大字符串的過程,把內存佔用降低非常多。

利用生成器解耦代碼

假如我們在討論的不是 Python,而是其他編程語言。那麼可以說上面的代碼已經很好了。但是如果你認真分析一下 return_count_v2 函數,你會發現在循環體內部,存在着兩個獨立的邏輯:數據生成(read 調用與 chunk 判斷) 與 數據消費。而這兩個獨立邏輯被耦合在了一起。

爲了提升複用能力,我們可以定義一個新的 chunked_file_reader 生成器函數,由它來負責所有與“數據生成”相關的邏輯。這樣 return_count_v3 裏面的主循環就只需要負責計數即可。

def chunked_file_reader(fp, block_size=1024 * 8):
    """生成器函數:分塊讀取文件內容
    """
    while True:
        chunk = fp.read(block_size)
        # 當文件沒有更多內容時,read 調用將會返回空字符串 ''
        if not chunk:
            break
        yield chunk


def return_count_v3(fname):
    count = 0
    with open(fname) as fp:
        for chunk in chunked_file_reader(fp):
            count += 1
    return count

進行到這一步,代碼似乎已經沒有優化的空間了,但其實不然。iter(iterable) 是一個用來構造迭代器的內建函數,但它還有一個更少人知道的用法。當我們使用 iter(callable, sentinel) 的方式調用它時,會返回一個特殊的對象,迭代它將不斷產生可調用對象 callable 的調用結果,直到結果爲 setinel 時,迭代終止。

def chunked_file_reader(file, block_size=1024 * 8):
    """生成器函數:分塊讀取文件內容,使用 iter 函數
    """
    # 首先使用 partial(fp.read, block_size) 構造一個新的無需參數的函數
    # 循環將不斷返回 fp.read(block_size) 調用結果,直到其爲 '' 時終止
    for chunk in iter(partial(file.read, block_size), ''):
        yield chunk

最後只需要兩行代碼,就構造出了一個可複用的分塊讀取方法,和一開始的”標準流程“按行讀取 2GB 內存/耗時 65 秒 相比,使用生成器的版本只需要 7MB 內存 / 12 秒就能完成計算。效率提升了接近 4 倍,內存佔用更是不到原來的 1%,簡直完美。

原文轉載自「劉悅的技術博客」 https://v3u.cn/a_id_97

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章