В этой статье мы рассмотрим, как эффективно читать большие текстовые файлы с минимальным использованием памяти в Python. Мы начнем с обзора наиболее распространенных методов и затем перейдем к конкретным примерам кода.

Почему важно знать об этой теме?

Обработка больших текстовых файлов — распространенная задача в различных областях, таких как анализ данных, машинное обучение, веб-скрапинг и другие. Например, при работе с логами веб-сервера, которые могут достигать гигабайтов в размере, или при обработке больших наборов данных, таких как базы данных транзакций. В таких сценариях, когда файлы слишком велики для загрузки в память целиком, эффективное управление памятью становится критически важным.

Знание методов эффективного чтения больших файлов позволяет:

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

  2. Улучшить производительность: Чтение файла по частям позволяет обрабатывать данные постепенно, что может значительно ускорить выполнение задачи, особенно на системах с ограниченными ресурсами.

  3. Оптимизировать использование ресурсов: Эффективное использование памяти позволяет запускать программы на менее мощных машинах или обрабатывать большие объемы данных без необходимости апгрейда оборудования.

Как эффективно обработать большой файл в Python?

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

1. Использование итераторов и генераторов

Итераторы и генераторы позволяют читать файлы построчно, что значительно снижает потребление памяти. Вместо загрузки всего файла в память, они обрабатывают данные по мере их поступления.

Плюсы:

  • Эффективное использование памяти: Итераторы и генераторы позволяют обрабатывать данные по мере их поступления, что значительно снижает потребление памяти.

  • Простота реализации: Эти подходы легко реализовать и интегрировать в существующий код.

  • Поддержка потоковой обработки: Подходят для задач, где данные могут обрабатываться последовательно и не требуют одновременной загрузки всего файла.

Минусы:

  • Ограниченная функциональность: Не все операции могут быть выполнены эффективно с использованием итераторов и генераторов, особенно если требуется произвольный доступ к данным.

  • Медленная построчная обработка: Обработка данных построчно может быть медленнее, чем другие методы, особенно если файл очень большой.

2. Чтение файла по частям

Чтение файла по частям, например, по определенному числу строк или бит/байт за раз, также может быть эффективным способом обработки больших файлов. Этот метод позволяет управлять объемом данных, которые находятся в памяти в любой момент времени.

Плюсы:

  • Управление объемом данных: Позволяет явно контролировать объем данных, которые находятся в памяти в любой момент времени.

  • Многопоточность и многопроцессность: Позволяет читать и обрабатывать данные в нескольких потоках или процессах, что может значительно ускорить обработку больших объемов данных.

  • Гибкость: Можно настроить размер чанка в зависимости от доступной памяти и требований задачи.

  • Подходит для различных форматов данных: Этот подход можно применять не только к текстовым файлам, но и к бинарным данным.

Минусы:

  • Дополнительная сложность: Требует дополнительной логики для обработки данных, которые могут быть разделены между чанками.

  • Не всегда оптимально: Для некоторых задач, особенно если требуется частая обработка данных, этот подход может быть менее эффективным по сравнению с итераторами и генераторами.

3. Использование сопоставления памяти (Memory-Mapped Files)

Сопоставление памяти — это мощный метод, который позволяет обращаться к большим файлам так, как если бы они хранились непосредственно в памяти. Этот подход особенно полезен при выполнении операций произвольного доступа к большим файлам, так как он упрощает работу с данными и может значительно повысить производительность.

В Python на 64-битных системах можно использовать модуль mmap для обхода ограничений по памяти. Такой подход увеличит скорость обработки больших файлов. Однако стоит внимательно относиться к проблемам адресации на 32-битных системах.

Плюсы:

  1. Производительность: Операции с памятью обычно быстрее, чем традиционные операции ввода-вывода, такие как read и write. Это может привести к значительному ускорению работы, особенно для операций, которые часто обращаются к одним и тем же частям файла.

  2. Простота использования: Вы можете работать с файлом как с массивом байтов, что упрощает код и делает его более понятным.

  3. Снижение накладных расходовmmap может снизить накладные расходы на копирование данных между пользовательским пространством и пространством ядра.

  4. Эффективное использование памяти: Файл отображается в память по мере необходимости, что снижает потребление памяти.

