JavaRush /Курсы /Swift SELF /Chunked I/O: не всегда надо читать всё в память

Chunked I/O: не всегда надо читать всё в память

Swift SELF
57 уровень , 4 лекция
Открыта

1. Зачем нужен chunked I/O

Когда вы только начинаете работать с файлами, Data(contentsOf:) выглядит как мечта: одна строка — и у вас всё содержимое файла в руках. На маленьких файлах так и надо делать: код короткий, читаемый, и ошибок меньше. Но как только файлы становятся большими, “мечта” превращается в “почему моя программа внезапно съела всю память и ушла в закат?”.

Представьте, что вы читаете видеофайл, архив, большой лог или дамп базы. Если файл 2 ГБ, то “прочитать всё в Data” означает попытку выделить в памяти примерно 2 ГБ (плюс накладные расходы). На обычной машине это легко может закончиться ошибкой, свопом, зависанием или тем самым моментом, когда вентилятор начинает звучать как турбина самолёта.

Для контраста — маленький пример “всё в память” (он корректный, просто не всегда подходящий):

import Foundation

let url = URL(fileURLWithPath: "/tmp/big.bin")
let data = try Data(contentsOf: url)
print(data.count) // например: 2048

Проблема тут не в синтаксисе. Проблема в том, что вы не контролируете потребление памяти. А хочется контролировать.

Идея chunked I/O: читаем порциями и работаем как конвейер

Chunked I/O — это подход, где файл обрабатывается “ломтиками”: прочитали небольшой кусок (chunk), что-то с ним сделали (или просто сразу записали), потом прочитали следующий, и так до конца. Это не “магическая оптимизация”, которая всегда быстрее. Это скорее способ сделать программу предсказуемой по памяти: вы держите в оперативке только один chunk, а не весь файл.

Полезно мыслить chunked I/O как о простом цикле с понятным завершением: пока удаётся прочитать непустой кусок — работаем. Как только кусок пустой — файл закончился. В этом месте у новичков обычно появляется облегчение: “А, то есть конец файла — это просто пустые данные”.

Ниже — схема на уровне идеи (без претензий на «низкоуровневость»):

flowchart TD
    A[Открыли файл на чтение] --> B[Открыли/подготовили файл на запись]
    B --> C{Прочитать chunk}
    C -->|chunk не пустой| D[Обработать chunk]
    D --> E[Записать chunk]
    E --> C
    C -->|chunk пустой| F[Закрыть файлы, завершить]

Главная рамка лекции: мы изучаем каркас, чтобы вы уверенно понимали структуру решения. Это не курс “я пишу свой cp на уровне ядра ОС”.

2. Инструменты Foundation: FileHandle и defer

FileHandle: минимальный инструмент для порционного чтения и записи

Чтобы читать и писать порциями, нам нужен API, который умеет “дай мне N байт”. В Foundation для этого есть FileHandle. Его можно открыть на чтение или на запись, а потом вызывать методы чтения/записи.

Здесь важный момент дисциплиной: FileHandle — это ресурс (по смыслу “открытый файл”), и его нужно закрывать, иначе можно получить утечки ресурсов и странные баги. В Swift для такой дисциплины есть отличный инструмент — defer: ставим defer сразу после успешного открытия, и мы почти гарантировали себе, что “закрытие случится при выходе”. Такой подход часто показывают как базовую практику очистки ресурсов.

В Swift Evolution обсуждают идею “ресурсности” файловых хендлов как значений, которыми нужно правильно управлять по жизненному циклу. Это скорее интересный факт “из мира взрослых”, но он хорошо подкрепляет интуицию: открыл — закрой.

Мини‑пример: открыли файл и прочитали первые 8 байт.

import Foundation

let url = URL(fileURLWithPath: "/tmp/input.bin")
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }

let chunk = try handle.read(upToCount: 8) ?? Data()
print(chunk.count) // например: 8

Обратите внимание на две вещи. Во-первых, read(upToCount:) возвращает Data?, поэтому мы подставляем пустой Data() через ??. Во-вторых, close() мы закрываем через try? — это тот редкий случай, где “проглотить” ошибку обычно допустимо, потому что закрытие — уборка, а не основная логика программы.

Почему defer стоит ставить сразу

