python fancy чтение больших файлов (10g/50g/1t) столкнулся с проблемами производительности (ориентирован на интервью)

Один из часто задаваемых вопросов, который в последнее время постоянно преследует как на собеседованиях, так и в письменных тестах, это вопрос о том, с какой позиции читать большой файл, по крайней мере, более 10 гб, при ограниченной памяти (менее 2 гб).

Как всем известно, существует «стандартная процедура» для чтения файлов в python

def retrun_count(fname):
    """计算文件有多少行
    """
    count = 0
    with open(fname) as file:
        for line in file:
            count += 1
    return count
Войдите в полноэкранный режим Выход из полноэкранного режима

Почему этот способ чтения файлов стал стандартом? Это связано с тем, что у него есть два преимущества.

при этом менеджер контекста автоматически закрывает открытый дескриптор файла

при итерации по объекту файла содержимое возвращается построчно, не занимая много памяти

Но этот стандартный подход не лишен недостатков. Если считываемый файл вообще не содержит новых строк, то второе преимущество выше недействительно. Когда код доберется до строки в файле, строка станет очень большим строковым объектом, занимающим очень большой объем памяти.

Если у вас есть файл размером 5 ГБ, big_file.txt, он полон случайных строк. Только он хранит содержимое немного по-другому, размещая весь текст на одной строке

Если мы продолжим использовать функцию return_count, как и раньше, для подсчета количества строк в этом большом файле. На ПК этот процесс занял бы 65 секунд и при этом съел бы 2 ГБ памяти!

Чтобы решить эту проблему, нам нужно на время отложить эту «стандартную практику» в сторону и использовать более низкоуровневый метод 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 для чтения содержимого файла размером до 8 кб за раз, что позволяет избежать необходимости сшивать огромную строку и значительно сократить объем памяти.

Развязывание кода с помощью генераторов

Предположим, что мы говорим не о Python, а о каком-то другом языке программирования. Тогда справедливо будет сказать, что приведенный выше код подходит. Но если вы внимательно посмотрите на функцию return_count_v2, то увидите, что внутри тела цикла есть две отдельные логики: генерация данных (вызовы чтения и суждения о чанках) и потребление данных. Эти две отдельные логики соединены вместе.

Для улучшения возможности повторного использования мы можем определить новую функцию-генератор 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
Войдите в полноэкранный режим Выход из полноэкранного режима

В итоге, многократно используемый метод chunked read построен всего в двух строках кода, а версия, использующая генератор, занимает 7 МБ памяти / 12 секунд для завершения вычислений, по сравнению со «стандартным процессом» чтения 2 ГБ памяти на строку / 65 секунд в начале. Это почти в 4 раза эффективнее и использует менее 1% исходной памяти, что идеально.

Оцените статью
devanswers.ru
Добавить комментарий