Генераторы и память в пайплайнах
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, остальные разбираются в тренажёре.
- Нужно посчитать агрегат по файлу в 100 ГБ на машине с 16 ГБ RAM. Как?A)Потоково: читать генератором по одной записи/чанку и обновлять агрегат инкрементальноB)Сначала загрузить весь файл в список в памяти, а потом посчитать агрегатC)Открыть файл сразу в нескольких копиях в памяти и считать их параллельно для ускорения агрегатаD)Отсортировать весь файл целиком в памяти перед тем, как считать по нему агрегат
показать ответ и разбор
+A)Потоково: читать генератором по одной записи/чанку и обновлять агрегат инкрементально// разбор: Файл в 100 ГБ не влезет в 16 ГБ RAM, поэтому его обрабатывают потоково: генератор отдаёт по строке (или чанку), а агрегат обновляется инкрементально в константной памяти. Сумма, счётчик, среднее считаются так тривиально; точные квантили и распределения требуют либо двух проходов, либо приближённых структур (t-digest, reservoir sampling).
- Что делает ключевое слово yield в функции Python?A)Немедленно возвращает список всех значений и завершает работу функцииB)Печатает значение на экран, никак не влияя на возвращаемый функцией результатC)Кэширует результат функции в памяти, чтобы при повторном вызове не вычислять зановоD)Превращает функцию в генератор: она отдаёт значения по одному и приостанавливается между ними
показать ответ и разбор
+D)Превращает функцию в генератор: она отдаёт значения по одному и приостанавливается между ними// разбор: yield делает функцию генератором: при каждом next() она выполняется до yield, отдаёт значение и замирает, сохраняя состояние, а на следующем вызове продолжает с того же места. Значения производятся лениво, по одному, без материализации всей последовательности — основа потоковой обработки данных на ограниченной памяти.
- Чем итерируемое (iterable) отличается от итератора (iterator)?A)Iterable умеет отдать итератор (__iter__); итератор хранит позицию и отдаёт следующий (__next__)B)Это два близких термина, обозначающих по сути одно понятие в PythonC)Iterable можно пройти лишь один раз, а итератор — многократно подрядD)Итератор обязательно хранит все свои элементы целиком в оперативной памяти сразу
показать ответ и разбор
+A)Iterable умеет отдать итератор (__iter__); итератор хранит позицию и отдаёт следующий (__next__)// разбор: Iterable (список, файл) реализует __iter__ и умеет отдать свежий итератор; итератор реализует __next__, хранит текущую позицию и одноразов — пройдя до конца, исчерпывается. Из iterable можно получить много итераторов, итератор же проходится один раз. Отсюда баг: повторный проход по уже исчерпанному генератору молча даёт пусто.
- Зачем в обработке больших потоков берут itertools (islice, chain, groupby)?A)Itertools загружает весь итерируемый объект в список и лишь потом применяет операции к немуB)Это функции для многопоточности и параллельного исполнения на нескольких ядрахC)Комбинировать и резать потоки лениво, не материализуя промежуточные списки в памятиD)Itertools ускоряет числовые вычисления и к потокам данных отношения не имеет
показать ответ и разбор
+C)Комбинировать и резать потоки лениво, не материализуя промежуточные списки в памяти// разбор: itertools даёт ленивые строительные блоки над итераторами: islice берёт срез без списка, chain склеивает потоки, groupby группирует подряд идущие, islice/takewhile ограничивают. Всё работает по одному элементу и не создаёт промежуточных списков — так конвейеры обрабатывают потоки, которые не влезают в память.
- Почему pandas.read_csv(chunksize=...) обрабатывает файл, который не влезает в память?A)Pandas сжимает файл в памяти, поэтому он обычно помещается целикомB)Chunksize ускоряет чтение, но по-прежнему держит весь распарсенный файл в оперативной памятиC)Эта опция автоматически распараллеливает чтение файла между всеми ядрами процессораD)Возвращает итератор по кускам: в памяти держится лишь один чанк, а не весь файл сразу
показать ответ и разбор
+D)Возвращает итератор по кускам: в памяти держится лишь один чанк, а не весь файл сразу// разбор: chunksize превращает read_csv в итератор, отдающий DataFrame по N строк. Пайплайн обрабатывает и агрегирует чанк, отбрасывает его и берёт следующий, поэтому пиковая память ограничена размером чанка, а не файла. Классический приём для файлов больше RAM; агрегаты копят инкрементально между чанками.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.