Новичку часто хочется написать так: “сначала прочитаю, потом где-то в конце закрою”. Но у такого кода есть неприятная особенность: если где-то случилась ошибка, return, throw, или вы добавили ещё одну ветку if, то закрытие легко забыть. А незакрытые хендлы — это как “оставить кран открытым”: сначала вроде ничего, а потом внезапно вода на полу.

defer решает эту проблему дисциплиной: вы открыли ресурс — и сразу же рядом написали, как его закрывать. Даже если дальше будет throw, закрытие всё равно выполнится при выходе из области видимости. Именно поэтому defer часто показывают как базовую практику “уборки” ресурсов в CLI и серверном коде.

Если вам хочется запомнить это одной фразой: defer — это “напоминалка компилятору: когда уйдём отсюда, выключи свет”.

3. Каркас: копирование порциями

Читаем chunk → пишем chunk

Самый честный учебный пример chunked I/O — копирование файла. Оно понятное: мы не меняем данные, просто перегоняем байты из источника в назначение. Да, в реальной жизни иногда проще вызвать FileManager.copyItem, но наша цель сейчас — увидеть структуру порционного чтения/записи и закрепить навык “не держать всё в памяти”.

Начнём с каркаса функции. Она принимает URL источника и назначения и размер порции. Функция throws, потому что чтение/запись файлов — это нормальный источник ошибок.

import Foundation

func copyChunked(from src: URL, to dst: URL, chunkSize: Int) throws {
    try Data().write(to: dst) // создаём/обнуляем файл назначения

    let input = try FileHandle(forReadingFrom: src)
    defer { try? input.close() }

    let output = try FileHandle(forWritingTo: dst)
    defer { try? output.close() }

    while true {
        let chunk = try input.read(upToCount: chunkSize) ?? Data()
        if chunk.isEmpty { break }
        try output.write(contentsOf: chunk)
    }
}

Здесь есть несколько “почему так”.

FileHandle(forWritingTo:) обычно ожидает, что файл уже существует, поэтому мы заранее создаём пустой файл через try Data().write(to:). Мы не обсуждаем в этой лекции стратегии “не перезаписывать существующее” или “делать backup” — это отдельные большие темы. Сейчас фиксируем простое правило: “назначение создаём/обнуляем”.

Цикл заканчивается, когда chunk.isEmpty. Для новичка это один из самых важных моментов: “конец файла” в таком подходе — это не исключение и не магическая константа, а просто пустое чтение.

Вызов этой функции выглядит так:

import Foundation

let src = URL(fileURLWithPath: "/tmp/input.bin")
let dst = URL(fileURLWithPath: "/tmp/output.bin")

do {
    try copyChunked(from: src, to: dst, chunkSize: 64 * 1024)
    print("Done") // Done
} catch {
    print("Copy failed:", error)
}

Размер 64 * 1024 (64 KB) — частый “нормальный старт”. Это не закон природы, но хорошая отправная точка.

Встраиваем в CLI: минимальная обвязка

Поскольку мы делаем курс, где примеры постепенно складываются в одно приложение, удобно представить chunked‑копирование как маленькую команду “служебных файловых операций”. Даже если ваше основное приложение про библиотеку книг, вам всё равно часто нужны утилиты: скопировать файл базы, сделать резервную копию, перегнать данные в другой каталог.

Не будем усложнять полноценным парсером команд (он уже был раньше в курсе). Сделаем максимально простой “скелет”: берём пути из CommandLine.arguments. Это очень прямолинейно и хорошо подходит для демонстрации идеи.

import Foundation

let args = CommandLine.arguments
if args.count == 3 {
    let src = URL(fileURLWithPath: args[1])
    let dst = URL(fileURLWithPath: args[2])

    do {
        try copyChunked(from: src, to: dst, chunkSize: 64 * 1024)
        print("OK") // OK
    } catch {
        print("ERROR:", error)
    }
} else {
    print("Usage: app <src> <dst>")
}

Да, это не “идеальный UX”, но для учебной цели отлично: вы видите, как chunked‑функция подключается как обычный строительный блок.

4. Размер chunk и обработка “на лету”

Размер chunk’а: почему “слишком маленький” и “слишком большой” — оба плохо

Размер chunk’а — это компромисс. Маленький chunk (например, 64 байта) означает очень много операций чтения/записи, а I/O‑операции сами по себе не бесплатны. Большой chunk (например, 100 МБ) снова возвращает нас к проблеме памяти — мы почти читаем “всё в память”, только кусками по 100 МБ.

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

