Skip to content

Files and IO

Files and I/O (Введення-Виведення)

Що таке файловий об'єкт

Файловий об'єкт - це об'єкт, який надає файлово-орієнтований API (методи read(), write() та ін.) для доступу до ресурсу. Залежно від способу створення, файловий об'єкт може надавати доступ до реального файлу на диску або іншого типу пристрою зберігання або передачі даних (стандартні потоки введення/виведення, буфери у пам'яті, сокети і т.д.). Файлові об'єкти також називають потоками. Файлові об'єкти є контекстними менеджерами.

Які існують типи файлових об'єктів

У Python існують три типи файлових об'єктів

  • Текстові файли (text files): Ці файли використовуються для роботи з текстовими даними. Вони працюють з рядками (строками) і автоматично виконують кодування і декодування символів, а також перетворення символів кінця рядка (\n, \r, \r\n) між стандартними відображенням в пам'яті та збереженням на диску. Для роботи з текстовими файлами використовуються класи TextIOWrapper і TextIO з модуля io.
  • Буферизовані бінарні файли (buffered binary files): Ці файли використовуються для роботи з бінарними даними. Вони працюють з байтами і зберігають дані без будь-яких змін або перетворень. Буферизовані бінарні файли мають більш ефективну систему буферизації для швидкого зчитування та запису даних. Для роботи з буферизованими бінарними файлами використовується клас BufferedReader з модуля io.
  • Небуферизовані бінарні файли (raw binary files): Ці файли також використовуються для роботи з бінарними даними. Вони працюють з байтами і не мають системи буферизації. Це означає, що дані безпосередньо записуються або зчитуються з диску при кожній операції. Для роботи з небуферизованими бінарними файлами використовується клас BufferedWriter з модуля io.

В чому відмінність текстових і бінарних файлів

Текстові файли записують і зчитують дані типу str і автоматично виконують перетворення кодувань і символів кінця рядка. Бінарні файли записують і зчитують дані типів bytes і bytearray і не виконують жодних маніпуляцій з даними: все записується і зчитується в тому ж самому вигляді, як зберігається.

Як користуватися функцією open

Сигнатура функції

open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None)

Основні параметри: - file - ім'я файлу або файловий дескриптор - mode - режим відкриття файлу - encoding - кодування файлу - buffering - використовувати буферизацію: від'ємне число (за замовчуванням, явно вказувати не потрібно) - стандартне значення для даного типу файлового об'єкту, 0 - вимкнути буферизацію, 1 - построчна буферизація (для текстових файлів), інше значення - увімкнути буферизацію і встановити відповідний розмір буфера

Обов'язковим параметром є лише перший. Найчастіше функцію open() використовують з двома параметрами.

mode може починатися з символів "r" (читання), "w" (запис, очищує файл, якщо він вже існує), "x" (виключне створення, неуспішно, якщо файл вже існує), "a" (додавання, запис в кінець файлу). Також параметр mode може мати другу літеру для визначення типу файлу: "t" для текстового (за замовчуванням) і "b" для бінарного. Також можна додати символ "+" для відкриття в режимі читання і запису одночасно. Порядок останніх двох символів не має значення: "rb+" і "r+b" задають один і той же режим.

З чого складається процес закриття файлів

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

Крім того, якщо файл відкритий для запису, він буде заблокований для інших процесів на запис до моменту його закриття. Усі відкриті файли автоматично закриваються при видаленні відповідних файлових об'єктів з пам'яті збирачем сміття інтерпретатора Python або при завершенні роботи самого інтерпретатора, проте слід тримати файли відкритими мінімально необхідний час.

Що роблять методи tell і seek

Метод tell() повертає поточну позицію зчитування/запису в файлі. Метод seek(offset, whence) встановлює її. Параметр offset визначає зсув, а whence - точку, від якої цей зсув обчислюється: io.SEEK_SET(0) - початок файлу, io.SEEK_CUR(1) - поточна позиція, io.SEEK_END(2) - кінець файлу.

Що роблять StringIO і BytesIO

Класи io.StringIO і io.BytesIO представляють потоки для зчитування та запису в рядки або байтові рядки у пам'яті. Вони можуть використовуватися для використання рядків і байтових рядків як текстових і бінарних файлів. Надають інтерфейс файлу без взаємодії з файловою системою.

Чи є файлові об'єкти контекстними менеджерами

Так, вони є.

В чому різниця між буферизованим і небуферизованим введенням-виведенням

Summary

Небуферизований I/O робить системний виклик на кожен байт і впирається в ліміт IOPS диска; буферизований накопичує дані в пам'яті й скидає великими шматками, отримуючи нормальну пропускну здатність.

