сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · ETL и оркестрация

Генераторы и память в пайплайнах

Генераторы: поток вместо памяти

DE на Python постоянно упирается в память: файл больше RAM, выборка на миллионы строк. Собес проверяет, умеешь ли ты обрабатывать поток лениво, за O(1) памяти, а не грузить всё в список.

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

// Формулировки: «как обработать файл больше памяти?», «чем генератор лучше списка здесь?», «что съедает память в пайплайне?».

Конвейер генераторов

Потоковая обработка вместо загрузки в память: открытый файл - уже итератор по строкам, и цепочка генераторов read → parse → filter → transform → write прокачивает данные с O(1) памяти независимо от размера входа.

Каждый шаг - отдельный генератор: конвейер компонуется, тестируется по шагам, память не растёт, потому что в каждый момент в обработке одна запись.

// Чанкование - компромисс между потоком и векторизацией: read_csv(chunksize=) или fetchmany(n) из БД обрабатывают куски фиксированного размера, когда построчно слишком медленно, а всё сразу не влезает.

def pipeline(path):
    with open(path) as f:      # ленивый
        rows = (parse(x) for x in f)
        good = (r for r in rows if ok(r))
        for r in good:
            yield transform(r)   # O(1)
конвейер генераторов
цепочка ленивых шагов с O(1) памятью
chunksize
обработка файла/выборки кусками фиксированного размера

Что материализует и держит память

Пик памяти обычно создаёт неожиданная материализация: list(...), sorted(), join. Сортировка требует всех данных сразу - для больших объёмов сортируют в БД или внешней сортировкой; groupby в потоке возможен только по уже отсортированному ключу.

Профилируй, а не гадай: tracemalloc или memory_profiler покажут реальный пик по строкам кода. Часто виновник не там, где кажется.

// Ссылки держат память: большой объект, застрявший в замыкании, кэше или глобале, не освободится. del и выход из области освобождают его сразу (CPython - подсчёт ссылок), а gc чинит только циклические ссылки.

материализация
разворачивание ленивого потока в структуру в памяти
внешняя сортировка
сортировка кусками с merge, когда данные больше памяти

Ловушки памяти

Самые частые сливы памяти простые. f.read() или f.readlines() на гигабайтном файле - OOM (out of memory) там, где хватило бы итерации по строкам. list(generator) «на всякий случай» - вся ленивость выброшена одной строкой.

Копить результаты в список ради записи в конце - тоже антипаттерн: пиши потоково по мере обработки, тогда память не зависит от объёма выхода.

// И тихий убийца - кэш без ограничения размера: обычный dict как кэш растёт, пока не съест память. Кэшу нужен предел (LRU с maxsize), иначе это медленная утечка до OOM.

tracemalloc
встроенный трекинг аллокаций памяти по строкам кода
потоковая запись
писать по мере обработки, не копить результат
LRU
least recently used

Как отвечать: «Как обработать файл, который больше памяти?»

Не загружаю его целиком, а строю ленивый конвейер. Открытый файл это уже итератор по строкам, поэтому читаю его построчно и прогоняю через цепочку генераторов: разобрать строку, отфильтровать, преобразовать, записать. В каждый момент в памяти одна запись, а не весь файл, так что потребление памяти O(1) независимо от размера входа. Результат тоже пишу потоково, по мере обработки, а не коплю в список, чтобы выход не съел то, что сэкономил вход. Если построчно слишком медленно, беру компромисс - чанки фиксированного размера через chunksize. И внимательно слежу за операциями, которые ломают ленивость: sorted, join, list - они требуют всё сразу, поэтому сортировку и группировку больших данных отдаю в БД или делаю внешней сортировкой. Если что-то всё равно течёт, не гадаю, а профилирую tracemalloc.

Почему это сильный ответ: назван приём (ленивый конвейер генераторов, O(1)), потоковая запись, чанки как компромисс, и предупреждение про материализующие операции - практика, а не «читай по частям».

