Работа с файлами в асинхронном Python: от основ до продвинутых приёмов
При разработке асинхронных приложений на 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, полученный без блокировки основного потока.