При небуферизованому виводі кожен виклик write() транслюється в окремий системний виклик до ядра. Якщо записувати 10 GB-файл побайтово, отримуємо ~10 мільярдів системних викликів (на пристрій вони доходять у вигляді блочних I/O після коалесценсії в page cache і IO scheduler, але вузьким місцем стає сам шлях syscall + блочний рівень).

Це б'є по двох речах:

  • IOPS (Input/Output Operations Per Second) - кількість дискових операцій за секунду. Виробники SSD/HDD вказують максимальний IOPS у специфікації поряд з лінійною швидкістю читання/запису. Дрібні побайтові записи навіть після амортизації в кеші ОС лишаються неоптимальним патерном і впираються в стелю syscall-оверхеду задовго до того, як насититься пристрій.
  • Знос і затримка - на HDD головка фізично рухається (якщо ОС перемикає контекст і дає іншому процесу попрацювати з диском, головка йде в інше місце і потім має повертатись - додатковий seek time). На SSD механічного seek немає, але дрібні записи провокують зайвий write amplification і вкорочують ресурс комірок.

При буферизованому виводі байти спершу складаються в буфер у пам'яті користувача (на рівні Python). Системний виклик відбувається тільки коли буфер заповнений або викликано flush()/close(). Замість 10 мільярдів викликів маємо умовно кілька десятків тисяч, які доходять до пристрою великими блоками - пропускна здатність близька до лінійної швидкості диска.

# Buffered (default): one syscall per buffer block (~128 KB on modern systems)
with open("big.bin", "wb") as f:
    for chunk in data:
        f.write(chunk)  # accumulates in buffer

# Unbuffered: syscall on every write
with open("big.bin", "wb", buffering=0) as f:
    for byte in data:
        f.write(byte)  # raw I/O, hits disk every call

Керування буферизацією через параметр buffering у open():

  • buffering=-1 (default) - для бінарних файлів CPython використовує max(min(blocksize, 8 MiB), io.DEFAULT_BUFFER_SIZE), де blocksize визначається os.stat().st_blksize; на сучасних системах це зазвичай ~128 KB, а io.DEFAULT_BUFFER_SIZE (8 KB) - лише нижня межа. Текстові файли надбудовують ще один буфер поверх бінарного.
  • buffering=0 - вимкнути буферизацію (тільки для бінарного режиму).
  • buffering=1 - построкова буферизація (тільки для текстового режиму), скидає буфер на кожному \n.
  • buffering=N (N > 1) - буфер заданого розміру в байтах.
open("log.txt", "w", buffering=1)        # line-buffered text
open("data.bin", "wb", buffering=65536)  # 64 KB buffer

Поза open():

  • print() буферизований через sys.stdout; коли stdout - термінал, буферизація построкова, коли pipe - блокова. Параметр flush=True форсує скид: print(msg, flush=True).
  • Прапорець інтерпретатора python -u (або змінна PYTHONUNBUFFERED=1) робить stdout/stderr небуферизованими - корисно в Docker, де логи інакше не з'являються в реальному часі.
  • file.flush() примусово скидає буфер Python в ОС; os.fsync(file.fileno()) додатково просить ОС скинути свій кеш на фізичний диск.

Окрім буфера на рівні Python, є ще кеші ОС, контролера диска і самого пристрою - всі вони існують саме щоб амортизувати дрібні операції в більші. Буферизація Python - перший і найдешевший рівень цієї оптимізації.

В чому різниця між блокуючим і неблокуючим введенням-виведенням

Summary

Це характеристика самого I/O-syscall: блокуючий присипляє потік до готовності даних, неблокуючий повертає керування одразу - або з даними, або з помилкою EAGAIN/EWOULDBLOCK. Це окрема вісь від синхронний/асинхронний.

Блокуючий режим (default для сокетів, файлів, pipe). Виклик read/recv зупиняє поточний потік виконання у ядрі і не повертає керування, поки дані не з'являться у буфері. Програма "стоїть" - наступні інструкції не виконуються, CPU потік паркується планувальником ОС.

Неблокуючий режим. Той самий syscall повертає керування негайно. Якщо дані є - повертає їх (або скільки наразі доступно). Якщо даних немає - повертає помилку EAGAIN / EWOULDBLOCK (у Python - виняток BlockingIOError). Далі програма сама вирішує що робити: зайнятися іншою роботою, опитати дескриптор пізніше, або віддати його у select/poll/epoll/kqueue.

Режим виставляється на конкретний файловий дескриптор - через прапор O_NONBLOCK у fcntl, або через socket.setblocking(False) у Python.

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("example.com", 80))

# Blocking (default): hangs here until bytes arrive
data = s.recv(4096)

s.setblocking(False)
try:
    data = s.recv(4096)        # returns immediately
except BlockingIOError:
    pass                       # no data yet, do something else

