Итераторы и генераторы: спящие кадры, ленивые конвейеры и файл, который так и не закрылся
for зовёт __iter__, затем __next__ до StopIteration. Генератор — приостановленный кадр: им управляют send/throw/close, yield from делегирует. Ленивые конвейеры тянут гигабайты в константной памяти, но спящий кадр держит ресурсы — вплоть до открытого файла на часы.
Экспортёр стримил ежедневные CSV-снапшоты в S3 через элегантный генераторный конвейер: read_rows(path) открывал файл и отдавал распарсенные строки, нижние стадии фильтровали и собирали батчи. Потом платформенная команда ограничила открытые файловые дескрипторы числом 1024, и сервис начал умирать с OSError: Too many open files — но только в дни с множеством маленьких тенантов. Охота на утечку не нашла никого, кто забыл бы вызвать close. Виновником оказался поток управления: для тенантов без подходящих строк нижняя стадия выходила раньше после первой же попытки собрать батч, бросая конвейер. Генератор read_rows был приостановлен на yield внутри with open(path) — блок with так и не завершился, потому что кадр больше не возобновлялся. Спящий кадр генератора — живой объект, держащий всё в своей области видимости: файловый хендл, распарсенную строку, буферы. Сборщик мусора со временем вызвал бы close(), но CPython финализирует лишь при обнулении ссылок, а словарь-реестр держал каждый конвейер живым. Тысяча брошенных генераторов, тысяча открытых дескрипторов, один пейдж в четыре утра.
Протокол итератора: во что на самом деле компилируется for
for x in xs: разворачивается так: вызвать iter(xs) — то есть xs.__iter__() — и получить итератор, затем звать it.__next__(), пока тот не поднимет StopIteration, которое цикл проглатывает как нормальное завершение. Две роли, которые часто путают: итерируемое имеет __iter__ и обходится много раз (списки, словари, ваши классы-коллекции); итератор имеет __next__ и __iter__, возвращающий самого себя, несёт состояние позиции и исчерпывается ровно один раз. Классический баг, который объясняет это различие: передайте итератор (а не список) двум потребителям — второй не увидит ничего, итерация его съела. Вторая тонкость с зубами: StopIteration — это управление потоком, поэтому случайный StopIteration, поднятый внутри тела генератора, молча завершал бы итерацию вместо падения; PEP 479 (дефолт с 3.7) превращает его в RuntimeError — увидели такую ошибку, значит кто-то вызвал next() без дефолта внутри генератора.
Генераторы: спящие кадры с API
Функция с yield при вызове не выполняется — она возвращает объект-генератор, оборачивающий приостановленный кадр стека: локальные переменные, указатель инструкции, всё. next() возобновляет кадр до следующего yield, который снова его усыпляет, отдав значение вызывающему. return (или конец тела) поднимает StopIteration с приложенным возвращаемым значением. Живучесть кадра — и есть вся фича (конечные автоматы без классов), и вся проблема Хука: всё, на что ссылается кадр, живёт, пока он спит.
Помимо next(), полный управляющий API: gen.send(value) возобновляет кадр, и приостановленное выражение yield вычисляется в value (на этом построены корутинные протоколы — с этого начиналась машинерия asyncio); gen.throw(exc) поднимает исключение в точке спящего yield, давая генератору поймать его для очистки или восстановления; gen.close() бросает туда GeneratorExit, ожидая выхода, — блок finally или with вокруг yield выполняется именно в этот момент. В этом и фикс для Хука: потребитель, бросающий конвейер, обязан вызвать close() (или держать генератор в with contextlib.closing(...)) — кадр возобновится в последний раз, и with open(...) сможет завершиться. yield from sub делегирует целый под-генератор: значения, send, throw проходят насквозь прозрачно, а возвращаемое значение под-генератора становится значением выражения — ручной for x in sub: yield x теряет проброс send/throw и возвращаемое значение.
import csv
from collections.abc import Iterator
def read_rows(path: str) -> Iterator[dict]:
with open(path, newline="") as f: # открыт, пока кадр спит!
yield from csv.DictReader(f) # делегирование: полный проброс протокола
def large_orders(rows: Iterator[dict], min_total: float) -> Iterator[dict]:
return (r for r in rows if float(r["total"]) >= min_total)
pipeline = large_orders(read_rows("orders.csv"), 100.0)
try:
for row in pipeline:
process(row)
finally:
pipeline.close() # будит спящий кадр, чтобы `with open` завершилсяГенератор открывает файл в with-блоке и отдаёт строки изнутри него. Потребитель читает 10 строк, выходит раньше и сохраняет ссылку на генератор в словаре. Когда закроется файл?
Конвейеры и itertools: словарь ленивой обработки
Сцепленные генераторы дают стриминг с константной памятью: каждая стадия тянет по одному элементу, и CSV на 20 ГБ протекает через фильтрацию, трансформацию и батчинг в несколько килобайт рабочего набора — альтернатива, материализация списков между стадиями, стоит полного датасета на каждую стадию. Числа при этом честные: возобновление генератора — переключение кадра на уровне Python, так что накладные расходы на элемент примерно 100–200 нс против ~30 нс шага итерации списка на уровне C; для горячих циклов на миллион элементов, целиком влезающих в память, списковое включение часто быстрее. Ленивость покупает память и время до первого элемента, а не сырую пропускную способность.
itertools — стандартный словарь, чтобы перестать писать статфул-циклы вручную: islice (пагинация по любому итератору без срезов), chain (склейка потоков), groupby (группировка серий — требует отсортированного входа, самый частый баг с itertools), tee (разветвить итератор на n — но буферизует всё, что не прочитал самый медленный потребитель, так что два потребителя на разных скоростях молча пересобирают список в памяти), batched (3.12+, та самая стадия батчинга, которую десять лет писали руками), плюс count/cycle/takewhile/dropwhile. Они складываются в конвейеры, читающиеся как поток данных, который реализуют, — и все написаны на C, так что конвейер из стадий itertools почти не платит поэлементную цену Python-кадров.
Стадия конвейера делает `for x in sub(): yield x` вместо `yield from sub()`. Для простой итерации функционально одинаково — что на самом деле потерял рефакторинг?
- 01Объясните, как брошенный генератор течёт файловым хендлом, почему GC не спасает быстро, и двусторонний фикс.
- 02Сравните next/send/throw/close и точно сформулируйте, что yield from добавляет к ручному циклу for-yield.
Итерация — это два дандера: for вызывает iter() ради итератора, затем __next__() до StopIteration — итерируемые (с __iter__) перезапускаются на каждом цикле, итераторы (с __next__ и __iter__, возвращающим себя) несут позицию и исчерпываются один раз; вот почему общий итератор у двух потребителей оставляет второго голодным. PEP 479 превратил случайный StopIteration внутри тела генератора в RuntimeError, закрыв ловушку тихого усечения. Генераторная функция возвращает приостановленный кадр: next ведёт до следующего yield, send заставляет этот yield вычислиться в значение, throw поднимает в нём исключение, close вбрасывает GeneratorExit, чтобы finally и with наконец выполнились, — а yield from делегирует всё это: значения, send, throw, close, плюс возвращаемое значение под-генератора, которое рукописный цикл for-yield теряет. Конвейеры генераторов обрабатывают сколь угодно большие потоки в константной памяти с отличным временем до первого элемента, по честным ~100–200 нс на возобновление уровня Python против ~30 нс C-шагов списка — itertools (islice, chain, groupby с отсортированным входом, прожорливый на буферы tee, batched из 3.12) даёт стадии C-скорости и общий словарь. Продакшен-отказ — спящий кадр как держатель ресурса: потребитель, бросивший конвейер на полпути, оставляет with open(...) незавершённым, дескриптор живым, а на пейджере — тихого кузена OOM-киллера, исчерпание FD. Закрывайте то, что не исчерпали.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.