Итераторы, генераторы и корутины
Протокол итерации, генераторы и yield, модуль itertools, сопрограммы, async и await
Вячеслав Федоров
Лекция 7
Разработка и применение ПО
в физических исследованиях
Изложение. Седьмая лекция посвящена трём родственным механизмам языка, связанным одной идеей — не вычислять всё сразу. Итератор выдаёт элементы по одному, генератор приостанавливает функцию и продолжает её с места остановки, сопрограмма дополнительно принимает значения извне, а цикл событий переключается между сопрограммами, пока они ожидают ответа. В рамках лекции рассматриваются протокол итерации и устройство цикла for, генераторные функции и выражения, конвейеры обработки данных, модуль itertools, методы send, throw и close, генераторы как менеджеры контекста и синтаксис async и await. Надпись на титуле — ключевое слово yield, превращающее функцию в генераторную: её вызов возвращает генератор.
Демонстрации. Слайды с розовым выделением слова «Демонстрация» отмечают места, где изложение прерывается для работы в интерактивной оболочке на виртуальной машине lab. На каждом таком слайде проигрывается запись тех же команд, поэтому показ воспроизводим и без живого терминала.
Содержание
От протокола итерации до async и await
Изложение. Лекция состоит из пяти частей. Первая — итераторы: протокол итерации, устройство цикла for, собственные итераторы и итераторы стандартной библиотеки. Вторая — генераторы: функции с yield, их состояния, генераторные выражения, конвейеры, бесконечные последовательности и делегирование через yield from. Третья — модуль itertools: бесконечные итераторы, срезы, объединение, группировка и комбинаторика. Четвёртая — генераторы как сопрограммы: методы send, throw и close, yield from как канал между вызывающим кодом и подгенератором, генераторы как менеджеры контекста. Пятая — async и await: цикл событий, нативные сопрограммы, задачи, тайм-ауты и асинхронная итерация.
Цель лекции
Не вычислять всё сразу: элементы по одному, вычисления по запросу, ожидание без блокировки
Итератор выдаёт элементы по одному, генератор приостанавливает функцию на yield и продолжает её с места остановки, сопрограмма принимает значения извне, а цикл событий переключается между сопрограммами, пока они ожидают. Эти механизмы позволяют обрабатывать данные, не помещающиеся в память, описывать бесконечные последовательности и в одном потоке одновременно ожидать ответов нескольких приборов.
Изложение. Оценим расход памяти: список из ста тысяч квадратов целых чисел вместе с самими числами занимает около 4 МБ, а генератор тех же чисел — сотни байт, поскольку значения вычисляются тогда, когда их запрашивают. На этом основаны три приёма. Первый — потоковая обработка: файл, журнал или последовательность событий читаются по одному элементу, и объём данных ограничен диском, а не оперативной памятью. Второй — бесконечные последовательности: генератор описывает правило получения следующего элемента, а момент остановки определяет вызывающий код. Третий — сопрограммы и асинхронность: функция, способная приостановиться, позволяет одному потоку переключаться между задачами, пока каждая ожидает ответа от сети или прибора.
Для физиков: журналы смены, потоки событий детектора и шаги интегрирования естественно описываются итераторами и генераторами; асинхронность нужна там, где программа опрашивает приборы или обращается к сетевым сервисам.
Итераторы
Протокол итерации, цикл for, собственные итераторы, итераторы стандартной библиотеки
01
Итераторы
Генераторы
Модуль itertools
Сопрограммы
async и await
Изложение. Первая часть посвящена устройству цикла for: какие методы вызывает интерпретатор, чем итерируемый объект отличается от итератора и как написать собственный итератор.
Устройство
цикла for
выполняется как
it = iter(xs) while True: try: x = next(it) except StopIteration: break process(x)
Вызов Метод
iter(xs) xs.__iter__()
next(it) it.__next__()
элементов нет raise StopIteration
>>> dis.dis('for x in xs: pass') # 3.12 GET_ITER # iter(xs) FOR_ITER # next(it) STORE_NAME x JUMP_BACKWARD END_FOR
Пояснение
StopIteration перехватывает сам цикл: в коде исключение не видно.
Изложение. Цикл for в Python не перебирает индексы: он получает у объекта итератор функцией iter и вызывает для него next, пока тот не сообщит об окончании исключением StopIteration. Исключение перехватывает сам цикл, поэтому в коде оно не видно. В байт-коде это две инструкции: GET_ITER вызывает iter для перебираемого объекта, FOR_ITER на каждом шаге вызывает next и при окончании элементов переходит за конец цикла. Встроенные функции iter и next вызывают специальные методы __iter__ и __next__, как len вызывает __len__; напрямую специальные методы почти никогда не вызываются. По тому же протоколу работают распаковка, списковые включения, функции sum, max, sorted и list, конструкторы коллекций и оператор in для объектов без метода __contains__.
Для физиков: протокол итерации позволяет обрабатывать любой поддерживающий его источник данных — файл, генератор событий, набор измерений — тем же циклом for, что и список.
Итерируемый объект
и итератор
it = iter(xs)
итератор: __next__ и __iter__
next(it) × 3
1.5, 2.5, 4.0
next(it)
StopIteration при каждом вызове
Итерируемый Итератор
методы __iter__ → новый итератор __next__; __iter__ → self
проходов сколько угодно один
примеры list, str, dict, range iter(list), файл, map, zip
Важно
Итератор однопроходный: после StopIteration каждый следующий next снова даёт StopIteration.
Пояснение
Проверка: isinstance(x, collections.abc.Iterator) отличает итератор от итерируемого объекта.
Изложение. Протокол итерации разделяет две роли. Итерируемый объект (iterable) является источником итераторов: его метод __iter__ возвращает новый объект-итератор при каждом вызове. Итератор (iterator) представляет собой объект, выдающий элементы: метод __next__ возвращает следующий элемент или возбуждает StopIteration, если элементы закончились. Итератор в Python обязан быть и итерируемым — его __iter__ возвращает self, поэтому итератор можно передать в цикл for. Отсюда различие в поведении: по списку можно пройти сколько угодно раз, поскольку каждый for получает новый итератор, а по итератору — один раз, после чего он исчерпан. Документация требует, чтобы исчерпанный итератор и дальше возбуждал StopIteration при каждом вызове next; итератор, который после этого снова начинает выдавать элементы, считается некорректным.
Для физиков: список измерений можно обойти несколько раз — для среднего и для дисперсии; файл или генератор событий после первого прохода пуст, и для второго прохода необходимо открыть файл заново или сохранить данные.
Демонстрация:
итератор списка
01
Итератор списка: iter(xs)
02
Элементы и дважды StopIteration
04
Пустой list(it) и полный list(xs)
06
Байт-код: GET_ITER и FOR_ITER
Что наблюдается
Исчерпанный итератор каждый раз даёт StopIteration; список выдаёт новый итератор на каждый проход.
root@lab — запись со стенда
Демонстрация. Для списка xs = [1.5, 2.5, 4.0] функция iter возвращает объект list_iterator. Два вызова next дают 1.5 и 2.5, третий — 4.0, четвёртый и пятый возбуждают StopIteration: исчерпанный итератор остаётся исчерпанным. Выражение iter(it) is it даёт True: итератор возвращает из __iter__ самого себя. list(it) после исчерпания пуст, а list(xs) выдаёт все элементы: список создаёт новый итератор. next(range(3)) завершается TypeError: 'range' object is not an iterator, поскольку range является ленивой последовательностью, по которой можно пройти многократно, но не итератором. Дизассемблирование строки for x in xs: pass показывает инструкции GET_ITER и FOR_ITER; инструкция END_FOR в Python 3.12 отмечает выход из цикла.
Собственный
итератор
class Linspace: def __init__(self, a, b, n): self.a, self.n = a, n self.h = (b - a) / (n - 1) def __iter__(self): return LinspaceIterator(self.a, self.h, self.n) class LinspaceIterator: def __init__(self, a, h, n): self.a, self.h, self.n, self.i = a, h, n, 0 def __iter__(self): return self def __next__(self): if self.i == self.n: raise StopIteration self.i += 1 return self.a + (self.i - 1) * self.h
Пояснение
Счётчик обхода i хранится в итераторе: каждый for получает новый итератор и начинает с первой точки.
Важно
Если __iter__ контейнера вернёт self и счётчик будет храниться в нём, второй проход окажется пустым.
Для физика
Linspace — упрощённый аналог numpy.linspace. Точка вычисляется как a + i·h, а не накоплением: ошибки округления не растут с номером точки.
Изложение. Собственный итератор представляет собой класс с методами __next__ и __iter__. В качестве примера рассмотрим Linspace — аналог numpy.linspace, выдающий n равноотстоящих точек отрезка от a до b. Роли разделены: контейнер Linspace хранит параметры сетки и на каждый вызов __iter__ создаёт новый LinspaceIterator, а итератор хранит состояние обхода — номер следующей точки. Поэтому по одной сетке можно пройти сколько угодно раз, и каждый цикл начинается с первой точки. Если совместить роли в одном классе, где __iter__ возвращает self, а счётчик хранится в объекте, кода станет меньше, но второй проход окажется пустым: он начнётся там, где закончился первый. Такое совмещение оправдано для объектов, которые сами являются однопроходными, — файлов, сетевых потоков, генераторов. Точка вычисляется как a + i·h, а не прибавлением h к предыдущей: при накоплении ошибки округления складываются и растут с номером точки, а при умножении погрешность каждой точки остаётся в последних разрядах. Последняя точка и при умножении может отличаться от b в последнем знаке, поэтому numpy.linspace присваивает ей значение b явно. Во второй части тот же обход записывается генератором в несколько строк.
Для физиков: сетки по времени, энергии или координате — частый случай собственного итерируемого объекта; при работе с массивами их заменяют numpy.linspace и numpy.arange, но протокол тот же.
Другие способы
итерации
Механизм Как работает Применение
__getitem__ перебор индексов 0, 1, 2… до IndexError старые классы-последовательности
iter(f, граница) вызовы f() до граничного значения чтение блоками, опрос
__contains__ x in obj; без метода — перебор через __iter__ проверка принадлежности
from functools import partial with open('run042.dat', 'rb') as f: for block in iter(partial(f.read, 4096), b''): process(block)
Важно
x in iterator расходует итератор: элементы до найденного включительно уже не вернуть.
Изложение. Кроме основного протокола, функция iter поддерживает два дополнительных способа. Первый — протокол последовательности: если у объекта нет __iter__, но есть __getitem__, iter создаёт итератор, запрашивающий элементы с индексами 0, 1, 2 и далее, пока __getitem__ не возбудит IndexError. Так перебирались последовательности до появления протокола итераторов, и этот путь сохранён для совместимости; collections.abc.Iterable такой объект итерируемым не признаёт, поэтому надёжной проверкой является вызов iter(obj). Второй — функция iter с двумя аргументами: вызываемым объектом и граничным значением. Итератор вызывает функцию без аргументов и выдаёт результаты, пока не получит граничное значение. Для чтения файла блоками границей служит пустая строка байтов: read возвращает b'' в конце файла. Оператор in вызывает метод __contains__, а при его отсутствии перебирает объект через __iter__ или __getitem__, пока не найдёт равный элемент. Для итератора это означает расход элементов: после проверки 0.5 in it в итераторе остаются только элементы после найденного.
Для физиков: двоичные файлы оцифровщиков и детекторов часто читаются блоками фиксированного размера — iter(partial(f.read, n), b'') записывает такой цикл в одну строку.
Демонстрация:
протоколы итерации
01
Классы LinspaceIterator и Linspace
02
Два прохода по одной сетке
03
0.5 in it: расход итератора
04
Класс только с __getitem__
05
Чтение блоками: iter с границей b''
Что наблюдается
Сетка обходится дважды; после 0.5 in it итератор выдаёт только 0.75 и 1.0; класс без __iter__ перебирается по индексам.
root@lab — запись со стенда
Демонстрация. Введём классы LinspaceIterator и Linspace; сетка Linspace(0.0, 1.0, 5) дважды выдаёт [0.0, 0.25, 0.5, 0.75, 1.0]. Итератор it = iter(grid) проверяется выражением 0.5 in it — результат True, после чего list(it) возвращает только [0.75, 1.0]: элементы до найденного израсходованы. Класс Squares определяет лишь __getitem__ и возбуждает IndexError при i >= 4; list(Squares()) даёт [0, 1, 4, 9], а 9 in Squares() — True: оба выражения перебирают индексы. Чтение io.BytesIO(b'0123456789') итератором iter(functools.partial(f.read, 4), b'') даёт блоки b'0123', b'4567' и b'89': перебор останавливается на пустой строке байтов.
Итераторы
стандартной библиотеки
Объект Итератор? Повторный обход
list, tuple, str, dict, set нет: итерируемый да
range(n) нет: ленивая последовательность да; есть len и индексы
dict.keys(), values(), items() нет: представления да; текущее содержимое словаря
open(path) да нет: позиция в файле
map, filter, zip, enumerate, reversed да нет
генератор да нет
Пояснение
zip(..., strict=True) (Python 3.10) проверяет равенство длин и при расхождении возбуждает ValueError.
Важно
Результат map или zip, использованный дважды, во второй раз пуст.
Изложение. Встроенные коллекции являются итерируемыми объектами, но не итераторами: каждый проход создаёт новый итератор. range не хранит элементы, а вычисляет их и при этом остаётся последовательностью с длиной, индексами и проверкой in без перебора. Методы словаря keys, values и items возвращают представления — итерируемые объекты, отражающие текущее содержимое словаря; изменение размера словаря во время обхода завершается RuntimeError. Файл, открытый функцией open, сам является итератором по строкам: второй проход требует перемотки seek(0) или повторного открытия. Функции map, filter, zip, enumerate и reversed возвращают итераторы и вычисляют элементы по запросу; их результат, использованный дважды, во второй раз пуст — частая ошибка при переносе кода с Python 2, где map и zip возвращали списки. Функция zip останавливается на самой короткой последовательности; с Python 3.10 параметр strict=True превращает расхождение длин в ValueError.
Для физиков: zip(times, values, strict=True) обеспечивает защиту от молчаливого усечения рядов разной длины, например при объединении отметок времени и показаний.
Генераторы
yield, состояния генератора, генераторные выражения, конвейеры, бесконечные последовательности, yield from
02
Итераторы
Генераторы
Модуль itertools
Сопрограммы
async и await
Изложение. Вторая часть посвящена генераторам — объектам, которые возвращает функция с yield: выполнение приостанавливается на yield и продолжается с места остановки. Генератор заменяет класс итератора несколькими строками и позволяет строить конвейеры обработки данных и бесконечные последовательности.
Генераторная
функция
def countdown(n): print('старт') while n > 0: yield n n -= 1 return 'пуск' g = countdown(3) # тело не выполняется next(g) # старт, 3 next(g), next(g) # (2, 1) next(g) # StopIteration: пуск
Пояснение
Генератором можно написать __iter__: «for i in range(self.n): yield self.a + i * self.h» заменяет класс LinspaceIterator.
g = countdown(3)
объект-генератор без выполнения тела
next(g)
печать «старт», выполнение до yield: 3
next(g)
продолжение после yield: 2
return 'пуск'
StopIteration('пуск')
Изложение. Функция, в теле которой встречается yield, при вызове не выполняет тело, а возвращает объект-генератор. Генератор является итератором: next запускает тело и выполняет его до ближайшего yield, значение yield становится результатом next, а функция приостанавливается, сохранив локальные переменные и место остановки. Следующий next продолжает выполнение с инструкции после yield. Когда тело завершается, возбуждается StopIteration, и значение return, если оно есть, сохраняется в атрибуте value исключения; цикл for это значение не использует. Поэтому «старт» печатается не при вызове countdown(3), а при первом next. Генератор заменяет класс итератора: метод __iter__ контейнера, написанный генератором, возвращает новый генератор при каждом вызове, что позволяет проходить по контейнеру многократно, как по Linspace из первой части, но без отдельного класса.
Для физиков: генератор удобен там, где следующий элемент вычисляется по предыдущим, — шаги интегрирования, итерации метода Ньютона, последовательные приближения.
Состояния
генератора
next()
yield
next(), send(), throw()
close()
return или исключение
Атрибут Что показывает
inspect.getgeneratorstate(g) одно из четырёх состояний
g.gi_frame кадр: f_locals, f_lineno; None после завершения
g.gi_running True во время выполнения тела
g.gi_yieldfrom подгенератор при yield from
Изложение. Генератор проходит четыре состояния, которые возвращает функция inspect.getgeneratorstate. Сразу после вызова генераторной функции он находится в состоянии GEN_CREATED: кадр создан, тело не выполнялось. next переводит его в GEN_RUNNING, а yield — в GEN_SUSPENDED, где сохраняются локальные переменные и номер инструкции. Следующие next, send или throw (методы send и throw разбираются в четвёртой части) снова запускают тело с места остановки. Завершение тела оператором return или исключением, а также вызов close переводят генератор в GEN_CLOSED, после чего next всегда возбуждает StopIteration; close у ещё не запущенного генератора переводит его в GEN_CLOSED сразу, без выполнения тела. Состояние хранится в атрибутах объекта: gi_frame — кадр выполнения с локальными переменными f_locals и номером строки f_lineno, после завершения генератора gi_frame равен None; gi_running — признак выполнения; gi_yieldfrom — подгенератор, которому делегировано выполнение через yield from. Генератор не может запустить сам себя: next(g) внутри тела g завершается ValueError: generator already executing.
Демонстрация:
состояния генератора
01
countdown: print, yield и return
02
Вызов без выполнения тела: GEN_CREATED
03
Первый next: «старт» и 3
04
Состояние и локальные переменные
05
StopIteration со значением return
06
PEP 479: next внутри генератора
Что наблюдается
«старт» — при первом next; return передан в StopIteration: пуск; StopIteration внутри firsts стала RuntimeError.
root@lab — запись со стенда
Демонстрация. Генераторная функция countdown печатает «старт», выдаёт n, n − 1, …, 1 и возвращает строку «пуск». Вызов countdown(3) создаёт объект generator, но ничего не печатает: состояние GEN_CREATED. Первый next печатает «старт» и возвращает 3; состояние — GEN_SUSPENDED, в g.gi_frame.f_locals хранится {'n': 3}. Следующие два вызова дают 2 и 1, четвёртый возбуждает StopIteration: пуск — значение return передано в исключение. После этого состояние GEN_CLOSED. list(countdown(3)) создаёт новый генератор, печатает «старт» и собирает [3, 2, 1]: значение return в список не попадает. Генератор firsts выдаёт первый элемент каждой группы вызовом next без значения по умолчанию; на пустой группе list(firsts([[1], []])) завершается RuntimeError: generator raised StopIteration, а трассировка показывает исходную StopIteration как прямую причину.
Генераторные
выражения
squares = [x * x for x in range(100_000)] lazy = (x * x for x in range(100_000)) total = sum(x * x for x in range(100_000))
Python 3.12 Список Генератор
sys.getsizeof 800 984 байта 200 байт
пик памяти при sum 4,0 МБ 464 байта
повторный обход да нет
len и индексы да нет
Важно
Первый источник вычисляется сразу, остальное — при обходе: условие читает переменные в момент перебора.
data = [0, 2, 5, 12] threshold = 1 g = (x for x in data if x > threshold) threshold = 10 list(g) # [12]
Изложение. Генераторное выражение представляет собой краткую форму генератора: от спискового включения оно отличается круглыми скобками вместо квадратных. Списковое включение сразу строит список и хранит все элементы; генераторное выражение возвращает генератор, который вычисляет элементы по одному при обходе. Если выражение — единственный аргумент функции, достаточно одних скобок: sum(x * x for x in range(100_000)). Разница в памяти измерена на Python 3.12.3: сам список из 100 тысяч ссылок занимает 800 984 байта с учётом запаса, оставленного при росте, генератор — 200 байт; пиковое потребление при суммировании через список, с учётом объектов-чисел, — около 4 МБ, через генератор — 464 байта. Взамен генератор однопроходный и не поддерживает len и индексы. Отметим особенность вычисления: первый источник — range(...) или data — вычисляется сразу при создании выражения, а условие и остальные части выполняются при обходе. Поэтому изменение переменной threshold после создания генератора влияет на результат; списковое включение в такой ситуации вычислилось бы сразу.
Для физиков: суммы, средние и экстремумы по большим наборам удобно вычислять генераторными выражениями — без промежуточного списка в памяти.
Конвейер
генераторов
def read_lines(path): with open(path) as f: yield from f def parse(lines): for line in lines: if not line.strip() or line.startswith('#'): continue t, channel, value = line.split() yield float(t), channel, float(value) def select(events, name): for t, channel, value in events: if channel == name: yield t, value lines = read_lines('run042.log') bpm = select(parse(lines), 'BPM01') print(max(value for t, value in bpm))
read_lines('run042.log')
строки файла по одной
parse
разбор строки в событие
select
события канала BPM01
max(...)
результат; в памяти — одно событие
Изложение. Генераторы соединяются в конвейер: каждая стадия, кроме первой, принимает итерируемый объект и сама является генератором. read_lines читает журнал построчно, поскольку файл — итератор по строкам; parse пропускает пустые строки и комментарии и выдаёт разобранные события; select оставляет события одного канала; max потребляет результат. Элементы проходят конвейер по одному: max запрашивает событие у select, select — у parse, parse — строку у read_lines. В памяти одновременно находятся одна строка и одно событие; таким образом, журнал любого размера обрабатывается с постоянным расходом памяти. Независимость стадий позволяет заменять их, добавлять проверку или пересчёт единиц; parse и select проверяются по отдельности на списке строк вместо файла, как в демонстрации. Файл закрывается, когда генератор read_lines завершится или будет закрыт; если обход прерван досрочно, файл остаётся открытым до вызова close или сборки мусора.
Для физиков: журнал смены, записанный за много часов, может не помещаться в память, но обрабатывается таким конвейером так же просто, как список.
Демонстрация:
конвейер генераторов
01
Стадии: чтение, разбор, отбор
02
Конвейер без чтения строк
03
next: строка через все стадии
04
next: отбрасывание строки BPM02
05
next со значением по умолчанию
06
Размер списка и генератора
Что наблюдается
Строки читаются по одной и по запросу; getsizeof списка — 800 984 байта (одни ссылки), генератора — 200.
root@lab — запись со стенда
Демонстрация. Стадия read печатает каждую прочитанную строку, parse разбирает её на время, канал и значение, select оставляет канал BPM01; список log из трёх строк заменяет файл. Создание pipe = select(parse(read(log)), 'BPM01') ничего не печатает: конвейер ещё не запущен. Первый next печатает «чтение: 0.0 BPM01 1.2» и возвращает (0.0, 1.2). Второй next читает две строки: строка канала BPM02 отброшена стадией select, затем возвращается (0.2, 1.4). Третий next со значением по умолчанию возвращает 'конец' вместо StopIteration. sys.getsizeof для спискового включения квадратов 100 тысяч чисел даёт 800 984 байта — это только массив ссылок, вместе с самими числами список занимает около 4 МБ, — для генераторного выражения — 200 байт.
Бесконечные
последовательности
def oscillator(x, v, dt, w=1.0): t = 0.0 while True: yield t, x, v v -= w * w * x * dt x += v * dt t += dt states = oscillator(1.0, 0.0, 0.01) first = list(islice(states, 3)) t0 = next(t for t, x, v in states if x < 0)
Для физика
Полунеявная схема Эйлера: сначала скорость, затем координата по новой скорости; энергия колеблется, а не растёт.
Потребитель Остановка
islice(g, n) после n элементов
takewhile(cond, g) на первом ложном условии
next(s for s in g if cond) на первом подходящем
for ... break по любому условию
Пояснение
Генератор описывает правило перехода к следующему состоянию, а момент остановки выбирает вызывающий код.
Изложение. Генератор с бесконечным циклом описывает последовательность без конца: значения вычисляются, пока их запрашивают. Рассмотрим генератор oscillator, выдающий состояния гармонического осциллятора с шагом dt по полунеявной схеме Эйлера: сначала обновляется скорость, затем координата по новой скорости. Условия остановки в генераторе нет — его задаёт вызывающий код: islice берёт заданное число состояний, takewhile — пока выполняется условие, next с генераторным выражением возвращает первое состояние, удовлетворяющее условию, например первое пересечение нуля. Генератор, из которого взяли часть элементов, продолжает с того места, где остановился; takewhile при этом забирает и первый элемент, не прошедший условие, и этот элемент теряется. Список из бесконечного генератора построить нельзя: list(oscillator(...)) не завершится, а память будет расти, пока процесс не будет остановлен.
Для физиков: такой подход позволяет разделить схему интегрирования и условие остановки — по времени, по числу шагов или по событию (пересечение нуля, выход за границу, достижение равновесия); одна и та же функция служит и для коротких проверок, и для длинных расчётов.
Демонстрация:
генератор состояний
01
Генератор состояний осциллятора, ω = 1, dt = 0,01
02
Первые три состояния через islice с печатью
03
Продолжение с четвёртого состояния
04
Первое пересечение нуля: t ≈ π/2
05
Отклонение энергии за 10 000 шагов
Что наблюдается
Координата впервые становится отрицательной при t = 1,57 (π/2 = 1,5708); за 10 000 шагов x² + v² отклоняется от 1 не больше чем на 0,00503 = dt/(2 − dt).
root@lab — запись со стенда
Демонстрация. Генератор oscillator(1.0, 0.0, 0.01) описывает осциллятор с ω = 1, начальной координатой 1 и нулевой скоростью. Цикл по islice печатает три первых состояния с шестью знаками: 0.00 1.000000 0.000000, 0.01 0.999900 -0.010000 и 0.02 0.999700 -0.019999. Следующий next возвращает четвёртое состояние при t = 0.03: генератор продолжает с места, где остановился islice. Генераторное выражение с условием x < 0 внутри next находит первое пересечение нуля: t = 1.5700000000000012 против π/2 = 1.5707963267948966 — расхождение меньше шага dt = 0,01. Оценим отклонение энергии: максимум |x² + v² − 1| по 10 000 шагам равен 0.005025…, то есть dt/(2 − dt). Полунеявная схема точно сохраняет величину x² + v² − dt·x·v; таким образом, x² + v² колеблется в узкой полосе около 1, а не растёт. У явной схемы Эйлера x² + v² на каждом шаге умножается на 1 + dt², и за те же 10 000 шагов выросла бы примерно в e раз.
yield from
и делегирование
class Node: def __init__(self, value, left=None, right=None): self.value = value self.left, self.right = left, right def walk(node): if node is not None: yield from walk(node.left) yield node.value yield from walk(node.right) tree = Node(5, Node(3, Node(1)), Node(8)) list(walk(tree)) # [1, 3, 5, 8]
01
Выдача наружу
всех значений подгенератора g
02
Передача внутрь
значений send и исключений throw
03
Возврат результата
значения return генератора g
r = yield from sub() # r — значение return из sub()
Изложение. Когда генератор лишь передаёт элементы другого итерируемого объекта, цикл for x in g: yield x записывается короче: yield from g. Обход двоичного дерева поиска в порядке возрастания — пример, где это особенно удобно: рекурсивный генератор выдаёт элементы левого поддерева, значение узла и элементы правого поддерева, а вложенные вызовы соединяются через yield from. Заметим, что конструкция (PEP 380, Python 3.3) не сводится к сокращению цикла. Она передаёт во вложенный генератор значения, отправленные через send, и исключения, отправленные через throw (оба метода разбираются в четвёртой части), а значением самого выражения yield from становится результат return вложенного генератора. Это позволяет разбивать сложный генератор на части, как функцию на подфункции, и было основой первой реализации сопрограмм asyncio до появления await.
Для физиков: рекурсивный обход деревьев — разбиений пространства (квадродеревья и октодеревья в задаче N тел), иерархий геометрии детектора — записывается рекурсивным генератором с yield from.
Завершение
генератора
Ситуация Результат
return значение StopIteration(значение): результат для yield from, не для for
StopIteration в теле (PEP 479, 3.7) RuntimeError: generator raised StopIteration
next(g) внутри самого g ValueError: generator already executing
досрочное прерывание обхода выполнение finally и with при close() или сборке мусора
def firsts(groups): for g in groups: yield next(iter(g)) # [] → RuntimeError def firsts(groups): for g in groups: yield next(iter(g), None)
Важно
Прерванный обход генератора с открытым файлом закрывают явно: g.close() или contextlib.closing(g).
Изложение. Генератор завершается, когда тело доходит до конца или до return; значение return передаётся в StopIteration.value, его использует yield from, а цикл for отбрасывает. До Python 3.7 исключение StopIteration, возникшее внутри тела, — например, от вызова next у исчерпанного итератора, — молча завершало генератор, и ошибка терялась. PEP 479 изменил это поведение: такое исключение превращается в RuntimeError: generator raised StopIteration. Поэтому внутри генератора next необходимо вызывать со значением по умолчанию или в блоке try, а для завершения использовать return. Генератор, обход которого прерван досрочно — оператором break или исключением у потребителя, — остаётся в состоянии GEN_SUSPENDED; его блоки finally и менеджеры контекста выполнятся при вызове close или при уничтожении объекта сборщиком мусора. В CPython это обычно происходит сразу после потери последней ссылки, в других реализациях — позже, поэтому ресурсы освобождают явно: g.close() или contextlib.closing(g).
Модуль itertools
Бесконечные итераторы, срезы, объединение, группировка, комбинаторика
03
Итераторы
Генераторы
Модуль itertools
Сопрограммы
async и await
Изложение. Третья часть посвящена модулю itertools стандартной библиотеки — набору быстрых ленивых итераторов, из которых собираются конвейеры обработки данных без промежуточных списков.
Бесконечные итераторы
и срезы
Функция Пример Результат
count(start, step) count(0.0, 0.25) 0.0, 0.25, 0.5, …
cycle(it) cycle('AB') A, B, A, B, …
repeat(x, n) repeat(0, 3) 0, 0, 0
islice(it, start, stop, step) islice(count(), 2, 8, 2) 2, 4, 6
takewhile(pred, it) takewhile(lambda t: t < 1, ts) до первого ложного
dropwhile(pred, it) dropwhile(lambda x: x < 3, [1, 4, 2, 5]) 4, 2, 5
compress(it, mask) compress('ABCD', [1, 0, 1, 0]) A, C
filterfalse(pred, it) filterfalse(lambda x: x % 2, range(6)) 0, 2, 4
Пояснение
Функции модуля написаны на C и ленивы: результат — итератор, элемент вычисляется по запросу.
Изложение. Модуль itertools содержит ленивые итераторы, реализованные на C, — базовые средства обработки последовательностей без промежуточных списков. Бесконечные итераторы: count считает от start с шагом step, в том числе для вещественных чисел; cycle повторяет элементы источника по кругу, сохраняя их копию; repeat выдаёт одно значение заданное или бесконечное число раз. Срезы и отбор: islice — аналог среза для любого итератора, без отрицательных индексов, поскольку длина итератора неизвестна; takewhile выдаёт элементы, пока условие истинно, и останавливается на первом ложном; dropwhile пропускает элементы, пока условие истинно, и выдаёт всё остальное; compress отбирает элементы по маске; filterfalse дополняет встроенную filter. Все функции возвращают итераторы, поэтому их результат однопроходный и может быть входом следующей функции.
Для физиков: count(0.0, dt) задаёт моменты времени, но для длинных рядов надёжнее i * dt: count с вещественным шагом накапливает его сложением, и ошибка округления растёт с номером элемента — документация рекомендует в таком случае умножение.
Объединение
и группировка
Функция Что делает
chain(a, b), chain.from_iterable(xs) последовательное соединение источников
zip_longest(a, b, fillvalue=None) соединение до конца самого длинного источника
groupby(it, key) группы соседних элементов с одним ключом
pairwise(it) — 3.10 соседние пары (a, b), (b, c), …
batched(it, n) — 3.12 кортежи по n элементов
accumulate(it, func, initial=x) накопленные суммы, бегущий максимум
starmap(f, pairs) f(*pair) для каждой пары
tee(it, n) n независимых копий одного итератора
Важно
groupby объединяет только соседние элементы: данные сначала сортируют по тому же ключу.
Изложение. Вторая группа функций объединяет и преобразует потоки. chain соединяет несколько источников в один, chain.from_iterable — источники из итерируемого объекта, например из списка списков. zip_longest, в отличие от zip, доходит до конца самого длинного источника, подставляя fillvalue. groupby разбивает поток на группы соседних элементов с одинаковым ключом и выдаёт пары «ключ, итератор группы»; группы образуют только соседние элементы, поэтому для группировки по всему набору данные необходимо сначала отсортировать по тому же ключу. pairwise (Python 3.10) выдаёт соседние пары — основу для разностей и интервалов. batched (Python 3.12) делит поток на кортежи заданной длины, последний может быть короче. accumulate выдаёт накопленные суммы или результаты любой двуместной функции, например бегущий максимум; параметр initial (3.8) задаёт начальное значение. starmap вызывает функцию, распаковывая каждый элемент в аргументы. tee создаёт несколько независимых итераторов из одного и хранит элементы, пока их не прочитают все копии.
Для физиков: pairwise по отметкам времени даёт интервалы между событиями, accumulate — накопленную дозу или интеграл методом прямоугольников, batched — пакеты для записи в файл или отправки по сети.
Демонстрация:
itertools для событий
01
count и islice: сетка с шагом 0,25
02
chain: соединение источников
05
pairwise: интервалы времени
Что наблюдается
Без сортировки BPM02 попал в две группы; после sorted значения собраны по каналам; интервалы — 0,12, 0,19 и 0,08.
root@lab — запись со стенда
Демонстрация. islice(count(0.0, 0.25), 5) даёт [0.0, 0.25, 0.5, 0.75, 1.0]; chain соединяет список, кортеж и строку в [1, 2, 3, 4, 'a', 'b']. Для списка событий из пар «канал, значение» groupby без сортировки выдаёт ключи ['BPM02', 'BPM01', 'BPM02']: первый и последний элементы не соседние и попадают в разные группы. После sorted цикл печатает BPM01 [1.2, 1.4] и BPM02 [0.7, 0.9]. Разности соседних отметок времени [0.00, 0.12, 0.31, 0.39] через pairwise после округления равны [0.12, 0.19, 0.08]. batched(range(7), 3) даёт [(0, 1, 2), (3, 4, 5), (6,)], accumulate с функцией max — бегущий максимум [3, 3, 4, 4, 5].
Комбинаторика
и перебор параметров
Функция Перебирает Вариантов
product(A, B) все пары (a, b) |A|·|B|
product(A, repeat=k) кортежи длины k n**k
permutations(A, k) упорядоченные выборки n!/(n−k)!
combinations(A, k) неупорядоченные выборки C(n, k)
combinations_with_replacement(A, k) выборки с повторением C(n+k−1, k)
for T, B in product(temps, fields): run_simulation(T, B) pairs = combinations(detectors, 2) math.comb(32, 2) # 496 пар
Для физика
product перебирает сетку параметров моделирования без вложенных циклов; combinations даёт пары детекторов для поиска совпадений.
Изложение. Комбинаторные функции заменяют вложенные циклы. product выдаёт декартово произведение источников — все наборы по одному элементу из каждого в лексикографическом порядке, где последний источник меняется быстрее всех; с параметром repeat — произведение источника на самого себя. permutations выдаёт упорядоченные выборки длины k без повторений, combinations — неупорядоченные выборки в порядке следования элементов источника, combinations_with_replacement — выборки с повторениями. Число вариантов растёт быстро; оценить его заранее позволяют функции math.perm и math.comb (Python 3.8). Входные источники product, permutations и combinations читаются целиком и сохраняются в кортежах до выдачи первого результата, поэтому бесконечный итератор передавать в них нельзя.
Для физиков: product перебирает сетку параметров моделирования — температуры и поля, энергии и углы — одним циклом без вложенности; combinations даёт пары детекторов для поиска совпадений: 32 детектора образуют 496 пар.
Демонстрация:
комбинаторика
01
product: сетка 2 × 3 значений параметров
02
combinations: пары детекторов A–D
03
permutations и math.perm
04
math.comb(32, 2) и прямой подсчёт
05
product: чтение входного генератора целиком до первого результата
Что наблюдается
Сетка содержит 6 точек, четыре детектора — 6 пар, 32 детектора — 496. Генератор noisy выдал все три значения ещё при создании product.
root@lab — запись со стенда
Демонстрация. product([1.0, 2.0], [0.1, 0.2, 0.3]) даёт 6 пар, первые три — (1.0, 0.1), (1.0, 0.2) и (1.0, 0.3): последний источник меняется быстрее всех. combinations для детекторов A, B, C и D даёт 6 пар без повторений и без учёта порядка. Число размещений из 5 по 3 равно 60 и совпадает с math.perm(5, 3). math.comb(32, 2) и прямой подсчёт пар дают 496. Генератор noisy печатает каждое выдаваемое значение: уже при создании product(noisy(), 'ab') печатаются «выдано 0», «выдано 1» и «выдано 2» — источник прочитан целиком, — и только затем next(p) возвращает (0, 'a').
Рецепты
и ограничения
from collections import deque from itertools import islice def moving_average(xs, n): it = iter(xs) window = deque(islice(it, n - 1), maxlen=n) for x in it: window.append(x) yield sum(window) / n list(moving_average([1, 2, 3, 4, 5], 3)) # [2.0, 3.0, 4.0]
Для физика
Скользящее среднее сглаживает поток отсчётов: в памяти только n последних значений.
Функция Ограничение
product, combinations чтение входов целиком
tee хранение элементов до чтения всеми копиями
groupby группа — только до перехода к следующей
islice без отрицательных индексов
Пояснение
Рецепты sliding_window, roundrobin, unique_everseen — в документации itertools; готовые реализации — в пакете more-itertools.
Изложение. Документация модуля itertools содержит раздел рецептов — коротких функций, собранных из стандартных блоков: скользящее окно sliding_window, чередование источников roundrobin, удаление повторов unique_everseen и другие; многие из них вошли в сторонний пакет more-itertools. Скользящее среднее строится на deque с ограниченной длиной: очередь хранит последние n отсчётов, а старые вытесняются автоматически. Сумма окна здесь пересчитывается на каждом шаге за O(n); для длинных окон достаточно обновлять сумму вычитанием уходящего и прибавлением нового элемента. Ограничения модуля следуют из ленивости. product и комбинаторные функции сохраняют входы в кортежах; tee хранит элементы, пока их не прочитают все копии, и при большом отставании одной копии расходует память как список. Группа из groupby действительна только до перехода к следующей: если её необходимо сохранить, её сразу превращают в список. islice не поддерживает отрицательные индексы, поскольку длина итератора заранее неизвестна.
Для физиков: скользящее среднее и медиана являются простейшими фильтрами сигнала; для массивов NumPy среднее вычисляют свёрткой numpy.convolve, медиану — функцией scipy.signal.medfilt, а генератор подходит для потока, который не хранится целиком.
Сопрограммы
send, throw и close, yield from как канал, генераторы как менеджеры контекста
04
Итераторы
Генераторы
Модуль itertools
Сопрограммы
async и await
Изложение. Четвёртая часть показывает генератор с другой стороны: он не только выдаёт значения, но и принимает их. Генератор, управляемый методом send, называют сопрограммой (корутиной); на этом механизме построены менеджеры контекста из contextlib и первая версия asyncio.
Метод
send
def stats(): n, mean, m2 = 0, 0.0, 0.0 result = None while True: x = yield result n += 1 d = x - mean mean += d / n m2 += d * (x - mean) result = n, mean, m2 / (n - 1) if n > 1 else 0.0
Для физика
Формулы Уэлфорда: среднее и дисперсия потока измерений без хранения выборки и без вычитания близких больших сумм.
s = stats()
создание сопрограммы
next(s)
запуск до первого yield; результат None
s.send(2.0)
x = 2.0; результат (1, 2.0, 0.0)
s.send(4.0)
x = 4.0; результат (2, 3.0, 2.0)
Изложение. Выражение yield справа от присваивания делает генератор двусторонним: x = yield result выдаёт result наружу, приостанавливает функцию и при возобновлении получает значение, переданное методом send. Вызов next(g) равносилен g.send(None). Перед первым send сопрограмму необходимо запустить вызовом next или send(None), чтобы она дошла до первого yield: отправить значение в только что созданный генератор нельзя — TypeError: can't send non-None value to a just-started generator. Метод send возвращает следующее значение, выданное сопрограммой, что позволяет одним вызовом и передать измерение, и получить обновлённую статистику. Сопрограмма stats вычисляет среднее и дисперсию по формулам Уэлфорда: на каждом шаге обновляются число отсчётов, среднее и сумма квадратов отклонений M2, по которой получим несмещённую оценку дисперсии M2/(n − 1). Состояние хранится в локальных переменных генератора, а не в атрибутах объекта; эквивалентный класс с методом add длиннее, но при сложном состоянии нагляднее.
Для физиков: формулы Уэлфорда численно устойчивы: в отличие от формулы «среднее квадратов минус квадрат среднего», они не вычитают близкие большие числа и не теряют точность при большом постоянном смещении сигнала.
Демонстрация:
сопрограмма статистики
01
Сопрограмма stats: n, среднее, M2
02
send до запуска: TypeError
03
next: запуск до первого yield
04
send(2.0), send(4.0), send(6.0)
Что наблюдается
До первого next сопрограмма значений не принимает; каждый send возвращает число отсчётов, среднее и дисперсию — после трёх значений (3, 4.0, 4.0).
root@lab — запись со стенда
Демонстрация. Сопрограмма stats принимает измерения через send и возвращает кортеж из числа отсчётов, среднего и несмещённой дисперсии. Вызов s.send(2.0) сразу после создания завершается TypeError: can't send non-None value to a just-started generator. next(s) запускает сопрограмму до первого yield и возвращает None, поэтому оболочка ничего не печатает. Отправка 2.0, 4.0 и 6.0 даёт (1, 2.0, 0.0), (2, 3.0, 2.0) и (3, 4.0, 4.0): среднее равно 4, дисперсия — ((−2)² + 0² + 2²)/2 = 4.
Методы
throw и close
def counter(): n = 0 try: while True: try: yield n n += 1 except ValueError: n = 0 finally: print('закрытие, n =', n)
Вызов Что происходит
g.throw(exc) исключение в точке yield; перехваченное — работа до следующего yield
g.close() GeneratorExit в точке yield; выполнение finally и завершение генератора
yield после GeneratorExit RuntimeError: generator ignored GeneratorExit
g.throw(тип, значение, tb) форма с тремя аргументами: устаревшая с 3.12
Пояснение
close() у ещё не запущенного генератора тело не выполняет: finally не срабатывает.
Изложение. Кроме send, у генератора есть ещё два метода управления. throw возбуждает исключение в точке, где генератор приостановлен, — так, будто его возбудила инструкция yield. Если генератор перехватывает исключение, он продолжает работу до следующего yield, и throw возвращает выданное значение; если нет, исключение выходит наружу из вызова throw. В счётчике на слайде ValueError служит командой сброса. close возбуждает в точке остановки GeneratorExit: генератор обязан завершиться — выполнить блоки finally и выйти. Если вместо этого он снова выполнит yield, возникнет RuntimeError: generator ignored GeneratorExit. Поэтому после перехвата GeneratorExit генератор не выполняет yield: исключение возбуждают повторно или завершают генератор оператором return, а для освобождения ресурсов достаточно finally или with. CPython вызывает close и при уничтожении незавершённого генератора, что обеспечивает закрытие файлов и соединений, открытых в теле генератора, и при прерванном обходе. Форма throw с тремя аргументами — тип, значение, трассировка — с Python 3.12 устарела: передаётся один аргумент — экземпляр или класс исключения, как c.throw(ValueError) в демонстрации. close у ещё не запущенного генератора тело не выполняет: блок finally в этом случае не срабатывает.
yield from
как канал
def averager(): total, n = 0.0, 0 while True: x = yield if x is None: return total / n total, n = total + x, n + 1 def collect(results, name): while True: results[name] = yield from averager() results = {} c = collect(results, 'BPM01') next(c) for x in (1.25, 1.5, 1.75): c.send(x) c.send(None) # конец серии results # {'BPM01': 1.5}
collect
передача значения через yield from
averager
x = yield: x = 1.25
c.send(None)
return 1.5 → results['BPM01']
Изложение. yield from соединяет вызывающий код с подгенератором напрямую: значения, выдаваемые подгенератором, проходят наружу, значения send и исключения throw — внутрь, а делегирующий генератор на это время приостановлен и лишь получает итоговый результат. Сопрограмма averager принимает значения одной серии и по сигналу конца серии — значению None — возвращает среднее; делегирующий генератор collect записывает этот результат в словарь и начинает следующую серию. Для вызывающего кода два уровня неразличимы: он отправляет значения в collect, а они попадают в averager. Без yield from было бы необходимо вручную перехватывать StopIteration, передавать send и throw и обрабатывать close — PEP 380 описывает эту логику на нескольких десятках строк. На такой передаче управления по цепочке вложенных генераторов была построена первая версия asyncio: сопрограммы с декоратором @asyncio.coroutine ожидали друг друга через yield from, пока в Python 3.5 не появился await.
Для физиков: серии измерений, разделённые маркером конца серии, обрабатываются одной сопрограммой на серию, а результаты собираются делегирующим генератором.
Демонстрация:
управление генератором
01
Счётчик с try и finally
03
throw(ValueError): сброс в 0
04
close: GeneratorExit и finally
05
yield from: значение return
06
yield from как канал: averager и collect
Что наблюдается
throw сбросил счётчик без завершения генератора; close выполнил finally; через yield from среднее серии попало в results.
root@lab — запись со стенда
Демонстрация. Генератор counter выдаёт 0, 1, 2; c.throw(ValueError) возбуждает исключение в точке yield, генератор перехватывает его, обнуляет счётчик и выдаёт 0 — это значение возвращает сам вызов throw. Следующий next даёт 1. c.close() возбуждает GeneratorExit, блок finally печатает «закрытие, n = 1», и генератор завершается; next после этого возбуждает StopIteration. Генератор outer делегирует работу sub через r = yield from sub(): list(outer()) собирает [1, 2], а outer печатает «подгенератор вернул итог» — значение return подгенератора стало значением выражения yield from. Затем averager и collect со слайда о канале: после next(c) значения 1.25, 1.5 и 1.75 отправляются в collect, но попадают в averager; c.send(None) завершает серию, и results содержит {'BPM01': 1.5}.
Генератор как
менеджер контекста
import contextlib, time @contextlib.contextmanager def timer(label): t0 = time.perf_counter() try: yield finally: dt = time.perf_counter() - t0 print(f'{label}: {dt:.2f} с') with timer('подгонка'): fit(data)
код до yield
__enter__: вход в блок with
yield значение
значение для as
finally после yield
__exit__: выход, в том числе по ошибке
Важно
Исключение из блока with возникает в точке yield: без try/finally код после yield не выполнится.
Изложение. Декоратор contextlib.contextmanager превращает генератор с одним yield в менеджер контекста. Код до yield выполняется при входе в блок with и играет роль __enter__; значение yield связывается с переменной после as; код после yield выполняется при выходе из блока и играет роль __exit__. Если в блоке with возникает исключение, оно возбуждается в генераторе в точке yield, поэтому завершающий код необходимо поместить в finally — иначе при ошибке он не выполнится. Исключение, перехваченное в генераторе и не возбуждённое повторно, считается обработанным. Такой менеджер короче класса с методами __enter__ и __exit__ и подходит для однократных действий вокруг блока: замер времени, временная смена каталога или настроек, захват и освобождение ресурса. В contextlib есть и готовые менеджеры: suppress подавляет указанные исключения, chdir (3.11) временно меняет рабочий каталог, closing вызывает close по выходе, ExitStack собирает переменное число менеджеров. Менеджер из contextmanager применяется и как декоратор функции.
Для физиков: timer вокруг этапов обработки — чтения, подгонки параметров, построения графиков — позволяет установить, где программа тратит время, без запуска профилировщика.
Демонстрация:
менеджер контекста
01
Менеджер timer через contextmanager
02
Блок без ошибок: время суммирования
03
Блок с ZeroDivisionError: выполнение finally и выход исключения наружу
Что наблюдается
Время напечатано в обоих случаях; ZeroDivisionError прошла через генератор и вышла из блока with.
root@lab — запись со стенда
Демонстрация. Менеджер timer объявляется через contextlib.contextmanager: до yield запоминается время, в finally печатается прошедшее. Блок с суммой квадратов миллиона чисел печатает «сумма: 0.04 с» — значение зависит от машины. Блок с делением на ноль печатает «деление: 0.00 с», после чего исключение ZeroDivisionError выходит из блока with: генератор получил исключение в точке yield, выполнил finally и не перехватил его.
async и await
Ожидание в одном потоке, цикл событий, нативные сопрограммы, задачи и тайм-ауты, асинхронная итерация
05
Итераторы
Генераторы
Модуль itertools
Сопрограммы
async и await
Изложение. Пятая часть связывает генераторы с асинхронным программированием: функция, способная приостановиться, позволяет одному потоку переключаться между задачами, пока они ожидают. Устройство цикла событий подробно разобрано в главе книги «Асинхронность».
Ожидание
вместо вычисления
последовательно: 1,6 с
одновременно: 0,8 с
Тип задачи Узкое место Средство
I/O-bound: сеть, приборы ожидание ответа asyncio, потоки
чтение и запись файлов диск потоки, asyncio.to_thread
CPU-bound: расчёт процессор процессы, NumPy, numba
Важно
Асинхронность ускоряет ожидание, а не вычисление: поток один, и долгий расчёт останавливает все задачи.
Изложение. Программа, опрашивающая несколько приборов или скачивающая файлы, большую часть времени ожидает: запрос отправлен, ответа нет, процессор простаивает. При последовательном опросе задержки складываются: три прибора с задержками 0,3, 0,5 и 0,8 с опрашиваются за 1,6 с. Если ожидания перекрываются, общее время равно наибольшей задержке — 0,8 с. Перекрыть ожидания можно потоками, но поток является дорогим ресурсом: память под стек и переключения контекста операционной системой, а при тысячах соединений их число становится ограничением. Асинхронность позволяет решить задачу в рамках одного потока: задача, дошедшая до ожидания, добровольно отдаёт управление, и планировщик переключается на задачу, готовую продолжить. Для расчётов этот подход бесполезен: поток один, и задача, занявшая процессор, останавливает все остальные. Вычислительные задачи распределяют по процессам, векторизуют средствами NumPy или компилируют — им посвящены главы раздела «Ускорение расчётов».
Для физиков: опрос приборов и датчиков по сети, обращение к базе данных эксперимента, загрузка файлов с сервера — задачи ожидания; численное моделирование и обработка массивов — вычислительные.
Цикл событий
на генераторах
from collections import deque def task(name, n): for i in range(n): print(name, 'шаг', i) yield def run(tasks): queue = deque(tasks) while queue: t = queue.popleft() try: next(t) except StopIteration: continue queue.append(t)
deque([A, B])
очередь готовых задач
next(A)
шаг задачи A до yield
queue.append(A)
A в конце: deque([B, A])
StopIteration
удаление задачи из очереди
Пояснение
Задачи переключаются только в точках yield: многозадачность кооперативная.
Изложение. Планировщик, переключающийся между задачами, записывается в десяток строк, если задачи являются генераторами. Задача выполняет шаг работы и отдаёт управление инструкцией yield. Планировщик хранит очередь готовых задач, берёт первую, продвигает её вызовом next до следующего yield и ставит в конец очереди; завершившаяся задача возбуждает StopIteration и из очереди удаляется. Две задачи чередуются, хотя поток один: переключение происходит только в точках yield, поэтому такая многозадачность называется кооперативной — задача, не дошедшая до yield, удерживает поток. В цикле событий asyncio задача, ожидающая данных, не возвращается в очередь сразу: она регистрирует ожидание у селектора операционной системы и снова становится готовой, когда данные пришли или истёк тайм-аут. Полную реализацию такого планировщика на генераторах по Д. Бизли разбирает глава книги «Асинхронность»; asyncio устроен по тому же принципу, но вместо генераторов использует нативные сопрограммы и объекты Future.
Демонстрация:
планировщик задач
01
Задача-генератор: yield после каждого шага
02
Планировщик: очередь deque и next по кругу
03
Две задачи с тремя и двумя шагами
04
Удаление завершённой задачи из очереди
Что наблюдается
Шаги приборов A и B чередуются в одном потоке; после двух шагов B в очереди остаётся только A.
root@lab — запись со стенда
Демонстрация. Генератор task печатает номер шага и отдаёт управление пустым yield. Функция run хранит задачи в deque, продвигает первую вызовом next и возвращает её в конец очереди, а завершившуюся отбрасывает. Запуск с задачами «прибор A» на три шага и «прибор B» на два печатает «прибор A шаг 0», «прибор B шаг 0», «прибор A шаг 1», «прибор B шаг 1» и «прибор A шаг 2»: задачи чередуются, хотя дополнительных потоков и процессов нет.
async def
и
await
import asyncio async def poll(name, delay): await asyncio.sleep(delay) # ожидание return name, delay async def main(): return await poll('A', 0.3) asyncio.run(main()) # ('A', 0.3)
Важно
Вызов poll(...) без await лишь создаёт объект-сопрограмму: RuntimeWarning: coroutine 'poll' was never awaited.
Генераторная сопрограмма Нативная: 3.5, PEP 492
def и yield async def
yield from future await future
@asyncio.coroutine — удалён в 3.11 без декоратора
loop.run_until_complete(main()) asyncio.run(main()) — 3.7
Изложение. До Python 3.5 сопрограммы asyncio были генераторами: они ожидали друг друга через yield from и помечались декоратором @asyncio.coroutine. Генератор и сопрограмма при этом выглядели одинаково, и ошибку — забытый yield from или генератор, переданный вместо сопрограммы, — было трудно заметить. PEP 492 ввёл нативные сопрограммы: функция, объявленная через async def, при вызове возвращает объект-сопрограмму, а await приостанавливает её до завершения ожидаемого объекта — другой сопрограммы, задачи или Future. await допустим только внутри async def, а объект-сопрограмму нельзя перебрать циклом for. Декоратор @asyncio.coroutine удалён в Python 3.11. Сопрограммы запускает цикл событий; для программы достаточно одного вызова asyncio.run(main()), который создаёт цикл, выполняет сопрограмму до конца и закрывает цикл. Вызов poll('A', 0.3) без await ничего не выполняет: создаётся объект, а при его уничтожении выводится предупреждение RuntimeWarning: coroutine 'poll' was never awaited — признак забытого await.
Задачи
и тайм-ауты
asyncio Назначение
run(main()) цикл событий для программы
await gather(a, b, c) одновременное ожидание; результаты по порядку
create_task(c) запуск сопрограммы в фоне
TaskGroup() — 3.11 группа задач; отмена остальных при ошибке одной
timeout(s) — 3.11 ограничение времени: TimeoutError
to_thread(f, ...) — 3.9 блокирующая функция в отдельном потоке
DEVICES = {'A': 0.3, 'B': 0.5, 'C': 0.8} async def main(): async with asyncio.timeout(2.0): async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(poll(n, d)) for n, d in DEVICES.items()] return [t.result() for t in tasks]
Для физика
Общее время опроса — время самого медленного прибора, а не сумма задержек.
Изложение. Для одновременного выполнения сопрограмм необходимо обернуть каждую в задачу (Task): await a(); await b() выполняет их по очереди. asyncio.gather запускает переданные сопрограммы как задачи и ожидает все, возвращая результаты в порядке аргументов. create_task ставит сопрограмму в цикл событий и возвращает объект Task, который можно ожидать позже; ссылку на задачу сохраняют, иначе её может уничтожить сборщик мусора. С Python 3.11 предпочтителен TaskGroup: задачи, созданные в блоке async with, ожидаются при выходе из блока, а ошибка одной из них отменяет остальные и выходит наружу как ExceptionGroup; таким образом, после выхода из блока незавершённых задач не остаётся. asyncio.timeout ограничивает время блока: при превышении отменяется текущая задача, а вместе с ней — задачи, которые она ожидает через gather или TaskGroup, и блок завершается TimeoutError. Вызов asyncio.to_thread позволяет выполнить блокирующую функцию из синхронной библиотеки в отдельном потоке, не останавливая цикл событий.
Для физиков: опрос нескольких приборов с общим тайм-аутом — типичная задача управления установкой; при отказе одного прибора TaskGroup отменяет остальные запросы и сообщает об ошибке.
Демонстрация:
одновременное ожидание
01
Сопрограмма poll с await asyncio.sleep
02
Вариант blocking с time.sleep
03
gather трёх опросов poll
04
Тот же gather с blocking
05
Тайм-аут 0,6 с на опросы A и C
06
Сопрограмма без await: предупреждение при удалении
Что наблюдается
С await задержки перекрываются, и общее время равно наибольшей — 0,8 с; time.sleep блокирует цикл событий, и задержки складываются в 1,6 с.
root@lab — запись со стенда
Демонстрация. Сопрограмма poll имитирует опрос прибора: ожидает delay секунд через await asyncio.sleep и возвращает имя и задержку. blocking делает то же через time.sleep. Сопрограмма main принимает функцию опроса, запускает три опроса с задержками 0,3, 0,5 и 0,8 с через asyncio.gather и печатает результаты и затраченное время. С poll результат [('A', 0.3), ('B', 0.5), ('C', 0.8)] получен за 0,8 с: ожидания перекрылись. С blocking тот же результат получен за 1,61 с: time.sleep не отдаёт управление циклу событий, и опросы выполнились по очереди. Сопрограмма limited ожидает опросы A и C внутри asyncio.timeout(0.6): через 0,6 с ожидание отменяется, и перехваченная TimeoutError превращается в строку «тайм-аут 0,6 с». Объект c = poll('A', 0.3) имеет тип coroutine; при его удалении без await выводится RuntimeWarning: coroutine 'poll' was never awaited.
Асинхронная
итерация
Синхронно Асинхронно
__iter__, __next__ __aiter__, __anext__
StopIteration StopAsyncIteration
for, with async for, async with
iter(), next() aiter(), anext() — 3.10
def с yield async def с yield — 3.6
async def readings(dt, n): for i in range(n): await asyncio.sleep(dt) yield round((i + 1) * dt, 1) async def consume(): async for t in readings(0.1, 3): print('отсчёт', t) asyncio.run(consume())
Пояснение
Пока источник ожидает следующего элемента (await внутри __anext__), цикл событий выполняет другие задачи; источник без await управление не отдаёт.
Изложение. Протокол итерации имеет асинхронный вариант для источников, следующий элемент которых приходится ожидать: сообщений из сети, отсчётов прибора, строк из базы данных. Асинхронный итерируемый объект определяет __aiter__, асинхронный итератор — __anext__, возвращающий ожидаемый объект, а окончание сообщается исключением StopAsyncIteration. Цикл async for вызывает эти методы и ожидает результат __anext__; если источник при этом ожидает данных, цикл событий тем временем выполняет другие задачи, а источник без await управление не отдаёт. Асинхронный генератор (PEP 525, Python 3.6) представляет собой функцию async def с yield и реализует протокол автоматически, как обычный генератор реализует синхронный. Аналогично async with вызывает асинхронные методы __aenter__ и __aexit__, а встроенные функции aiter и anext (3.10) — асинхронные аналоги iter и next. Асинхронное включение [x async for x in source] собирает элементы асинхронного источника внутри async def.
Для физиков: поток отсчётов прибора, поступающих с заданным интервалом, описывается асинхронным генератором, а потребитель обрабатывает отсчёты по мере поступления, не блокируя опрос других устройств.
Демонстрация:
TaskGroup и async for
01
TaskGroup: опрос A и отказ прибора B
02
except* ConnectionError: список ошибок
03
Отмена опроса A после ошибки B
04
Асинхронный генератор отсчётов с интервалом 0,1 с
05
async for: три отсчёта по мере поступления
Что наблюдается
Ошибка B отменила опрос A и пришла в ExceptionGroup; async for получил отсчёты 0.1, 0.2 и 0.3.
root@lab — запись со стенда
Демонстрация. Сопрограмма survey создаёт в asyncio.TaskGroup две задачи: опрос A на 0,3 с и опрос B, который через 0,1 с возбуждает ConnectionError. Ошибка B отменяет незавершённый опрос A, а при выходе из блока группа возбуждает ExceptionGroup; конструкция except* ConnectionError (3.11) получает его часть и печатает «ошибки: ['B: нет ответа']», затем a.cancelled() даёт True. Асинхронный генератор readings выдаёт отсчёты с интервалом dt, ожидая через await asyncio.sleep; сопрограмма consume перебирает его циклом async for и печатает «отсчёт 0.1», «отсчёт 0.2» и «отсчёт 0.3».
Выбор
средства
Задача Средство
перебор данных по одному элементу итератор, генератор
конвейер обработки потока цепочка генераторов, itertools
состояние, меняющееся по входным данным сопрограмма с send или класс
действия до и после блока contextlib.contextmanager
ожидание сети и приборов asyncio: gather, TaskGroup, timeout
расчёт на всех ядрах multiprocessing, NumPy, numba
блокирующие вызовы и файлы в асинхронном коде asyncio.to_thread
Важно
Блокирующие вызовы в асинхронном коде недопустимы: time.sleep или синхронный запрос останавливает весь цикл событий.
Изложение. Таким образом, рассмотренные в лекции механизмы различаются назначением. Итераторы и генераторы являются средством последовательной обработки данных по одному элементу: файлов, потоков событий, бесконечных последовательностей. Конвейеры генераторов и функции itertools собирают обработку из независимых стадий. Сопрограмма с send хранит состояние, меняющееся по входным данным, — счётчик, статистику, конечный автомат; если состояние сложное или нужен доступ к нему извне, класс нагляднее. contextmanager описывает действия до и после блока. asyncio предназначен для одновременного ожидания многих операций ввода-вывода в одном потоке, а не для ускорения расчётов: вычисления распределяют по процессам, векторизуют или компилируют. Главное правило асинхронного кода — отсутствие блокирующих вызовов: одна функция, надолго занявшая поток, останавливает все задачи цикла событий; такие вызовы выносят в поток через to_thread или в процесс через run_in_executor.
Итоги
лекции
01
Цикл for вызывает iter и next до StopIteration; итератор однопроходный, итерируемый объект выдаёт новый итератор на каждый проход.
02
Функция с yield возвращает генератор: тело выполняется по шагам, а состояние хранится в кадре между вызовами.
03
Генераторные выражения и конвейеры обрабатывают данные по одному элементу с постоянным расходом памяти.
04
itertools даёт ленивые бесконечные итераторы, срезы, группировку и комбинаторику без промежуточных списков.
05
send, throw и close превращают генератор в сопрограмму; yield from соединяет её с подгенератором.
06
async def и await — нативные сопрограммы: цикл событий переключает задачи, пока они ожидают.
Изложение. Цикл for получает итератор функцией iter и вызывает next, пока тот не возбудит StopIteration; итерируемый объект выдаёт новый итератор на каждый проход, а сам итератор однопроходный. Функция с yield возвращает генератор, выполняемый по шагам: между вызовами локальные переменные и место остановки хранятся в кадре, а return завершает генератор и передаёт значение в StopIteration. Генераторные выражения и конвейеры генераторов обрабатывают данные по одному элементу, поэтому расход памяти не зависит от объёма данных. Модуль itertools даёт ленивые бесконечные итераторы, срезы, объединение, группировку и комбинаторику. Методы send, throw и close превращают генератор в сопрограмму, а yield from соединяет её с подгенератором и возвращает его результат. Нативные сопрограммы async def и await выполняет цикл событий, переключая задачи в точках ожидания.
Лекцией завершается часть курса о языке Python; следующая часть посвящена алгоритмам и структурам данных.
Упражнения для самопроверки
01
Класс Linspace с методом __iter__ в виде генератора; два прохода и сравнение последней точки при накоплении шага и при a + i·h для миллиона точек
02
Конвейер для журнала событий: чтение, отбор по каналу, пересчёт в физические единицы и статистика Уэлфорда через send
03
Скользящее среднее и скользящая медиана на deque; проверка на синусоиде с шумом
04
Генераторы шагов осциллятора по явной и полунеявной схемам Эйлера; период по пересечениям нуля через pairwise и рост энергии
05
Асинхронный опрос пяти имитированных приборов: TaskGroup, общий тайм-аут, отказ одного прибора и обработка ExceptionGroup
Главы книги: «Итераторы, генераторы и корутины», «Асинхронность» · упражнения для самопроверки, без сдачи
Изложение. Отдельного задания к лекции нет: упражнения служат самопроверке и опираются на главы книги «Итераторы, генераторы и корутины» и «Асинхронность». Первое проверяет итерируемый объект с методом __iter__ в виде генератора и накопление ошибок округления при вычислении точек сетки сложением. Второе собирает конвейер генераторов с сопрограммой статистики. Третье — скользящие фильтры на очереди фиксированной длины. Четвёртое сравнивает схемы интегрирования: у явной схемы Эйлера величина x² + v² осциллятора с ω = 1 на каждом шаге умножается на 1 + dt², у полунеявной остаётся ограниченной, а период по пересечениям нуля близок к 2π. Пятое — асинхронный опрос с тайм-аутом и обработкой отказа: ExceptionGroup разбирается конструкцией except* (Python 3.11).
Источники
6
PEP 255, 342, 380, 479, 492, 525 — генераторы, сопрограммы, yield from, StopIteration, async и await, асинхронные генераторы
peps.python.org
8
Ramalho L. Fluent Python. – 2nd ed. – O'Reilly, 2022
Изложение. Основой лекции служат главы книги курса «Итераторы, генераторы и корутины» и «Асинхронность»; порядок изложения и часть примеров следуют лекциям курса «Python» Computer Science Center. Для самостоятельного изучения рекомендуются документация модулей itertools и asyncio, перечисленные PEP, курс Д. Бизли о сопрограммах, в котором планировщик задач строится на генераторах, и книга Л. Рамальо «Fluent Python», где итераторам, генераторам, сопрограммам и asyncio посвящены отдельные главы.
Спасибо за внимание
Вопросы
phys-dev.github.io/soft-dev-book
Изложение. Надпись на последнем слайде — await, ключевое слово, в точке которого нативная сопрограмма ожидает результат и, если он ещё не готов, отдаёт управление циклу событий. Лекция завершается ответами на вопросы.