Небольшая таблица для ориентира:

Chunk size Что это даёт Типичный эффект
4 KB
близко к “страничным” размерам памяти/ФС много I/O вызовов, но маленькая память
64 KB
хороший баланс “часто нормально” обычно достаточно быстро и стабильно
1 MB
меньше системных вызовов быстрее на некоторых дисках, но больше память

Эта таблица не обещает чудес. Она помогает вам не выбирать “7 байт” просто потому, что число красивое.

“Обработать chunk”: пример с простой checksum

Пока мы копировали файл, chunked I/O выглядел как “сложнее, чем надо”. Но самое интересное начинается, когда вы хотите что-то посчитать или поменять, не читая весь файл.

Например, можно посчитать “контрольную сумму‑игрушку”: сумму всех байтов по модулю 256. Это не криптография и не настоящая checksum, но отличный учебный пример агрегации данных на потоке.

Сначала функция, которая обновляет сумму:

import Foundation

func updateChecksum(_ checksum: inout UInt8, with chunk: Data) {
    for b in chunk {
        checksum &+= b
    }
}

Теперь встроим её в цикл чтения:

import Foundation

func checksumChunked(of url: URL, chunkSize: Int) throws -> UInt8 {
    let input = try FileHandle(forReadingFrom: url)
    defer { try? input.close() }

    var checksum: UInt8 = 0

    while true {
        let chunk = try input.read(upToCount: chunkSize) ?? Data()
        if chunk.isEmpty { break }
        updateChecksum(&checksum, with: chunk)
    }

    return checksum
}

И вызов:

import Foundation

let url = URL(fileURLWithPath: "/tmp/input.bin")
let sum = try checksumChunked(of: url, chunkSize: 64 * 1024)
print(sum) // например: 137

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

5. Типичные ошибки

Ошибка №1: забыть, что конец файла — это пустой chunk.
Самая частая логическая поломка — написать while true { read; write } и не сделать условие выхода. Тогда цикл либо становится бесконечным, либо вы пишете в выходной файл бесконечные нулевые данные (если сами их подставляете). Правильный якорь в каркасе: if chunk.isEmpty { break }.

Ошибка №2: открыть FileHandle на запись в несуществующий файл.
FileHandle(forWritingTo:) обычно не создаёт файл “из воздуха”. Если путь назначения не существует, вы получаете ошибку ещё на открытии. В учебном каркасе проще всего перед открытием записи сделать try Data().write(to:) и тем самым создать пустой файл.

Ошибка №3: “проглотить” ошибки чтения/записи через try? в основной логике.
Иногда новичок думает: “если try? проще, буду везде писать try?”. В I/O это почти всегда плохо: вы теряете причину сбоя, а программа продолжает работать в полусломанном состоянии. try? оставляйте только для уборки в defer (например, try? handle.close()), а основные операции чтения/записи делайте через нормальный try и throws.

Ошибка №4: выбрать chunkSize без здравого смысла (например, 1 байт или 500 МБ).
Слишком маленький chunk создаёт огромное количество I/O‑операций и замедляет работу. Слишком большой chunk возвращает проблему памяти. Если вы не профилируете и не оптимизируете под конкретную систему, берите что-то вроде 64 * 1024 и живите спокойно.

Ошибка №5: считать chunked I/O “обязательным” всегда.
Иногда студенты после этой темы начинают читать даже файлы на 2 KB порциями, потому что “так правильно”. На самом деле правильно — по ситуации. Маленькие файлы удобнее читать целиком: код короче, меньше мест для ошибок. Chunked I/O нужен тогда, когда размер файла или сценарий обработки делает чтение целиком рискованным или неуместным.

1
Задача
Swift SELF, 57 уровень, 4 лекция
Недоступна
Подсчёт чанками
Подсчёт чанками
1
Задача
Swift SELF, 57 уровень, 4 лекция
Недоступна
Чанк checksum
Чанк checksum
1
Задача
Swift SELF, 57 уровень, 4 лекция
Недоступна
Чанк копирование
Чанк копирование
1
Задача
Swift SELF, 57 уровень, 4 лекция
Недоступна
Нарезка на части
Нарезка на части
1
Опрос
Работа с Data, 57 уровень, 4 лекция
Недоступен
Работа с Data
Бинарные данные и файловый ввод-вывод
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