Работа с файлами в асинхронном Python: от основ до продвинутых приёмов

Раздел: Продвинутый Python -> Async

При разработке асинхронных приложений на Python важно не блокировать цикл событий (event loop) операциями ввода-вывода. Синхронные файловые операции, такие как open() или read(), могут остановить выполнение всех корутин на время обработки файла. Для решения этой проблемы применяются библиотеки, реализующие асинхронный доступ к файлам, самая популярная из которых - aiofiles. Она предоставляет асинхронные версии стандартных функций для работы с файлами, используя потоки под капотом, но при этом управляемые через asyncio. В статье рассматриваются различные подходы к асинхронной работе с файлами, начиная с базового и заканчивая продвинутыми приёмами, а также типичные ошибки и способы их устранения.

Основное решение: использование aiofiles для асинхронного доступа

Самый надёжный и эффективный способ организовать асинхронное чтение и запись файлов - применять библиотеку aiofiles. После установки (pip install aiofiles) она позволяет работать с файлами в стиле async with и await, что полностью интегрируется в асинхронный код.

import asyncio
import aiofiles

async def read_file_async(path: str) -> str:
    async with aiofiles.open(path, mode='r', encoding='utf-8') as f:
        content = await f.read()
    return content

async def write_file_async(path: str, data: str) -> None:
    async with aiofiles.open(path, mode='w', encoding='utf-8') as f:
        await f.write(data)

async def main():
    await write_file_async('example.txt', 'Привет, асинхронный мир!')
    text = await read_file_async('example.txt')
    print(text)

asyncio.run(main())

Python asyncio (asyncio в python)

Привет, асинхронный мир!

Async file python (асинхронная работа с файлами)

В этом примере функция read_file_async открывает файл в асинхронном контекстном менеджере, читает всё содержимое одной командой и возвращает строку. Функция write_file_async записывает данные. Обе функции не блокируют event loop, что особенно важно, когда одновременно выполняется много корутин.

Как читать большой файл построчно без загрузки в память?

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

async def read_lines(path: str) -> None:
    async with aiofiles.open(path, mode='r', encoding='utf-8') as f:
        async for line in f:
            # Обработка строки
            process_line(line.strip())

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

Возможная проблема:

Если в файле встречаются очень длинные строки (например, неразрывные последовательности байт), асинхронное чтение может всё равно занять много времени, так как буферизация внутри aiofiles работает не мгновенно. В таких случаях рекомендуется читать фиксированными блоками (chunks) и обрабатывать их.

Как параллельно прочитать несколько файлов и объединить результаты?

Типичная задача - обработать набор файлов одновременно, сократив общее время по сравнению с последовательным чтением. Для этого используется asyncio.gather.

async def read_multiple(paths: list[str]) -> list[str]:
    async def read_one(p: str) -> str:
        async with aiofiles.open(p, encoding='utf-8') as f:
            return await f.read()
    return await asyncio.gather(*[read_one(p) for p in paths])

# Пример вызова
results = asyncio.run(read_multiple(['file1.txt', 'file2.txt', 'file3.txt']))

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

Ошибка:

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

Как ограничить количество одновременно открытых файлов, чтобы не превысить лимит ОС?

Используется asyncio.Semaphore. Семафор разрешает только определённое количество корутин одновременно входить в критическую секцию.

semaphore = asyncio.Semaphore(10)  # не более 10 одновременно

async def safe_read(path: str) -> str:
    async with semaphore:
        async with aiofiles.open(path, encoding='utf-8') as f:
            return await f.read()

async def read_all_with_limit(paths: list[str]) -> list[str]:
    tasks = [safe_read(p) for p in paths]
    return await asyncio.gather(*tasks)

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

Как асинхронно читать и записывать бинарные файлы (изображения, ZIP)?

Режимы 'rb' и 'wb' работают так же, как для текстовых файлов, но без указания кодировки. Чтение обычно ведётся порциями (чанками) для больших файлов.

async def copy_binary(src: str, dst: str, chunk_size: int = 65536) -> None:
    async with aiofiles.open(src, 'rb') as src_f:
        async with aiofiles.open(dst, 'wb') as dst_f:
            while chunk := await src_f.read(chunk_size):
                await dst_f.write(chunk)

Этот код асинхронно копирует файл, читая и записывая блоками по 64 КБ. Он не блокирует event loop и подходит для файлов любого размера.

Типичная ошибка:

При работе с бинарными данными нельзя использовать текстовые методы (например, readline), так как они ожидают строки. Следует всегда проверять режим открытия файла.

Как обрабатывать ошибки при открытии или чтении файла в асинхронном коде?

Стандартный механизм исключений Python работает и в асинхронном контексте. Нужно оборачивать операции в try/except.

async def safe_read_file(path: str) -> str | None:
    try:
        async with aiofiles.open(path, encoding='utf-8') as f:
            return await f.read()
    except FileNotFoundError:
        print(f'Файл {path} не найден')
    except PermissionError:
        print(f'Нет прав на чтение {path}')
    except Exception as exc:
        print(f'Неизвестная ошибка: {exc}')
    return None

Как выполнить синхронную файловую операцию внутри асинхронного кода, если aiofiles недоступна?

Если по какой-то причине нельзя использовать aiofiles, можно запустить синхронную операцию в отдельном потоке с помощью asyncio.to_thread (доступно с Python 3.9).

import asyncio
import aiofiles

async def read_with_to_thread(path: str) -> str:
    loop = asyncio.get_running_loop()
    # Функция open и read выполнятся в отдельном потоке
    result = await loop.run_in_executor(None, lambda: open(path, encoding='utf-8').read())
    return result

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

Проблема:

При большом количестве файлов использование run_in_executor может привести к созданию множества потоков, что снижает эффективность. Лучше применять aiofiles, которая использует пул потоков ограниченного размера.

Как обеспечить атомарность записи, когда несколько корутин могут писать в один файл?

Для синхронизации доступа к общему файлу используется asyncio.Lock. Только одна корутина может одновременно находиться в защищённом блоке.

lock = asyncio.Lock()

async def safe_write(path: str, data: str) -> None:
    async with lock:
        async with aiofiles.open(path, 'a', encoding='utf-8') as f:
            await f.write(data + '\n')

Это гарантирует, что строки от разных корутин не перемешаются внутри файла.

Типичные ошибки и способы их решения

  • Ошибка: Забыли await перед асинхронной операцией (например, f.read() без await). Результат - получение корутины вместо строки. Решение: всегда проверять, что вызовы функций aiofiles предваряются await.
  • Ошибка: Использование синхронного open() внутри асинхронной функции. Это блокирует event loop на всё время операции. Решение: заменять на aiofiles.open() или использовать asyncio.to_thread.
  • Ошибка: Попытка использовать async for без асинхронного контекстного менеджера. Итерация по обычному файлу не будет асинхронной. Решение: открывать файл через aiofiles.open.
  • Ошибка: Открытие текстового файла без указания кодировки, что на некоторых платформах приводит к использованию системной кодировки (например, cp1251) и последующей ошибке UnicodeDecodeError. Решение: всегда передавать параметр encoding='utf-8' или нужную кодировку.
  • Ошибка: Чрезмерное количество одновременно открытых файлов (переполнение файловых дескрипторов). Решение: использовать Semaphore для ограничения параллелизма.
  • Ошибка: Использование aiofiles с бинарными файлами в текстовом режиме - потеря данных или ошибки декодирования. Решение: применять соответствующий режим ('rb' / 'wb') и не указывать кодировку.

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

Пример 1: Сравнение производительности синхронного и асинхронного чтения нескольких файлов

Создадим 100 текстовых файлов размером 1 МБ каждый, затем измерим время последовательного и асинхронного чтения.

Пример
import asyncio
import aiofiles
import time
import os

# Подготовка данных
os.makedirs('test_files', exist_ok=True)
for i in range(100):
    with open(f'test_files/file_{i}.txt', 'w') as f:
        f.write('A' * 1024 * 1024)  # 1 МБ

async def read_async(path):
    async with aiofiles.open(path, 'r') as f:
        return await f.read()

async def main_async():
    paths = [f'test_files/file_{i}.txt' for i in range(100)]
    start = time.monotonic()
    results = await asyncio.gather(*[read_async(p) for p in paths])
    elapsed = time.monotonic() - start
    print(f'Асинхронное чтение: {elapsed:.3f} сек')

def read_sync(path):
    with open(path, 'r') as f:
        return f.read()

def main_sync():
    paths = [f'test_files/file_{i}.txt' for i in range(100)]
    start = time.monotonic()
    for p in paths:
        read_sync(p)
    elapsed = time.monotonic() - start
    print(f'Синхронное чтение: {elapsed:.3f} сек')