На чём валят

  • f.readlines()/read() на гигабайтном файле - OOM там, где хватило бы итерации.
  • list(generator) «на всякий случай» - вся ленивость выброшена одной строкой.
  • Копить результаты в список ради записи в конце - пиши потоково по мере обработки.
  • sorted() над потоком, который не помещается в память.
  • Кэш без ограничения размера (dict как кэш) - медленная утечка до OOM.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 11, остальные разбираются в тренажёре.

  1. #generators_memory1 / 5
    Нужно посчитать агрегат по файлу в 100 ГБ на машине с 16 ГБ RAM. Как?
    A)Потоково: читать генератором по одной записи/чанку и обновлять агрегат инкрементально
    B)Сначала загрузить весь файл в список в памяти, а потом посчитать агрегат
    C)Открыть файл сразу в нескольких копиях в памяти и считать их параллельно для ускорения агрегата
    D)Отсортировать весь файл целиком в памяти перед тем, как считать по нему агрегат
    показать ответ и разбор
    +A)Потоково: читать генератором по одной записи/чанку и обновлять агрегат инкрементально

    // разбор: Файл в 100 ГБ не влезет в 16 ГБ RAM, поэтому его обрабатывают потоково: генератор отдаёт по строке (или чанку), а агрегат обновляется инкрементально в константной памяти. Сумма, счётчик, среднее считаются так тривиально; точные квантили и распределения требуют либо двух проходов, либо приближённых структур (t-digest, reservoir sampling).

  2. #generators_memory2 / 5
    Что делает ключевое слово yield в функции Python?
    A)Немедленно возвращает список всех значений и завершает работу функции
    B)Печатает значение на экран, никак не влияя на возвращаемый функцией результат
    C)Кэширует результат функции в памяти, чтобы при повторном вызове не вычислять заново
    D)Превращает функцию в генератор: она отдаёт значения по одному и приостанавливается между ними
    показать ответ и разбор
    +D)Превращает функцию в генератор: она отдаёт значения по одному и приостанавливается между ними

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

  3. #generators_memory3 / 5
    Чем итерируемое (iterable) отличается от итератора (iterator)?
    A)Iterable умеет отдать итератор (__iter__); итератор хранит позицию и отдаёт следующий (__next__)
    B)Это два близких термина, обозначающих по сути одно понятие в Python
    C)Iterable можно пройти лишь один раз, а итератор — многократно подряд
    D)Итератор обязательно хранит все свои элементы целиком в оперативной памяти сразу
    показать ответ и разбор
    +A)Iterable умеет отдать итератор (__iter__); итератор хранит позицию и отдаёт следующий (__next__)

    // разбор: Iterable (список, файл) реализует __iter__ и умеет отдать свежий итератор; итератор реализует __next__, хранит текущую позицию и одноразов — пройдя до конца, исчерпывается. Из iterable можно получить много итераторов, итератор же проходится один раз. Отсюда баг: повторный проход по уже исчерпанному генератору молча даёт пусто.

  4. #generators_memory4 / 5
    Зачем в обработке больших потоков берут itertools (islice, chain, groupby)?
    A)Itertools загружает весь итерируемый объект в список и лишь потом применяет операции к нему
    B)Это функции для многопоточности и параллельного исполнения на нескольких ядрах
    C)Комбинировать и резать потоки лениво, не материализуя промежуточные списки в памяти
    D)Itertools ускоряет числовые вычисления и к потокам данных отношения не имеет
    показать ответ и разбор
    +C)Комбинировать и резать потоки лениво, не материализуя промежуточные списки в памяти

    // разбор: itertools даёт ленивые строительные блоки над итераторами: islice берёт срез без списка, chain склеивает потоки, groupby группирует подряд идущие, islice/takewhile ограничивают. Всё работает по одному элементу и не создаёт промежуточных списков — так конвейеры обрабатывают потоки, которые не влезают в память.

  5. #generators_memory5 / 5
    Почему pandas.read_csv(chunksize=...) обрабатывает файл, который не влезает в память?
    A)Pandas сжимает файл в памяти, поэтому он обычно помещается целиком
    B)Chunksize ускоряет чтение, но по-прежнему держит весь распарсенный файл в оперативной памяти
    C)Эта опция автоматически распараллеливает чтение файла между всеми ядрами процессора
    D)Возвращает итератор по кускам: в памяти держится лишь один чанк, а не весь файл сразу
    показать ответ и разбор
    +D)Возвращает итератор по кускам: в памяти держится лишь один чанк, а не весь файл сразу

    // разбор: chunksize превращает read_csv в итератор, отдающий DataFrame по N строк. Пайплайн обрабатывает и агрегирует чанк, отбрасывает его и берёт следующий, поэтому пиковая память ограничена размером чанка, а не файла. Классический приём для файлов больше RAM; агрегаты копят инкрементально между чанками.

дальше

Теорию прочитали. Навык ставится повторением

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