Важливо не плутати з sync/async. Це різні осі:

  • Блокуючий vs неблокуючий - властивість syscall: чи паркує він потік.
  • Синхронний vs асинхронний - властивість моделі взаємодії: чи чекаєш ти результат у точці виклику, чи отримаєш повідомлення про готовність пізніше.

Можна мати блокуючий синхронний код (звичайний socket.recv()), неблокуючий синхронний код (event loop з epoll + setblocking(False), що лежить в основі asyncio, Twisted, gevent), і так далі. Сам по собі O_NONBLOCK не робить програму асинхронною - він лише дає механізм, поверх якого будуються event loop'и.

Робота з файловою системою завжди блокуюча

На Linux, macOS і Windows регулярні файли не підтримують O_NONBLOCK так, як сокети: read/write файлу завжди блокують потік до завершення дискової операції. epoll для звичайного файлу повідомить "готовий до читання" одразу - але сам read все одно піде в ядро і чекатиме на диск. Це означає, що asyncio не може зробити файлову операцію справді неблокуючою на рівні syscall'у.

Бібліотеки на кшталт aiofiles обходять це не через "async syscall", а через ThreadPoolExecutor: блокуючий read/write виконується в допоміжному потоці, а сам event loop отримує await-сумісний Future, що завершується, коли потік повернеться. Та сама механіка лежить в основі asyncio.to_thread для будь-якої блокуючої функції.

# aiofiles під капотом приблизно еквівалентне
async def read_file(path):
    return await asyncio.to_thread(_blocking_read, path)

Виграш порівняно зі синхронним кодом - не "syscall стає async", а "блокуючий syscall не зупиняє event loop, бо виконується в окремому потоці". Втрата - той самий поточний пул потоків стає вузьким місцем при високій кількості одночасних файлових операцій. На Linux ситуацію змінює io_uring (Linux 5.1+), що пропонує справді асинхронні файлові syscall'и; у Python поки що використовується через сторонні бібліотеки (aio-uring тощо) і не є частиною stdlib.

Що таке серіалізація

Серіалізація - це процес збереження об'єктів у бінарному або рядковому вигляді для зберігання, передачі та відновлення. Зворотний процес називається десеріалізацією.

json.dumps / json.dump, json.loads / json.load

Функція dumps модуля json зберігає представлення об'єкта у форматі JSON у рядок. Функція dump - у текстовий файл. Функція loads модуля json завантажує об'єкт з рядка. Функція load - з текстового файлу.

Що робити, якщо потрібно серіалізувати дані, які не підтримуються стандартним модулем json

Можна використовувати pickle або розширити класи JSONEncoder і JSONDecoder.

pickle.dumps / pickle.dump, pickle.loads / pickle.load

Функції dump, dumps, load і loads модуля pickle аналогічні за своїм призначенням відповідним функціям модуля json, але працюють з байтовими рядками та бінарними файлами.

Перелік файлів у директорії

Summary

