MEASUREMENT
bench/iteration/async_comprehension.py
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/interview/python/comprehensions
- How to run it
Времени нет ни в одном файле: смотрим на значения, тип ошибки и порядок событий. ## Версии | скрипт | 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/`. Асинхронное включение встроилось вместе с остальными. ## Одноразовые типы
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры: одноразовость, порча итератора и асинхронный обход
Четыре сюжета, которых нет в 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.
Script
134 lines"""Асинхронные включения: где выполняется await и что с областью видимости.
Один сюжет: `[x async for x in ...]` — то же неявное вложенное пространство
имён, что у обычного включения, но исполняется оно ВНУТРИ той же корутины и
поэтому имеет право приостанавливаться. Проверяем три вещи: приостановка
действительно происходит внутри включения; имена по-прежнему не утекают;
и наличие `async for` или `await` внутри делает включение асинхронным целиком —
включая внешнее, обычное на вид.
Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import asyncio
import dis
import inspect
import sys
print("PY", sys.version.split()[0])
print()
async def asrc(n, tag, order):
for i in range(n):
order.append(f"{tag}-выдал-{i}")
await asyncio.sleep(0)
yield i
async def slow(x, order):
order.append(f"await-{x}")
await asyncio.sleep(0)
return x * 10
async def main():
# --- 1. Приостановка происходит внутри включения ----------------------
order = []
async def background():
for i in range(5):
order.append(f"фон-{i}")
await asyncio.sleep(0)
async def consumer():
order.append("включение началось")
result = [x async for x in asrc(3, "src", order)]
order.append("включение кончилось")
return result
res, _ = await asyncio.gather(consumer(), background())
print("1) результат:", res)
print(" порядок событий:")
for line in order:
print(" ", line)
print(" фоновые записи попали МЕЖДУ шагами включения — оно приостанавливалось")
print()
# --- 2. await прямо в выражении включения -----------------------------
order2 = []
doubled = [await slow(x, order2) async for x in asrc(3, "s", order2)]
print("2) [await slow(x) async for x in asrc(3)] ->", doubled)
print(" порядок:", order2)
print(" await в выражении и async for в клаузе чередуются шаг за шагом")
print()
# --- 3. Область видимости: имя цели не утекает ------------------------
x = "внешнее"
values = [x async for x in asrc(2, "q", [])]
print("3) после включения внешнее x =", repr(x), "| значения:", values)
print(" имя цели живёт в неявном вложенном пространстве имён, как у обычного")
print()
# --- 4. Обычное включение с await, без async for ----------------------
order4 = []
plain = [await slow(v, order4) for v in (1, 2)]
print("4) [await slow(v) for v in (1, 2)] ->", plain)
print(" порядок:", order4)
print(" async for нет, но await есть — включение всё равно асинхронное")
print()
# --- 5. Вложенность: внешнее включение становится асинхронным ----------
nested = [[y async for y in asrc(2, "in", [])] for _ in range(2)]
print("5) [[y async for y in ...] for _ in range(2)] ->", nested)
print(" внешнее включение выглядит обычным, но внутри асинхронное —")
print(" справочник: «Outer comprehensions implicitly become asynchronous»")
print()
# --- 6. Генераторное выражение с async for --------------------------
agen_expr = (x async for x in asrc(3, "g", []))
print("6) (x async for x in ...) — это:", type(agen_expr).__name__)
print(" inspect.isasyncgen:", inspect.isasyncgen(agen_expr))
print(" собрать его можно только через async for:",
[v async for v in agen_expr])
print()
# --- 7. Флаги кода функции, содержащей включение ----------------------
async def holder():
return [x async for x in asrc(1, "h", [])]
CO_COROUTINE = 0x0080
CO_ASYNC_GENERATOR = 0x0200
print("7) флаги кода функции с асинхронным включением:")
print(" CO_COROUTINE :", bool(holder.__code__.co_flags & CO_COROUTINE))
print(" CO_ASYNC_GENERATOR:", bool(holder.__code__.co_flags & CO_ASYNC_GENERATOR))
print(" тело включения компилируется в отдельный объект кода:",
[c.co_name for c in holder.__code__.co_consts if hasattr(c, "co_name")]
or "включение встроено (inlining, 3.12+)")
print()
# --- 8. Инструкции, которых нет у синхронного включения ----------------
names = {i.opname for i in dis.get_instructions(holder.__code__)}
async_ops = sorted(n for n in names if "ASYNC" in n or "AWAIT" in n or "SEND" in n)
print("8) асинхронные инструкции в holder:", async_ops or "нет на верхнем уровне")
def sync_holder():
return [x for x in range(1)]
sync_names = {i.opname for i in dis.get_instructions(sync_holder.__code__)}
print(" у синхронного аналога:",
sorted(n for n in sync_names if "ASYNC" in n or "AWAIT" in n or "SEND" in n)
or "нет")
asyncio.run(main())
# --- 9. Вне async def это синтаксическая ошибка ---------------------------
code = "[x async for x in src]"
try:
compile(code, "<test>", "exec")
print()
print("9) компиляция", code, "вне async def: прошла")
except SyntaxError as e:
print()
print("9) компиляция", code, "вне async def -> SyntaxError:", e.msg)