Минусы:

  1. Использование памяти: Файлы, отображенные в память, могут занимать значительный объем памяти. Это может быть проблемой, если у вас ограниченные ресурсы памяти.

  2. Синхронизация: При изменении данных в отображенной памяти, изменения автоматически записываются обратно в файл. Однако, если процесс завершится аварийно, изменения могут быть потеряны. Поэтому важно использовать механизмы синхронизации, такие как msync.

  3. Безопасность: Неправильное использование mmap может привести к уязвимостям, таким как переполнение буфера. Важно тщательно проверять границы доступа к памяти.

  4. Ограничения операционной системы: Не все операционные системы поддерживают mmap одинаково хорошо.

  5. Сложность реализации: Использование mmap может быть сложнее, чем другие методы, особенно для новичков.

Теперь перейдём к рассмотрению основных методов и способов. Однако, прежде чем начать, хотелось бы уточнить один важный момент:

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

Использование итераторов и генераторов

Одним из правильных и полностью Pythonic способов чтения файла считается, использование конструкции:

with open(...) as file:
    for line in file:
	    your_func(line) # Ваша кастомная функция для обработки строк

Оператор with обрабатывает открытие и закрытие файла, в том числе, если во внутреннем блоке возникает исключение. for line in file Обрабатывает файловый объект file как итерируемый, который автоматически использует буферизованный ввод-вывод и управление памятью, поэтому вам не нужно беспокоиться о больших файлах. Вы просто берёте конкретную строку и обрабатываете её, следуя своим целям.

Другим примером может послужить случай, когда необходимо обрабатывать именно определенное кол-во строк за раз. Например, в файле хранится информация о некоторых объектах, пусть это будут машины. В файле на определение каждой машины, выделено 5 строк (марка машины, модель, год выпуска, пробег, цвет).

Давайте определим структуру класса Car:

class Car:
    def __init__(self, brand: str, model: str, year: int, mileage: int, color: str):
        self.brand = brand
        self.model = model
        self.year = year
        self.mileage = mileage
        self.color = color

    def __repr__(self) -> str:
        return f"Car(brand={self.brand}, model={self.model}, year={self.year}, mileage={self.mileage}, color={self.color})"

P.S. Строки документации отсутствуют намеренно, чтобы упростить примеры.

Теперь напишем наш генератор и небольшую обработку:

def read_car_batches(file: Iterator[str]) -> Iterator[List[str]]:
    batch: List[str] = []
    for line in file:
        batch.append(line.strip())
        if len(batch) == 5:
            yield batch
            batch = []
    if batch:
        yield batch

# Откройте файл для чтения
with open('cars.txt', 'r') as file:
    for batch in read_car_batches(file):
        # Выполняем преобразования с batch, если необходимо
        # Например, можно выполнить проверку или очистку данных
        try:
            car = Car(*batch)
            print(car)
        except Exception as e:
            print(f"Error creating Car object: {e}")
        # Здесь вы можете выполнить любые другие операции с объектом Car

На примере этого способа, можно рассмотреть и обработку файла.

Так, например если вам нужно извлечь определенную информацию мы можем прибегнуть к помощи регулярных выражений, используя модуль re:

import re

def extract_patterns(file_path: str, pattern: str) -> None:
    with open(file_path, 'r') as file:
        for line in file:
            matches: List[str] = re.findall(pattern, line)
            if matches:
                print(matches)

pattern_to_extract: str = r'^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$'  # Пример паттерна для поиска текста в кодировке Base64
extract_patterns('cars.txt', pattern_to_extract)

Это лишь один из множества примеров обработки файлов, которые предоставляет нам Python.

Однако использование итераторов и генераторов не является универсальным.

Представьте следующую ситуацию: у нас есть большой файл, в котором только одна строка (да-да, такое тоже встречается). Что делать в такой ситуации? Ведь, размер файла не позволит нам прочитать его целиком, а разделить его на строки мы просто не можем...

Чтение файла по частям

Chunking

Chunking (Чанкинг) - это вычислительный метод, который включает разбиение большого набора данных или текстового файла на более мелкие, более управляемые части, известные как чанки. Разбиение на фрагменты большого текстового файла позволяет более эффективно обрабатывать данные и извлекать соответствующую информацию.

