ЗАМЕР
bench/iteration/async_iteration.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/iterators-and-generators
- Как запустить
Времени нет ни в одном файле: смотрим на значения, тип ошибки и порядок событий. ## Версии | скрипт | 3.11.15 | 3.12.3 | 3.13.7 | 3.14.7 | | --- | --- | --- | --- | --- | | `one_shot_types.py` | \=\= | \=\= | \=\= | \=\= | | `invalidation.py` | \=\= | \=\= | \=\= | \=\= | | `async_iteration.py` | \=\= | \=\= | \=\= | \=\= | | `async_comprehension.py` | отдельный `<listcomp>`, опкоды `GET_AWAITABLE, SEND` | **встроено**, `END_ASYNC_FOR, END_SEND, SEND` | то же | то же | Единственное расхождение по версиям — во включениях, и это не про асинхронность, а про PEP 709 (встраивание включений в 3.12), уже разобранный в `bench/comprehensions/`. Асинхронное включение встроилось вместе с остальными. ## Одноразовые типы
Запись прогона
Замеры: одноразовость, порча итератора и асинхронный обход
Четыре сюжета, которых нет в bench/iterators/ и bench/comprehensions/:
конкретные одноразовые типы стандартной библиотеки, изменение контейнера во
время обхода, асинхронный протокол итерации и асинхронные включения.
| скрипт | что показывает | статья |
|---|---|---|
one_shot_types.py |
iter(x) is x на шестнадцати объектах; второй проход по map/filter/zip/файлу пуст |
итераторы |
invalidation.py |
RuntimeError у dict и set; у списка сторожа нет и элементы пропускаются молча |
итераторы |
async_iteration.py |
__aiter__/__anext__, async for, асинхронный генератор, StopAsyncIteration |
итераторы |
async_comprehension.py |
[x async for x in ...]: где приостановка, что с областью видимости, когда внешнее включение становится асинхронным |
включения |
Запуск:
for v in 3.11 3.12 3.13 3.14; do
echo "== $v"; python$v bench/iteration/invalidation.py
done
Времени нет ни в одном файле: смотрим на значения, тип ошибки и порядок событий.
Версии
| скрипт | 3.11.15 | 3.12.3 | 3.13.7 | 3.14.7 |
|---|---|---|---|---|
one_shot_types.py |
== | == | == | == |
invalidation.py |
== | == | == | == |
async_iteration.py |
== | == | == | == |
async_comprehension.py |
отдельный <listcomp>, опкоды GET_AWAITABLE, SEND |
встроено, END_ASYNC_FOR, END_SEND, SEND |
то же | то же |
Единственное расхождение по версиям — во включениях, и это не про асинхронность,
а про PEP 709 (встраивание включений в 3.12), уже разобранный в
bench/comprehensions/. Асинхронное включение встроилось вместе с остальными.
Одноразовые типы
$ python3.13 one_shot_types.py
PY 3.13.7
1) iter(x) is x — «объект сам себе итератор»:
тип iter(x) is x __next__?
list False False
dict False False
set False False
str False False
range False False
dict.keys() False False
map True True
filter True True
zip True True
enumerate True True
reversed True True
генератор True True
itertools.chain True True
open(...) True True
io.StringIO True True
list_iterator True True
2) map по списку [1, 2, 3]:
первый list(m): ['1', '2', '3']
второй list(m): []
3) zip:
первый list(z): [(1, 'a'), (2, 'b'), (3, 'c')]
второй list(z): []
4) filter:
первый list(fl): [2, 3]
второй list(fl): []
5) файловый объект:
iter(fh) is fh: True
первый проход: ['а', 'б', 'в']
второй проход: []
fh.tell(): 9 — курсор в конце, поэтому пусто
после fh.seek(0): ['а', 'б', 'в']
6) контроль — список:
первый list(src): [1, 2, 3]
второй list(src): [1, 2, 3]
iter(src) is iter(src): False — каждый раз НОВЫЙ итератор
7) частичное потребление одного и того же map:
islice(m2, 2): [1, 2]
list(m2) после: [3, 4] — начало уже съедено
8) sum и len по одному и тому же map:
sum(values) = 60
len(list(values)) = 0 -> среднее посчитать нечем
total / count -> ZeroDivisionError: division by zero
9) тот же код после list(...):
sum = 60 | len = 3 | среднее = 20.0
Строки, ради которых написан файл: пункт 8 — это единственное место, где
одноразовость даёт исключение, и то не то, которое подсказало бы причину
(ZeroDivisionError, а не что-нибудь про итератор). Во всех остальных случаях
второй проход просто пуст.
Пункт 5 отдельно: у файла одноразовость обратима — seek(0) возвращает
поток к началу. У map/filter/zip такого рычага нет.
Первоисточник — Doc/library/stdtypes.rst, строки 930–936 (тег v3.14.5):
iterator.__iter__()— Return the iterator object itself. This is required to allow both containers and iterators to be used with theforandinstatements.
Перевод: «Возвращает сам объект-итератор. Это требуется, чтобы и контейнеры,
и итераторы можно было использовать в инструкциях for и in.» Отсюда и
признак iter(x) is x.
Что перечисленные функции возвращают именно итератор — Doc/library/functions.rst
(тег v3.14.5):
- строка 1209,
map: «Return an iterator that applies function to every item of iterable, yielding the results.» — «Возвращает итератор, применяющий function к каждому элементу iterable и выдающий результаты.» - строка 734,
filter: «Construct an iterator from those elements of iterable for which function is true.» — «Строит итератор из тех элементов iterable, для которых function истинна.» - строка 2138,
zip: «More formally:zip()returns an iterator of tuples, where the i-th tuple contains the i-th element from each of the argument iterables.» — «Более формально:zip()возвращает итератор кортежей, где i-й кортеж содержит i-й элемент каждого из переданных итерируемых объектов.»
Файловый объект — Doc/library/io.rst, строка 342:
IOBase(and its subclasses) supports the iterator protocol, meaning that anIOBaseobject can be iterated over yielding the lines in a stream.
Перевод: «IOBase (и его подклассы) поддерживает протокол итератора: это
значит, что объект IOBase можно обходить, получая строки потока.»
Порча итератора
$ python3.13 invalidation.py
PY 3.13.7
1) добавление в dict во время обхода -> RuntimeError: dictionary changed size during iteration
2) удаление из dict во время обхода -> RuntimeError: dictionary changed size during iteration
сколько успело удалиться до ошибки: {'b': 2, 'c': 3}
3) замена значений во время обхода: ошибки нет, обход полный: ['a', 'b', 'c']
словарь: {'a': 99, 'b': 99, 'c': 99}
4) удаление + вставка (размер не изменился): ошибки нет
что обошли: ['a', 'b', 'z'] | словарь: {'a': 1, 'b': 2, 'z': 0}
сторож сравнивает di_used с ma_used — это ДЛИНА, не версия содержимого
5) добавление в set во время обхода -> RuntimeError: Set changed size during iteration
6) удаление из списка внутри for по этому же списку:
посетили: ['a', 'c', 'e']
осталось: ['b', 'd', 'e']
ни одного исключения; 'b' и 'd' не были посещены вовсе
7) шаг за шагом:
next(it) -> a | индекс итератора теперь 1
после xs.remove('a') список стал ['b', 'c']
next(it) -> c — это 'c', потому что индекс 1 указывает уже на него
'b' пропущен
8) обход по копии list(xs): ['d', 'e']
пересборка включением: ['d', 'e']
для dict — обход по list(d): {'b': 2}
9) первый next после изменения -> RuntimeError: dictionary changed size during iteration
размер вернули; следующий next -> RuntimeError: dictionary changed size during iteration
состояние итератора «залипает»: в исходнике di_used = -1 помечен как sticky
Четыре наблюдения, которые стоит держать вместе.
Пункт 4 — самый неожиданный. Сторож словаря сравнивает длину, а не
содержимое. Удалили 'c', добавили 'z' — длина та же, исключения нет, 'c'
не посещён, 'z' посещён. То есть RuntimeError — не гарантия обнаружения
порчи, а частный случай.
Пункт 3 — та же мысль с другой стороны: замена значений размер не меняет и проходит целиком законно.
Пункт 6 и 7 — у списка сторожа нет вовсе. Итератор списка хранит индекс;
remove сдвигает хвост, индекс остаётся на месте, элемент пропускается. Из
пяти элементов посещены три, а исключения нет ни одного.
Пункт 9 — после срабатывания сторож больше не отключается: даже если вернуть
размер, следующий next снова даст RuntimeError.
Первоисточник — Objects/dictobject.c (тег v3.14.5), функция
dictiter_iternextkey_lock_held, строки 5237–5242:
if (di->di_used != d->ma_used) {
PyErr_SetString(PyExc_RuntimeError,
"dictionary changed size during iteration");
di->di_used = -1; /* Make this state sticky */
return NULL;
}
Перевод комментария: «Сделать это состояние залипающим» (пункт 9 замера). Та же
проверка повторяется в dictiter_iternextvalue_lock_held (5361), в
dictiter_iternextitem_lock_held (5483), в dictiter_iternext_threadsafe
(5588) и в dictreviter_iter_lock_held (5783). Условие всюду одно и то же —
di_used != ma_used, то есть сравнение длин, что и объясняет пункт 4.
Рекомендация же записана не в справочнике, а в учебнике —
Doc/tutorial/controlflow.rst, строки 72–75 (тег v3.14.5):
Code that modifies a collection while iterating over that same collection can be tricky to get right. Instead, it is usually more straight-forward to loop over a copy of the collection or to create a new collection.
Перевод: «Код, изменяющий коллекцию во время обхода этой же коллекции, бывает непросто написать правильно. Обычно проще обойти копию коллекции или создать новую коллекцию.» Пункт 8 замера — оба названных способа.
Отдельно: правила «for по списку использует внутренний счётчик» в
справочнике языка нет. Раздел .. _for: в Doc/reference/compound_stmts.rst
(строки 139–196) говорит только, что для итерируемого объекта создаётся
итератор и элементы берутся до исчерпания; абзаца про счётчик и изменение
последовательности там нет ни на теге v3.14.5, ни на v3.13.7, v3.12.3,
v3.11.15 — проверено поиском по всем четырём. Поведение из пунктов 6 и 7 —
свойство реализации итератора списка, а не записанное правило языка, и в тексте
урока его надо называть именно так.
Асинхронный обход
Вывод async_iteration.py одинаков на всех четырёх версиях:
$ python3.13 async_iteration.py
PY 3.13.7
1) признаки объекта:
a.__aiter__() is a : True
inspect.isasyncgen(agen(3)) : True
у AsyncRange есть __iter__ : False
у AsyncRange есть __next__ : False
2) async for по AsyncRange(3): [1, 2, 3]
async for по агенератору : [1, 2, 3]
3) ручные вызовы __anext__:
await it.__anext__() -> 1
await it.__anext__() -> StopAsyncIteration (а не StopIteration)
4) обычный for над AsyncRange -> TypeError: 'AsyncRange' object is not iterable
list(агенератор) -> TypeError: 'async_generator' object is not iterable
5) [v async for v in AsyncRange(3)] -> [1, 2, 3]
{v: v*2 async for v in agen(3)} -> {1: 2, 2: 4, 3: 6}
6) два прохода по одному агенератору: [1, 2, 3] []
7) чередование потребителя и фоновой задачи:
['фон-0', 'получено-1', 'фон-1', 'получено-2', 'фон-2', 'получено-3', 'фон-3']
каждый шаг __anext__ — это точка, где цикл событий может переключиться
8) выход по break из async for: finally ещё не выполнен
после await g2.aclose(): ['finally выполнен']
у асинхронного генератора уборка требует явного aclose или
асинхронного финализатора цикла событий
Пункт 6 — мост к первой части файла: асинхронный генератор одноразов ровно так
же, как обычный, и второй проход даёт [] без всякой ошибки.
Пункт 4 — граница, на которой ломаются привычные инструменты: list, sum,
sorted над асинхронным итератором не работают вовсе.
Первоисточник — Doc/reference/datamodel.rst, строки 3898–3945 (тег v3.14.5):
object.__aiter__(self)— Must return an asynchronous iterator object.object.__anext__(self)— Must return an awaitable resulting in a next value of the iterator. Should raise aStopAsyncIterationerror when the iteration is over.
Перевод: «object.__aiter__(self) — должен вернуть объект асинхронного
итератора. object.__anext__(self) — должен вернуть ожидаемый объект, дающий
следующее значение итератора. Должен возбудить StopAsyncIteration, когда
итерация закончена.»
Там же, versionchanged:: 3.7:
Prior to Python 3.7,
__aiter__could return an awaitable that would resolve to an asynchronous iterator. Starting with Python 3.7,__aiter__must return an asynchronous iterator object. Returning anything else will result in aTypeErrorerror.
Перевод: «До Python 3.7 __aiter__ мог вернуть ожидаемый объект, дающий
асинхронный итератор. Начиная с Python 3.7 __aiter__ обязан вернуть объект
асинхронного итератора. Возврат чего-либо иного приведёт к ошибке TypeError.»
Поэтому в замере __aiter__ — обычный def, а не async def.
Перевод инструкции — Doc/reference/compound_stmts.rst, строки 1547–1586:
iter = (ITER).__aiter__()
running = True
while running:
try:
TARGET = await iter.__anext__()
except StopAsyncIteration:
running = False
else:
SUITE
else:
SUITE2
Асинхронные включения
$ python3.11 async_comprehension.py
PY 3.11.15
1) результат: [0, 1, 2]
порядок событий:
включение началось
src-выдал-0
фон-0
src-выдал-1
фон-1
src-выдал-2
фон-2
включение кончилось
фон-3
фон-4
фоновые записи попали МЕЖДУ шагами включения — оно приостанавливалось
2) [await slow(x) async for x in asrc(3)] -> [0, 10, 20]
порядок: ['s-выдал-0', 'await-0', 's-выдал-1', 'await-1', 's-выдал-2', 'await-2']
await в выражении и async for в клаузе чередуются шаг за шагом
3) после включения внешнее x = 'внешнее' | значения: [0, 1]
имя цели живёт в неявном вложенном пространстве имён, как у обычного
4) [await slow(v) for v in (1, 2)] -> [10, 20]
порядок: ['await-1', 'await-2']
async for нет, но await есть — включение всё равно асинхронное
5) [[y async for y in ...] for _ in range(2)] -> [[0, 1], [0, 1]]
внешнее включение выглядит обычным, но внутри асинхронное —
справочник: «Outer comprehensions implicitly become asynchronous»
6) (x async for x in ...) — это: async_generator
inspect.isasyncgen: True
собрать его можно только через async for: [0, 1, 2]
7) флаги кода функции с асинхронным включением:
CO_COROUTINE : True
CO_ASYNC_GENERATOR: False
тело включения компилируется в отдельный объект кода: ['<listcomp>']
8) асинхронные инструкции в holder: ['GET_AWAITABLE', 'SEND']
у синхронного аналога: нет
9) компиляция [x async for x in src] вне async def -> SyntaxError: asynchronous comprehension outside of an asynchronous function
На 3.12.3, 3.13.7 и 3.14.7 отличаются ровно две строки:
7) ...
тело включения компилируется в отдельный объект кода: включение встроено (inlining, 3.12+)
8) асинхронные инструкции в holder: ['END_ASYNC_FOR', 'END_SEND', 'SEND']
Что читать. Пункт 1 отвечает на вопрос «где выполняется await»: внутри
той же корутины, и цикл событий действительно переключается между шагами —
фоновые записи стоят между шагами включения, а не до и не после.
Пункт 3 отвечает на вопрос про область видимости: имя цели не утекает, как
и у обычного включения; асинхронность этого не меняет. Пункт 4 — включение
становится асинхронным и без async for, одного await в выражении достаточно.
Пункт 5 — внешнее включение, выглядящее совершенно обычным, тоже становится
асинхронным. Пункт 7 — функция при этом остаётся корутинной
(CO_COROUTINE), а не превращается в асинхронный генератор.
Первоисточник — Doc/reference/expressions.rst, строки 396–422 (тег v3.14.5):
If a comprehension contains
async forclauses, or if it containsawaitexpressions or other asynchronous comprehensions anywhere except the iterable expression in the leftmostforclause, it is called an asynchronous comprehension. An asynchronous comprehension may suspend the execution of the coroutine function in which it appears.
Перевод: «Если включение содержит предложения async for либо содержит
выражения await или другие асинхронные включения где угодно, кроме выражения
итерируемого объекта в самом левом предложении for, оно называется
асинхронным включением. Асинхронное включение может приостанавливать
выполнение корутинной функции, в которой оно записано.» — Пункты 1 и 4 замера.
Там же, versionchanged:: 3.11:
Asynchronous comprehensions are now allowed inside comprehensions in asynchronous functions. Outer comprehensions implicitly become asynchronous.
Перевод: «Асинхронные включения теперь разрешены внутри включений в асинхронных функциях. Внешние включения неявно становятся асинхронными.» — Пункт 5.
Про область видимости — там же, строки 381–394:
However, aside from the iterable expression in the leftmost
forclause, the comprehension is executed in a separate implicitly nested scope. This ensures that names assigned to in the target list don't "leak" into the enclosing scope. The iterable expression in the leftmostforclause is evaluated directly in the enclosing scope and then passed as an argument to the implicitly nested scope.
Перевод: «Однако, за исключением выражения итерируемого объекта в самом левом
предложении for, включение выполняется в отдельном неявно вложенном
пространстве имён. Это гарантирует, что имена, которым присваивается значение в
списке целей, не «утекают» в объемлющую область. Выражение итерируемого объекта
в самом левом предложении for вычисляется прямо в объемлющей области и затем
передаётся как аргумент в неявно вложенное пространство имён.» — Пункт 3.
Важно: слова «implicitly nested scope» остались в справочнике и после PEP 709.
Встраивание (пункт 7 на 3.12+) убрало отдельный объект кода, но не изменило
правило видимости — это уже проверено в bench/comprehensions/scope.py и
подтверждается здесь для асинхронного варианта.
Оговорка про версию
3.14 здесь — это 3.14.7. Ни одна строка на ней не отличается от
3.13.7. Тег исходников CPython для цитат из Doc/ и Objects/dictobject.c —
v3.14.5.
Скрипт
140 строк"""Асинхронный мост: __aiter__/__anext__, async for, асинхронный генератор.
Один сюжет: асинхронная итерация — тот же протокол с точностью до буквы «a»
и одного await. Проверяются три вещи, которые обычно спрашивают: что
возвращает __aiter__, чем кончается поток, и почему обычный for/list над
асинхронным итератором не работает.
Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import asyncio
import inspect
import sys
print("PY", sys.version.split()[0])
print()
class AsyncRange:
"""Минимальный асинхронный итератор: __aiter__ отдаёт себя, __anext__ — awaitable."""
def __init__(self, n):
self.n = n
self.i = 0
def __aiter__(self): # НЕ async def: должен вернуть итератор сразу
return self
async def __anext__(self):
await asyncio.sleep(0)
if self.i >= self.n:
raise StopAsyncIteration
self.i += 1
return self.i
async def agen(n):
"""Асинхронный генератор — короткая запись того же самого."""
for i in range(1, n + 1):
await asyncio.sleep(0)
yield i
async def main():
# --- 1. Протокол ------------------------------------------------------
a = AsyncRange(3)
print("1) признаки объекта:")
print(" a.__aiter__() is a :", a.__aiter__() is a)
print(" inspect.isasyncgen(agen(3)) :", inspect.isasyncgen(agen(3)))
print(" у AsyncRange есть __iter__ :", hasattr(AsyncRange, "__iter__"))
print(" у AsyncRange есть __next__ :", hasattr(AsyncRange, "__next__"))
print()
# --- 2. async for ------------------------------------------------------
got = []
async for v in AsyncRange(3):
got.append(v)
print("2) async for по AsyncRange(3):", got)
got = []
async for v in agen(3):
got.append(v)
print(" async for по агенератору :", got)
print()
# --- 3. Чем кончается поток -------------------------------------------
it = AsyncRange(1)
print("3) ручные вызовы __anext__:")
print(" await it.__anext__() ->", await it.__anext__())
try:
await it.__anext__()
except StopAsyncIteration:
print(" await it.__anext__() -> StopAsyncIteration (а не StopIteration)")
print()
# --- 4. Обычные инструменты не работают -------------------------------
try:
for _ in AsyncRange(3):
pass
except TypeError as e:
print("4) обычный for над AsyncRange ->", type(e).__name__ + ":", e)
try:
list(agen(3))
except TypeError as e:
print(" list(агенератор) ->", type(e).__name__ + ":", e)
print()
# --- 5. Асинхронное включение -----------------------------------------
values = [v async for v in AsyncRange(3)]
print("5) [v async for v in AsyncRange(3)] ->", values)
doubled = {v: v * 2 async for v in agen(3)}
print(" {v: v*2 async for v in agen(3)} ->", doubled)
print()
# --- 6. Асинхронный генератор — тоже одноразовый -----------------------
g = agen(3)
first = [v async for v in g]
second = [v async for v in g]
print("6) два прохода по одному агенератору:", first, second)
print()
# --- 7. Приостановка реально происходит -------------------------------
order = []
async def background():
for i in range(4):
order.append(f"фон-{i}")
await asyncio.sleep(0)
async def consumer():
async for v in AsyncRange(3):
order.append(f"получено-{v}")
await asyncio.gather(consumer(), background())
print("7) чередование потребителя и фоновой задачи:")
print(" ", order)
print(" каждый шаг __anext__ — это точка, где цикл событий может переключиться")
print()
# --- 8. aclose и завершение -------------------------------------------
closed = []
async def with_cleanup():
try:
yield 1
yield 2
finally:
closed.append("finally выполнен")
g2 = with_cleanup()
async for v in g2:
break
print("8) выход по break из async for:", closed or "finally ещё не выполнен")
await g2.aclose()
print(" после await g2.aclose():", closed)
print(" у асинхронного генератора уборка требует явного aclose или")
print(" асинхронного финализатора цикла событий")
asyncio.run(main())