Stdlib пропонує чотири основні інструменти: os.listdir (плоский список імен), os.scandir (метадані без додаткових stat-викликів), pathlib.Path.iterdir / glob / rglob (об'єктний API з шаблонами), os.walk (рекурсивний обхід дерева). Вибір залежить від того, чи потрібні шаблони, рекурсія, метадані файлів.

os.listdir(path) - плоский список імен

import os

for name in os.listdir("/var/log"):
    print(name)  # 'syslog', 'auth.log', ... - just names, no path

Найдешевший варіант: один getdents-syscall. Повертає лише імена, без stat. Не розрізняє файли і директорії - для типу треба окремий виклик os.path.isfile(), який робить stat. Не сортує.

os.scandir(path) - імена + метадані за один прохід

import os

with os.scandir("/var/log") as it:
    for entry in it:
        if entry.is_file():           # No extra stat - taken from getdents
            print(entry.name, entry.stat().st_size)

scandir дешевше за listdir + ручне os.path.isfile/os.path.isdir, бо тип файлу зчитується з d_type в getdents без окремих stat-викликів. Це рекомендований варіант для великих директорій. Повертає ітератор - сам звільняє ресурси через context manager.

pathlib.Path.iterdir() - об'єктний API

from pathlib import Path

for p in Path("/var/log").iterdir():
    if p.is_file():
        print(p.name, p.stat().st_size)

Той самий рівень абстракції, що і os.scandir, але повертає Path-об'єкти з зручними методами (.suffix, .stem, .with_suffix(), .read_text()). Внутрішньо реалізовано через scandir.

Glob-шаблони: .glob() і .rglob()

from pathlib import Path

base = Path("./project")
for p in base.glob("*.py"):           # Top-level only
    print(p)
for p in base.rglob("*.py"):          # Recursive, all levels
    print(p)
for p in base.glob("src/**/*.py"):    # ** = arbitrary depth
    print(p)

glob підтримує *, ?, [...], ** (рекурсія). Не повертає приховані файли (з імен на .) - за замовчуванням.

os.walk(top) - рекурсивний обхід дерева

import os

for dirpath, dirnames, filenames in os.walk("./project"):
    for filename in filenames:
        full_path = os.path.join(dirpath, filename)
        print(full_path)
    # Modify dirnames in-place to skip a subtree:
    dirnames[:] = [d for d in dirnames if d not in {".git", "__pycache__"}]

Виконує DFS-обхід дерева. На кожному рівні - (dirpath, dirnames, filenames). Модифікація dirnames на місці впливає на подальший обхід - канонічний спосіб пропускати піддерева (наприклад, .git, node_modules). Із Python 3.12 є os.walk(top, ...)-аналог pathlib.Path.walk().

Що вибирати

Сценарій Інструмент
Просто список імен у директорії os.listdir
Список з типом файлу або розміром os.scandir або Path.iterdir
Шаблон (*.py, *.log) Path.glob
Рекурсивний пошук за шаблоном Path.rglob або Path.glob("**/...")
Повний обхід дерева з контролем os.walk (можна пропускати піддерева)

Чого не робити

  • Не використовувати os.listdir + os.path.join + os.path.isfile у циклі для великих директорій - кожен isfile робить окремий stat-syscall. os.scandir робить це за один прохід.
  • Не покладатися на порядок: жоден з цих API не сортує. Якщо порядок потрібен - sorted(...) явно.

Links

Як обробити кілька файлів одночасно

Summary

Для роботи з невідомою кількістю файлових об'єктів одночасно - contextlib.ExitStack: реєструє довільну кількість context manager'ів і коректно закриває всі при виході, навіть при винятку. Для паралельного оброблення - concurrent.futures із ThreadPoolExecutor (I/O-bound) або ProcessPoolExecutor (CPU-bound).

ExitStack - багато файлів в одному with

Вкладені with-блоки погано масштабуються, якщо кількість файлів невідома заздалегідь:

# Doesn't scale: number of files known only at runtime
with open(paths[0]) as f1:
    with open(paths[1]) as f2:
        with open(paths[2]) as f3:
            ...

ExitStack розв'язує це динамічно:

from contextlib import ExitStack

def merge_files(paths, output):
    with ExitStack() as stack:
        files = [stack.enter_context(open(p)) for p in paths]
        with open(output, "w") as out:
            for f in files:
                out.write(f.read())
    # All files closed here - even if open() raised mid-loop

enter_context() додає об'єкт у стек; при виході з with ExitStack() всі зайдені менеджери закриваються у зворотному порядку. Якщо open() десь падає, уже відкриті файли коректно закриваються.

Послідовна обробка

Якщо обробка кожного файлу незалежна і дешева - простий цикл:

from pathlib import Path

for path in Path("./logs").glob("*.log"):
    with open(path) as f:
        process(f.read())

Паралельна обробка: I/O-bound

Парсинг JSON, читання з мережевого диска, виклик API на кожен файл - I/O-bound. Тут ThreadPoolExecutor дає виграш попри GIL: блокуючі syscall'и виконуються паралельно, оскільки під час I/O GIL звільняється.

from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

def process_file(path):
    with open(path) as f:
        return parse(f.read())

paths = list(Path("./data").glob("*.json"))
with ThreadPoolExecutor(max_workers=16) as pool:
    results = list(pool.map(process_file, paths))

Паралельна обробка: CPU-bound

Якщо обробка - тяжка (хеш великого файлу, парсинг XML, image processing) - ProcessPoolExecutor обходить GIL через окремі процеси.

from concurrent.futures import ProcessPoolExecutor

with ProcessPoolExecutor() as pool:
    results = list(pool.map(hash_file, paths))

Деталі GIL і вибору пула - у gil_threads_processes.md.

Async для багатьох файлів

asyncio не дає реального паралелізму на локальному дисковому I/O - syscall read() блокує event loop. Якщо потрібна асинхронна обробка з I/O в мережу (вивантажити файли в S3, відправити в API) - aiofiles для читання + native async-клієнти для I/O. Для чисто локального диска перевага мінімальна.

Чого не робити

  • Не відкривати багато файлів без with/ExitStack. Якщо посередині падає виняток, файли не закриваються до GC. На Windows це блокує перейменування / видалення.
  • Не запускати ProcessPoolExecutor для дрібних задач - serialization-оверхед з'їдає виграш від паралелізму.
  • Не плутати ThreadPoolExecutor з паралельним CPU: за GIL Python-код виконується по черзі; виграш є лише на I/O-bound коді.

Links