def read_large_file(file_path: str, chunk_size: int) -> Generator[str, None, None]:
    with open(file_path, 'r') as file:
        while True:
            chunk = file.read(chunk_size)
            if not chunk:
                break
            yield chunk

# Пример использования
for chunk in read_large_file(file_path):
    # Обработка каждого чанка по мере необходимости
    print(chunk)

Таким образом вы сможете получить часть данных основного файла, после чего проанализировать её и перейти к следующей части не переживая о памяти компьютера.

Важно отметить! Размер чанка (chunk_size) может влиять на производительность. Слишком маленький размер может привести к избыточным операциям ввода-вывода, а слишком большой — к неэффективному использованию памяти. Оптимальный размер зависит от конкретного случая и может потребовать экспериментов.

Есть один нюанс, что если у нас стоит задача поиска определенной подстроки? Здесь может возникнуть ситуация, когда подстрока находится на границах двух чанков и мы попросту можем не обработать её корректно.

В таком случае, вам следует обратить внимание на два подхода:

  1. Буферизация

  2. Сохранение контекста

Буферизация

Буферизация заключается в сохранении части данных из предыдущего чанка, чтобы обеспечить непрерывность при обработке. Это особенно полезно, когда вы ищете строки или обрабатываете данные, которые могут быть разделены между чанками.

Предположим, вы ищете строку "ThisIsTheStringILikeToFind" в большом файле. Вот как можно использовать буферизацию:

def search_large_file(file_path: str, search_string: str, chunk_size: int) -> Generator[int, None, None]:
    search_length = len(search_string)
    overlap = search_length - 1
    buffer = ""

    with open(file_path, 'r') as file:
        while True:
            chunk = file.read(chunk_size)
            if not chunk:
                break

            buffer += chunk
            while search_string in buffer:
                index = buffer.index(search_string)
                yield index + len(search_string) - search_length
                buffer = buffer[index + search_length:]

            if overlap < len(buffer):
                buffer = buffer[-overlap:]

    if search_string in buffer:
        yield buffer.index(search_string) + len(search_string) - search_length

file_path = 'large_file.txt'
search_string = "ThisIsTheStringILikeToFind"
for position in search_large_file(file_path, search_string):
    print(f"Found at position: {position}")

В этом примере:

  1. Переменная buffer: Используется для хранения части предыдущего чанка.

  2. Переменная overlap: Определяет размер перекрытия, который равен длине искомой строки минус один символ.

  3. Поиск строки: Внутри цикла, пока искомая строка находится в буфере, мы выводим её позицию и обновляем буфер, удаляя найденную строку.

  4. Обновление буфера: После обработки чанка мы обновляем буфер, сохраняя только последние overlap символов, чтобы обеспечить корректное перекрытие с следующим чанком.

Сохранение контекста

В мире больших данных часто возникает необходимость обрабатывать огромные объемы информации, например такие как большие JSON-файлы. Однако, при работе с такими данными возникает проблема: как корректно обрабатывать данные, которые пересекают границы чанков (частей файла)? Решением этой проблемы является сохранение контекста.

Что такое сохранение контекста?

Сохранение контекста — это механизм, который позволяет сохранять состояние обработки данных между различными чанками. Это особенно важно, когда данные имеют сложную структуру или требуют более сложной логики обработки.

В примере ниже мы рассмотрим, как можно обрабатывать большой текстовый файл, используя только встроенные библиотеки Python. Мы будем читать файл по частям (чанкам) и обрабатывать каждый чанк отдельно, сохраняя контекст между ними.

Пример:

Предположим, у нас есть большой текстовый файл, и мы хотим подсчитать количество строк, содержащих определенное слово. Мы будем читать файл по частям и подсчитывать строки, содержащие это слово.

def process_large_file(file_path: str, search_word: str) -> int:
    chunk_size = 1024 * 1024  # Размер чанка: 1 МБ
    total_lines = 0
    matching_lines = 0
    context = ''

    with open(file_path, 'r') as file:
        while True:
            chunk = file.read(chunk_size)
            if not chunk:
                break

            # Обработка чанка
            lines = (buffer + chunk).splitlines()
            context = lines.pop()  # Сохраняем последнюю строку для следующего чанка

            for line in lines:
                total_lines += 1
                if search_word in line:
                    matching_lines += 1

    # Обработка последнего чанка
    if context:
        total_lines += 1
        if search_word in context:
            matching_lines += 1

    return matching_lines

