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 обговорюють ідею ресурсного FileHandle як значення, яким потрібно правильно керувати протягом усього життєвого циклу. Це радше цікавий факт зі світу «дорослих» інструментів, але він добре підкріплює інтуїцію: відкрив — закрий.

Мініприклад: відкрили файл і прочитали перші 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:). Ми не розглядаємо в цій лекції стратегії «не перезаписувати наявний файл» або «робити резервну копію» — це окремі великі теми. Зараз фіксуємо просте правило: призначення створюємо або обнуляємо.

Цикл завершується, коли 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("Готово") // Готово
} catch {
    print("Копіювання не вдалося:", error)
}

Розмір 64 * 1024 (64 КБ) — частий нормальний старт. Це не закон природи, але добра відправна точка.

Вбудовуємо в 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("Готово") // Готово
    } catch {
        print("Помилка:", error)
    }
} else {
    print("Використання: app <src> <dst>")
}

Так, це не «ідеальний UX», але для навчальної мети — чудово: ви бачите, як chunked-функція підключається як звичайний будівельний блок.

4. Розмір chunk і обробка «на льоту»

Розмір порції: чому «занадто маленький» і «занадто великий» — погано в обох випадках

Розмір chunk — це компроміс. Маленький chunk, наприклад 64 байти, означає дуже багато операцій читання й запису, а I/O-операції самі по собі не безкоштовні. Великий chunk, наприклад 100 МБ, знову повертає нас до проблеми памʼяті — ми майже читаємо «все в памʼять», тільки шматками по 100 МБ.

Важливо, що зараз ми не женемося за абсолютним рекордом швидкості. Ми вчимося робити код передбачуваним і безпечним. Тому для новачка розумна стратегія — обрати помірний chunk і не чіпати його, доки не зʼявиться реальна причина.

Невелика таблиця для орієнтиру:

Розмір chunk Що це дає Типовий ефект
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

Сенс цього прикладу в тому, що в памʼяті ніколи не лежить увесь файл. Ви можете проганяти так хоч десятки гігабайтів (у розумних межах системи), і програма поводитиметься передбачувано щодо оперативної памʼяті.

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 КБ порціями, бо «так правильно». Насправді правильно — залежно від ситуації. Маленькі файли зручніше читати цілком: код коротший, менше місць для помилок. Chunked I/O потрібен тоді, коли розмір файла або сценарій обробки робить читання цілком ризикованим чи недоречним.

1
Опитування
Робота з Data, рівень 57, лекція 4
Недоступний
Робота з Data
Бінарні дані та файлове введення-виведення
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