asyncio.run(main_async())
main_sync()
Асинхронное чтение: 0.812 сек
Синхронное чтение: 2.456 сек

Асинхронный подход выполняется в несколько раз быстрее благодаря конкурентному ожиданию ввода-вывода. Реальная разница зависит от диска и системы.

Пример 2: Асинхронное копирование с прогрессом и лимитом на количество одновременных операций

Копируем набор файлов из одной директории в другую, ограничивая параллелизм до 5 и выводя прогресс.

Пример
import asyncio
import aiofiles
import os

sem = asyncio.Semaphore(5)

async def copy_file(src, dst):
    async with sem:
        async with aiofiles.open(src, 'rb') as f_src:
            async with aiofiles.open(dst, 'wb') as f_dst:
                while chunk := await f_src.read(65536):
                    await f_dst.write(chunk)

async def copy_directory(src_dir, dst_dir):
    os.makedirs(dst_dir, exist_ok=True)
    files = [f for f in os.listdir(src_dir) if os.path.isfile(os.path.join(src_dir, f))]
    tasks = []
    for f in files:
        src = os.path.join(src_dir, f)
        dst = os.path.join(dst_dir, f)
        tasks.append(copy_file(src, dst))
    # Прогресс через as_completed
    for coro in asyncio.as_completed(tasks):
        await coro
        print('.', end='', flush=True)
    print()

# Если есть test_files, копируем в test_files_copy
if os.path.isdir('test_files'):
    asyncio.run(copy_directory('test_files', 'test_files_copy'))
....................................................................................................

Код демонстрирует использование семафора, чтение блоками и асинхронную индикацию прогресса.

Пример 3: Обработка большого лог-файла с фильтрацией строк по дате

Предположим, есть лог-файл размером 100 МБ, строки начинаются с даты. Нужно извлечь строки за определённую дату и сохранить в новый файл.

Пример
import aiofiles
import asyncio

async def filter_log(input_path, output_path, target_date):
    async with aiofiles.open(input_path, 'r', encoding='utf-8') as f_in:
        async with aiofiles.open(output_path, 'w', encoding='utf-8') as f_out:
            async for line in f_in:
                if line.startswith(target_date):
                    await f_out.write(line)

# Пример: фильтр строк, начинающихся с '2025-01-15'
asyncio.run(filter_log('big.log', 'filtered.log', '2025-01-15'))

Такой подход обрабатывает файл построчно без полной загрузки в память. Асинхронность позволяет одновременно с фильтрацией выполнять другие корутины.

Пример 4: Асинхронная запись в несколько файлов с использованием Lock для общей части имени

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

Пример
import asyncio
import aiofiles

counter = 0
counter_lock = asyncio.Lock()

async def write_data(data: str, output_dir: str):
    global counter
    async with counter_lock:
        counter += 1
        filename = f'{output_dir}/output_{counter:04d}.txt'
    async with aiofiles.open(filename, 'w', encoding='utf-8') as f:
        await f.write(data)

async def main():
    import os
    os.makedirs('outputs', exist_ok=True)
    tasks = []
    for i in range(10):
        tasks.append(write_data(f'Data chunk {i}', 'outputs'))
    await asyncio.gather(*tasks)

asyncio.run(main())
После выполнения в папке outputs появятся файлы output_0001.txt ... output_0010.txt с уникальными данными.

Блокировка гарантирует, что два файла не получат одинаковый номер, что важно при параллельном исполнении.

Пример 5: Компрессия файла с использованием asyncio и gzip в отдельном потоке

Поскольку aiofiles не поддерживает напрямую сжатие, можно обернуть синхронную операцию gzip в run_in_executor.

Пример
import asyncio
import aiofiles
import gzip
import io

async def compress_file(src: str, dst: str):
    async with aiofiles.open(src, 'rb') as f_src:
        raw_data = await f_src.read()
    loop = asyncio.get_running_loop()
    # Сжатие выполняется в потоке, не блокируя event loop
    compressed = await loop.run_in_executor(None, gzip.compress, raw_data)
    async with aiofiles.open(dst, 'wb') as f_dst:
        await f_dst.write(compressed)

asyncio.run(compress_file('large.bin', 'large.bin.gz'))

Результат - сжатый файл в формате gzip, полученный без блокировки основного потока.

Асинхронная работа с файлами - comments

En
Async file python (python)