# Пример использования
file_path = 'large_file.txt'
search_word = 'example'
matching_lines = process_large_file(file_path, search_word)
print(f'Количество строк, содержащих "{search_word}": {matching_lines}')

Как это работает?

  1. Чтение файла по частям: Мы читаем файл по частям (чанкам) фиксированного размера (в данном случае 1 МБ).

  2. Сохранение контекста: Мы сохраняем последнюю строку из предыдущего чанка в переменной buffer, чтобы корректно обработать строки, которые могут пересекать границы чанков. (В зависимости от задач, вы можете сохранять различные объемы данных)

  3. Обработка чанка: Мы разбиваем чанк на строки и обрабатываем каждую строку, подсчитывая количество строк, содержащих искомое слово.

  4. Обработка последнего чанка: После завершения чтения файла, мы обрабатываем последний чанк, чтобы учесть строки, которые могли остаться в buffer.

Сохранение контекста позволяет:

  • Эффективно обрабатывать большие файлы: Обрабатывать данные по частям, не загружая весь файл в память.

  • Обеспечивать корректность обработки: Гарантировать, что данные будут обработаны правильно, даже если они пересекают границы чанков.

Сравнение буферизации и сохранения контекста

Метод

Цель

Применение

Пример

Буферизация

Обеспечение непрерывности обработки данных, которые могут быть разделены между чанками.

Поиск строк, обработка данных с учетом границ чанков.

Поиск строки в большом файле.

Сохранение контекста

Сохранение состояния обработки данных между различными чанками, особенно важно для сложной структуры данных.

Обработка больших файлов, JSON-файлов, данных с сложной структурой.

Подсчет строк, содержащих определенное слово в большом текстовом файле.

Заключение

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

Не всегда нам приходится считывать файлы напрямую из файла, иногда они поступают к нам в режиме онлайн. В таком случае принято пользоваться потоковой обработкой данных (stream processing). Если вам интересно почитать про это подробнее на хабре есть очень хорошая статья, которая во многом совпадает с моими наработками: Архитектурные решения для обработки потоковых данных / Хабр и ещё одна статья с подробным разбором - Streaming 101 (Основы потоковой обработки) / Хабр.

Сопоставление памяти

Использование "Memory mapping" (сопоставление памяти) может показаться немного более сложным по сравнению с традиционным файловым вводом-выводом, так как требует создания объекта mmap. Однако это небольшое усложнение может значительно ускорить работу с файлами, особенно с файлами размером в несколько мегабайт.

Основные шаги при использовании mmap:

  • Открыть файл с помощью open() недостаточно. Дополнительно нужно использовать метод mmap() из одноименной библиотеки, чтобы сообщить операционной системе, о необходимости отображения файла в ОЗУ.

  • Необходимо убедиться, что режим, который использует функция open(), совместим с mmap(). Режим по умолчанию для open() предназначен только для чтения, но режим по умолчанию для mmap() предназначен для чтения и записи. Таким образом, для mmap режим открытии файла нужно указывать явно.

  • Необходимо выполнять все операции чтения и записи с использованием объекта mmap вместо стандартного файлового объекта, возвращаемого функцией open().

from mmap import mmap

def open_io(filename):
    """Чтение файла традиционным способом"""
    with open(filename, mode="r", encoding="utf8") as fp:
        fp.read()

def mmap_io(filename):
    """Чтение файла с использованием модуля `mmap`"""
    with open(filename, mode="r", encoding="utf8") as fp:
        with mmap(fp.fileno(), length=0, access=mmap.ACCESS_READ) as mmap_obj:
            mmap_obj.read()

В этом примере, мы передали параметр lenght=0, что говорит Python о том, что необходимо считать файл целиком, что не подходит нам в условиях статьи.

Воспользуемся уже известным нам методом чанкинга:

import mmap

