Deep Engineering

MEASUREMENT

bench/iteration/invalidation.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/iterators-and-generators
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 the for and in statements.

Перевод: «Возвращает сам объект-итератор. Это требуется, чтобы и контейнеры, и итераторы можно было использовать в инструкциях 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 an IOBase object 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 a StopAsyncIteration error 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 a TypeError error.

Перевод: «До 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 for clauses, or if it contains await expressions or other asynchronous comprehensions anywhere except the iterable expression in the leftmost for clause, 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 for clause, 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 leftmost for clause 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.cv3.14.5.

Script

129 lines
"""Изменение контейнера во время обхода: где падает, где молча пропускает.

Один сюжет: у dict и set есть сторож — сравнение размера на каждом шаге, и он
поднимает RuntimeError. У list сторожа нет вовсе: итератор списка хранит индекс,
и удаление элемента сдвигает хвост под ним. Первое видно сразу, второе — нет.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import sys

print("PY", sys.version.split()[0])
print()

# --- 1. dict: добавление во время обхода -----------------------------------
d = {"a": 1, "b": 2, "c": 3}
try:
    for k in d:
        d[k + "!"] = 0
    print("1) добавление в dict во время обхода: прошло молча")
except RuntimeError as e:
    print("1) добавление в dict во время обхода ->", type(e).__name__ + ":", e)
print()

# --- 2. dict: удаление во время обхода -------------------------------------
d = {"a": 1, "b": 2, "c": 3}
try:
    for k in d:
        del d[k]
    print("2) удаление из dict во время обхода: прошло молча, осталось", d)
except RuntimeError as e:
    print("2) удаление из dict во время обхода ->", type(e).__name__ + ":", e)
print("   сколько успело удалиться до ошибки:", d)
print()

# --- 3. Сторож смотрит на РАЗМЕР, а не на содержимое -----------------------
d = {"a": 1, "b": 2, "c": 3}
seen = []
for k in d:
    seen.append(k)
    d[k] = 99                 # замена значения размер не меняет
print("3) замена значений во время обхода: ошибки нет, обход полный:", seen)
print("   словарь:", d)
print()

d = {"a": 1, "b": 2, "c": 3}
seen = []
try:
    for k in d:
        seen.append(k)
        if k == "a":
            del d["c"]        # -1
            d["z"] = 0        # +1, размер вернулся к прежнему
    print("4) удаление + вставка (размер не изменился): ошибки нет")
except RuntimeError as e:
    print("4) удаление + вставка ->", type(e).__name__ + ":", e)
print("   что обошли:", seen, "| словарь:", d)
print("   сторож сравнивает di_used с ma_used — это ДЛИНА, не версия содержимого")
print()

# --- 5. set --------------------------------------------------------------
s = {1, 2, 3}
try:
    for x in s:
        s.add(x + 100)
    print("5) добавление в set во время обхода: прошло молча")
except RuntimeError as e:
    print("5) добавление в set во время обхода ->", type(e).__name__ + ":", e)
print()

# --- 6. list: сторожа нет, элементы ПРОПУСКАЮТСЯ ---------------------------
xs = ["a", "b", "c", "d", "e"]
visited = []
for item in xs:
    visited.append(item)
    if item in ("a", "b", "c"):
        xs.remove(item)
print("6) удаление из списка внутри for по этому же списку:")
print("   посетили:", visited)
print("   осталось:", xs)
print("   ни одного исключения; 'b' и 'd' не были посещены вовсе")
print()

# --- 7. Почему именно так: итератор списка хранит ИНДЕКС -------------------
xs = ["a", "b", "c"]
it = iter(xs)
print("7) шаг за шагом:")
print("   next(it) ->", next(it), "| индекс итератора теперь 1")
xs.remove("a")
print("   после xs.remove('a') список стал", xs)
print("   next(it) ->", next(it), "— это 'c', потому что индекс 1 указывает уже на него")
print("   'b' пропущен")
print()

# --- 8. Как чинится ---------------------------------------------------------
xs = ["a", "b", "c", "d", "e"]
for item in list(xs):          # обход по КОПИИ
    if item in ("a", "b", "c"):
        xs.remove(item)
print("8) обход по копии list(xs):", xs)

xs = ["a", "b", "c", "d", "e"]
xs = [i for i in xs if i not in ("a", "b", "c")]
print("   пересборка включением:", xs)

d = {"a": 1, "b": 2, "c": 3}
for k in list(d):
    if k != "b":
        del d[k]
print("   для dict — обход по list(d):", d)
print()

# --- 9. Что делает сторож после срабатывания -------------------------------
d = {"a": 1, "b": 2, "c": 3}
it = iter(d)
next(it)
d["z"] = 0
try:
    next(it)
except RuntimeError as e:
    print("9) первый next после изменения ->", type(e).__name__ + ":", e)
del d["z"]                      # размер вернули к исходному
try:
    print("   размер вернули; следующий next ->", next(it))
except RuntimeError as e:
    print("   размер вернули; следующий next ->", type(e).__name__ + ":", e)
except StopIteration:
    print("   размер вернули; следующий next -> StopIteration")
print("   состояние итератора «залипает»: в исходнике di_used = -1 помечен как sticky")