def process_large_file_buffered(filename, buffer_size=1000000):
    with open(filename, 'rb') as f:
        while True:
            with mmap.mmap(f.fileno(), length=buffer_size, access=mmap.ACCESS_READ) as mm:
                # Обработка данных
                data = mm.read(buffer_size)
                if not data:
                    break
                print(data)
            f.seek(buffer_size, 1)  # Перемещение указателя на следующий буфер

process_large_file_buffered('large_file.dat')

Тоже самое можно выполнить и при помощи генераторов, воспользовавшись ключевым словом yield.

Использование этого способа позволит вам ускорить считывание файлов. Однако "Memory mapping" сильно зависит от реализации операционной системы и технологиям хранения данных. Так, например, улучшение производительности будет еще больше при чтении с обычного диска SATA или SSD и особенно больших файлов, нежели с HDD.

Более подробно про этот метод и библиотеку mmap, вы можете прочитать в официальной документации: mmap — Memory-mapped file support — Python 3.12.5 documentation и переводе одной из статей на хабре: Оптимизируем использование памяти в приложениях Python / Хабр
Вот ещё парочка полезных источников, где вы можете найти подробную информацию:

А мы же перейдем к конкретным библиотекам, которые помогут вам в обработке больших файлов:

  • Конечно же первая библиотека - это Pandas. Одна из самых популярных библиотек для обработки и анализа данных. Поддерживает работу с большими файлами с использованием буферизации и инкрементального чтения. Но с ростом размеров файлов её популярность становится всё меньше.

  • Вторая библиотека - это Dask. Библиотека для параллельных вычислений, которая предоставляет интерфейс, подобный pandas, но предназначенный для работы с действительно большими данными. Эта библиотека отличается своей скоростью работы и возможностью вычисления данных в тот момент, когда они действительно нужны.

Далее будут перечислены библиотеки, которые помогут вам обработать большие файлы, но не на прямую, как вышеперечисленные, а ускорив стандартные способы:

  • ijson - Библиотека для инкрементального чтения больших JSON-файлов, что позволяет работать с ними без загрузки всего файла в память. Эта библиотека обладает расширенным встроенным функционалом, для парсинга JSON-файлов.

  • lxml - Аналог встроенной Python библиотеки xml. Она предназначена для работы с XML и HTML, и предоставляет более мощные и гибкие инструменты по сравнению с ElementTree.

  • PyYAML - Ещё одна прекрасная библиотека, которая позволяет нам довольно быстро и с комфортом обрабатывать большие объемы данных, используя изученные ранее нами способы и её расширенный функционал.

  • joblib - это библиотека для параллельных вычислений и кэширования результатов.  Использует multiprocessing или threading под капотом, но предоставляет более высокоуровневый и удобный интерфейс. Ознакомившись с парадигмой параллельного и многопроцессорного программирования, вы сможете писать высокоэффективные обработчики файлов, которые будут выделяться своей скоростью работы.

  • mmap - Из озвученного выше метода, но всё так же остаётся лишь вспомогательной библиотекой, для увеличения скорость считывания и обработки файлов.

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

Спасибо за внимание, и удачи в ваших будущих исследованиях и проектах!

Читать наши другие посты в Telegram.

Комментарии (3)


  1. Andrey_Solomatin
    12.09.2024 12:18

    Минусы:

    Я бы добавил, что файлы в памяти дают широкие возможности для использования, но скрывают детали реализации, что позволяет писать очень неэффективно работающий код.

    Такие вещи можно отловить только целенпарвленным тестированием. Пока файл влезает в пямять целиком - всё будет работать очень быстро. А потом уже очень сильно зависит от кода.


    1. Chikkl Автор
      12.09.2024 12:18

      Хорошее замечание, добавлю в ведение!


  1. Andrey_Solomatin
    12.09.2024 12:18

    Важно отметить! Размер чанка (chunk_size) может влиять на производительность. Слишком маленький размер может привести к избыточным операциям ввода-вывода, а слишком большой — к неэффективному использованию памяти. Оптимальный размер зависит от конкретного случая и может потребовать экспериментов

    Там внутри тоже есть буферизация. https://docs.python.org/3/library/functions.html#open
    Так что read(1) не проблема, если буфера большие.