RRUNTIME LABruntime observatoryNNEON · Статьи на 90 языках
ГЛАВА 24
ПОДРОБНЫЙ РАЗБОР · ОТ БАЗЫ К КОДУ

Разбираем: CPython runtime, память, GIL и asyncio

Этот раздел можно читать до запуска опыта. После теории вернитесь к live trace и сопоставьте каждый шаг с реальным событием.

СНАЧАЛА ПРОСТЫМИ СЛОВАМИ

Python — язык, а CPython — его основная реализация. CPython разбирает source, создаёт code objects с bytecode и выполняет их во frames. Сам интерпретатор не является Event Loop: асинхронный планировщик появляется только когда программа запускает asyncio.

ТЕХНИЧЕСКАЯ ОСНОВА

Модуль, function body и class body являются code blocks и исполняются во frames. В CPython source компилируется в bytecode для evaluation loop; opcodes меняются между версиями. Объекты прежде всего освобождаются reference counting, а cyclic GC дополняет его для циклов. В обычной GIL-сборке один thread одновременно выполняет Python bytecode, хотя blocking I/O освобождает GIL. asyncio поверх этого предоставляет event loop, Tasks, Futures и awaitables.

Зачем это знатьМодель объясняет, почему async def сам ничего не запускает, time.sleep замораживает asyncio, threads помогают I/O, но обычно не ускоряют pure-Python CPU, ProcessPool требует сериализации, а RSS может не упасть сразу после удаления объектов.
ГДЕ ВЫПОЛНЯЕТСЯ РАБОТА
01SOURCEtokens · AST · symbols
02CODE OBJECTconstants · names · bytecode
03CPYTHON VMframes · eval loop · objects
04CONCURRENCYGIL · threads · asyncio · processes
Опорная модель CPython

Интерпретатор, GIL и asyncio — три разных слоя

Сначала определите: выполняется ли bytecode, управляется ли доступ threads к objects или coroutine добровольно отдала управление event loop.

  1. 01 · CompileSource становится code object

    Parser строит синтаксическую структуру, compiler определяет scopes и выпускает bytecode.

  2. 02 · ExecuteFrame выполняет bytecode

    Frame хранит context code block и locals. Evaluation loop исполняет инструкции CPython.

  3. 03 · CoordinateВыбирается concurrency model

    Threads делят память, processes изолируют heaps, asyncio переключает Tasks в точках suspension.

CPython memoryReference count

Объект часто освобождается сразу после исчезновения последней сильной ссылки.

Cyclic collectorgc

Дополняет refcount и обнаруживает изолированные циклы ссылок.

Interpreter lockGIL

В обычной сборке сериализует Python bytecode threads одного interpreter.

OS schedulingthread / process

Thread делит heap; process имеет отдельную память и IPC.

Library schedulerasyncio

Loop возобновляет Tasks после await; blocking-код управление не отдаёт.

Python не равен CPython

Язык задаёт semantics, CPython — implementation. PyPy и другие runtimes могут иначе устроить VM, JIT, память и timing cleanup.

GIL не делает код thread-safe

Составные операции и shared mutable state требуют синхронизации; C-extensions и free-threaded builds дополнительно меняют предпосылки.

Cooperative asyncio schedulingcreate_task(A, B) → main достигает await → A/B стартуют → await приостанавливает их → готовый B возобновляется → A

Если coroutine вызывает time.sleep или долго считает без await, loop не получает управление и остальные Tasks ждут.

01 · СЛОВАРЬ

Термины этого эксперимента

Сначала поймите слова — затем порядок выполнения.

01

CPython

Эталонная и наиболее распространённая реализация Python, написанная преимущественно на C.

02

Code object

Скомпилированное представление code block: bytecode, constants, names и metadata.

03

Bytecode

Инструкции VM CPython. Это не стабильный machine-code контракт и они меняются между версиями.

04

Frame

Контекст code block: code object, locals, globals, instruction state и связь с вызовами.

05

Reference counting

Основной механизм lifetime объектов CPython: объект можно освободить, когда сильных ссылок нет.

06

Cyclic GC

Collector, находящий недостижимые группы objects, которые ссылаются друг на друга.

07

GIL

Global Interpreter Lock обычной CPython-сборки, требуемый thread для Python objects и bytecode.

08

Coroutine

Объект приостанавливаемого вычисления, полученный вызовом async def. Сам ещё не запланирован.

09

Task

Обёртка asyncio, которая планирует coroutine на event loop и хранит result или exception.

10

Awaitable

Объект для await: coroutine, Task, Future или реализация соответствующего протокола.

11

Future

Контейнер будущего результата, связывающий callback/API с ожидающей Task.

12

Free-threaded build

Опциональная сборка CPython без GIL, требующая готовности libraries и extensions.

02 · МЕХАНИКА

Что происходит по шагам

Каждый шаг соответствует наблюдаемому состоянию runtime.

  1. 01
    Source разбирается

    Parser проверяет grammar и строит AST; SyntaxError возникает до выполнения code block.

  2. 02
    Compiler создаёт code object

    Имена/scopes классифицируются, literals становятся constants, операции — bytecode.

  3. 03
    def создаёт function object

    Function связывает code object с globals, defaults и closure.

  4. 04
    Вызов создаёт frame

    Arguments связываются с parameters, evaluation loop начинает bytecode.

  5. 05
    Bindings удерживают objects

    Удаление binding снижает reachability; cycles требует дополнительного GC.

  6. 06
    Thread получает interpreter

    В обычном CPython нужен GIL; на blocking I/O он обычно освобождается.

  7. 07
    asyncio возобновляет Task

    await возвращает управление loop, а готовность позже делает Task runnable.

  8. 08
    CPU выбирает другой путь

    Долгий pure-Python CPU обычно выносят в process pool, native code или проверенную free-threaded архитектуру.

03 · КОНТЕКСТ

Где результат требует оговорки

Эти детали объясняют, почему похожий код иногда даёт другой trace.

01

async def не запускает body

Вызов создаёт coroutine object. Нужны await, create_task или scheduler.

02

await не всегда переключает Task

Если awaitable уже готов, реальной suspension может не произойти.

03

GIL — свойство configuration CPython

Free-threaded builds появились в 3.13, другие implementations имеют свои модели.

04

Threads полезны для I/O

Blocking I/O и многие native extensions освобождают GIL и перекрывают ожидание.

05

Processes не делят обычный heap

Startup, IPC и serialization могут стоить дороже короткой CPU-задачи.

06

del не удаляет object напрямую

Он убирает binding; object живёт при оставшихся ссылках, allocator может сохранить arena.

07

Bytecode — диагностика, не API

dis полезен, но opcodes зависят от версии. Сначала нужен profiler.

08

Один asyncio loop обычно в одном thread

Большинство asyncio objects не thread-safe; из другого thread нужны специальные APIs.

01
Теория

Сначала разберитесь, какие части Node участвуют в выполнении.

02
Упрощённый код

Затем уберите служебные детали и рассмотрите только главную идею.

03
Runtime-код

После этого сопоставьте модель с кодом, который создаёт live trace.

04 · Упрощённый код

Минимальная модель без служебного кода

src/python-lab.py · учебный фрагментPython
async def fetch_pair():
    first = asyncio.create_task(fetch("/first"))
    second = asyncio.create_task(fetch("/second"))
    return await asyncio.gather(first, second)

# Blocking legacy I/O нужно убрать из loop thread:
data = await asyncio.to_thread(legacy_read, path)
05 · Runtime-код

Полный код, который выполняет сценарий

Это не альтернативный пример: ниже показаны функции и файлы, используемые кнопкой запуска.

ФАКТИЧЕСКИЙ SOURCE

Первый файл — настоящий CPython-сценарий; второй безопасно запускает child process и переводит его JSON Lines в live trace.

src/python-lab.py
CPython-сценарий294 строк
#!/usr/bin/env python3
"""Fixed, input-free CPython scenarios used by Runtime Lab."""

from __future__ import annotations

import asyncio
import dis
import gc
import io
import json
import platform
import sys
import time
import weakref
from dataclasses import dataclass
from typing import Any, Iterator


def emit(lane: str, event_type: str, key: str, **data: Any) -> None:
    print(
        json.dumps(
            {"lane": lane, "type": event_type, "key": key, "data": data},
            ensure_ascii=False,
        ),
        flush=True,
    )


def version_event() -> None:
    emit(
        "python",
        "runtime",
        "python.version",
        implementation=platform.python_implementation(),
        version=platform.python_version(),
    )


def run_syntax() -> None:
    version_event()
    orders = [
        {"id": "A-10", "status": "paid", "price": 120, "qty": 2},
        {"id": "A-11", "status": "draft", "price": 80, "qty": 1},
        {"id": "A-12", "status": "paid", "price": 50, "qty": 3},
    ]
    emit("objects", "state", "syntax.objects", count=len(orders))

    paid = [order for order in orders if order["status"] == "paid"]
    total = sum(order["price"] * order["qty"] for order in paid)
    emit(
        "comprehension",
        "result",
        "syntax.comprehension",
        ids=", ".join(order["id"] for order in paid),
        total=total,
    )

    first, *middle, last = [order["id"] for order in orders]
    emit(
        "sequence",
        "result",
        "syntax.unpack",
        first=first,
        middle=middle,
        last=last,
    )

    labels = [f"{index}:{order['id']}" for index, order in enumerate(orders, start=1)]
    emit("loop", "result", "syntax.enumerate", labels=", ".join(labels))

    def format_order(order: dict[str, Any], *, currency: str = "RUB") -> str:
        return f"{order['id']} · {order['price'] * order['qty']} {currency}"

    emit(
        "function",
        "result",
        "syntax.function",
        rendered=format_order(orders[0], currency="₽"),
    )
    emit("result", "result", "syntax.result")


@dataclass(slots=True)
class CartLine:
    sku: str
    price: int
    quantity: int = 1

    @property
    def subtotal(self) -> int:
        return self.price * self.quantity


def append_bad(item: str, bucket: list[str] = []) -> list[str]:
    bucket.append(item)
    return list(bucket)


def append_safe(item: str, bucket: list[str] | None = None) -> list[str]:
    target = [] if bucket is None else bucket
    target.append(item)
    return target


def even_squares(limit: int) -> Iterator[int]:
    for value in range(limit):
        if value % 2 == 0:
            yield value * value


def classify_event(event: dict[str, Any]) -> str:
    match event:
        case {"type": "order.paid", "payload": {"id": order_id}}:
            return f"paid order {order_id}"
        case {"type": event_type}:
            return f"other event {event_type}"
        case _:
            return "invalid event"


def run_semantics() -> None:
    version_event()
    line = CartLine("book", 450, quantity=2)
    emit(
        "class",
        "result",
        "semantics.dataclass",
        rendered=repr(line),
        subtotal=line.subtotal,
    )

    original = ["node"]
    alias = original
    alias.append("python")
    emit("objects", "mutation", "semantics.alias", shared=original == alias == ["node", "python"])

    bad_first = append_bad("api")
    bad_second = append_bad("worker")
    emit(
        "function",
        "warning",
        "semantics.mutable-default",
        first=bad_first,
        second=bad_second,
    )

    safe_first = append_safe("api")
    safe_second = append_safe("worker")
    emit(
        "function",
        "result",
        "semantics.safe-default",
        first=safe_first,
        second=safe_second,
    )

    emit("generator", "result", "semantics.generator", values=list(even_squares(7)))
    emit(
        "pattern",
        "result",
        "semantics.match",
        label=classify_event({"type": "order.paid", "payload": {"id": "A-42"}}),
    )

    try:
        int("not-a-number")
    except ValueError as error:
        emit("exception", "caught", "semantics.exception", message=str(error))

    stream = io.StringIO()
    with stream:
        stream.write("cleanup is deterministic")
        text = stream.getvalue()
    emit("context", "cleanup", "semantics.context", closed=stream.closed, text=text)
    emit("result", "result", "semantics.result")


def doubled_total(values: list[int]) -> int:
    return sum(value * 2 for value in values)


class CycleNode:
    def __init__(self) -> None:
        self.peer: CycleNode | None = None


async def traced_task(name: str, delay: float, completion: list[str]) -> str:
    emit("asyncio", "start", "asyncio.started", name=name)
    await asyncio.sleep(delay)
    completion.append(name)
    emit("asyncio", "resume", "asyncio.resumed", name=name)
    return name


async def measure_timer_while(blocking_call: Any) -> float:
    started = time.perf_counter()
    timer = asyncio.create_task(asyncio.sleep(0.01))
    await blocking_call()
    await timer
    return max(0.0, (time.perf_counter() - started - 0.01) * 1_000)


async def run_asyncio_round() -> None:
    completion: list[str] = []
    first = asyncio.create_task(traced_task("A", 0.025, completion))
    second = asyncio.create_task(traced_task("B", 0.005, completion))
    emit("asyncio", "schedule", "asyncio.created")
    await asyncio.gather(first, second)
    emit("asyncio", "result", "asyncio.result", order=" → ".join(completion))

    async def block_loop() -> None:
        time.sleep(0.055)

    async def offload_sleep() -> None:
        await asyncio.to_thread(time.sleep, 0.055)

    blocked_delay = await measure_timer_while(block_loop)
    emit("asyncio", "blocking", "asyncio.blocking", delay=round(blocked_delay, 1))
    offloaded_delay = await measure_timer_while(offload_sleep)
    emit("asyncio", "offload", "asyncio.offload", delay=round(offloaded_delay, 1))


def run_runtime() -> None:
    implementation = platform.python_implementation()
    gil_probe = getattr(sys, "_is_gil_enabled", None)
    gil_enabled = gil_probe() if gil_probe else implementation == "CPython"
    emit(
        "runtime",
        "config",
        "runtime.config",
        implementation=implementation,
        version=platform.python_version(),
        gil=gil_enabled,
    )

    operations = [instruction.opname for instruction in dis.get_instructions(doubled_total)]
    emit(
        "bytecode",
        "result",
        "runtime.bytecode",
        operations=" → ".join(operations[:12]),
    )

    frame = sys._getframe()
    visible_locals = ", ".join(sorted(name for name in frame.f_locals if not name.startswith("_")))
    emit(
        "frame",
        "state",
        "runtime.frame",
        functionName=frame.f_code.co_name,
        locals=visible_locals,
    )

    left = CycleNode()
    right = CycleNode()
    left.peer = right
    right.peer = left
    left_ref = weakref.ref(left)
    right_ref = weakref.ref(right)
    alive_before = left_ref() is not None and right_ref() is not None
    del left, right
    collected = gc.collect()
    alive_after = left_ref() is not None or right_ref() is not None
    emit(
        "gc",
        "result",
        "runtime.gc",
        collected=collected,
        aliveBefore=alive_before,
        aliveAfter=alive_after,
    )

    asyncio.run(run_asyncio_round())
    emit("result", "result", "runtime.result")


SCENARIOS = {
    "syntax": run_syntax,
    "semantics": run_semantics,
    "runtime": run_runtime,
}


def main() -> None:
    scenario = sys.argv[1] if len(sys.argv) > 1 else ""
    runner = SCENARIOS.get(scenario)
    if runner is None:
        raise SystemExit(f"unknown scenario: {scenario}")
    runner()


if __name__ == "__main__":
    main()

Trace инструментирован сценарием: Python вызывает emit(...) и печатает события как упорядоченные JSON Lines, а Node фиксирует timestamp при получении каждой строки. Названия source/lane задаёт код, это не profiler CPython/asyncio. Node bridge читает вывод без shell, ограничивает время и объём и передаёт события HTTP stream.

06 · РЕЦЕПТЫ

Практические шаблоны, которые можно подсмотреть

Сравнивайте цель, код и оговорки — не запоминайте синтаксис без модели.

01

Coroutine и Task

Различить созданное вычисление и работу.

async def load_user(user_id: int):
    return await repository.get(user_id)

coroutine = load_user(7)
task = asyncio.create_task(coroutine)
user = await task
  • Потерянный coroutine ничего полезного не выполнит.
  • Task хранит result или exception.
02

Параллельное ожидание I/O

Не ждать независимые операции последовательно.

user, orders = await asyncio.gather(
    load_user(user_id),
    load_orders(user_id),
)
  • Concurrency не означает threads.
  • Errors и cancellation входят в контракт.
03

Offload blocking I/O

Не блокировать event-loop thread.

def read_legacy_file(path: str) -> bytes:
    return legacy_client.read(path)

data = await asyncio.to_thread(read_legacy_file, path)
  • to_thread не делает library intrinsically async.
  • Ограничивайте количество jobs.
04

CPU в process pool

Использовать несколько cores.

pool = ProcessPoolExecutor()
result = await asyncio.get_running_loop().run_in_executor(
    pool, calculate_report, payload
)
  • Function и payload должны сериализоваться.
  • Pool должен быть долгоживущим и bounded.
05

Диагностика bytecode

Увидеть инструкции VM.

import dis

def total(values):
    return sum(value * 2 for value in values)

dis.dis(total)
  • Opcode names зависят от версии.
  • Сначала используйте profiler.
06

Наблюдение GC

Измерять collector, а не гадать по RSS.

import gc

before = gc.get_stats()
collected = gc.collect()
after = gc.get_stats()
  • collect в request path не чинит leak.
  • Ищите удерживающие ссылки.
07 · PRODUCTION-КЕЙСЫ

Как учебная ошибка превращается в инцидент

Реалистичный сервис: исходный код, наблюдаемая проблема, исправление и причина, по которой оно работает.

КЕЙС 01

Один blocking report замораживает весь asyncio server

Async handler строит тяжёлый PDF и вызывает sync provider. Автор считает, что async def автоматически отправляет весь body в фоновый thread.

КОНТЕКСТ ИНЦИДЕНТА

До следующего реального suspension coroutine выполняется в event-loop thread. time.sleep, sync I/O и pure-Python CPU задерживают timers, sockets и все остальные Tasks этого loop.

ДОПРОБЛЕМНАЯ РЕАЛИЗАЦИЯ
async def build_report(payload: dict) -> bytes:
    customer = sync_crm.load(payload["customer_id"])
    time.sleep(0.2)
    return render_pdf(customer, payload)

async def меняет протокол вызова, но не природу sync-функций внутри. Ни sync_crm.load, ни time.sleep, ни render_pdf не отдают управление asyncio.

ПОСЛЕИСПРАВЛЕННАЯ РЕАЛИЗАЦИЯ
from concurrent.futures import ProcessPoolExecutor

report_pool = ProcessPoolExecutor(max_workers=2)

async def build_report(payload: dict) -> bytes:
    customer = await asyncio.to_thread(
        sync_crm.load,
        payload["customer_id"],
    )
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(
        report_pool,
        render_pdf,
        customer,
        payload,
    )

Blocking I/O уходит в bounded thread pool, где ожидание не блокирует loop. Pure-Python CPU выполняется в ограниченном process pool и может использовать другие cores без GIL основного interpreter.

ФУНКЦИИ И КОНСТРУКЦИИ

Что делают непривычные вызовы из обоих фрагментов кода.

async def
Определяет coroutine function. Вызов создаёт coroutine object, но body выполняется только под управлением await/scheduler.
asyncio.to_thread(...)
Запускает sync callable в thread pool и возвращает coroutine для ожидания результата без блокировки loop thread.
ProcessPoolExecutor
Управляет ограниченным набором child processes с отдельными interpreters и heaps для CPU parallelism.
get_running_loop()
Возвращает event loop текущего async context и выбрасывает RuntimeError, если active loop отсутствует.
run_in_executor(...)
Передаёт sync callable executor-у и связывает его concurrent result с asyncio Future для await.
max_workers=2
Задаёт bounded concurrency: не позволяет одному process бесконтрольно создавать workers на каждый request.
ПОЧЕМУ ИСПРАВЛЕНИЕ РАБОТАЕТ

Перед offload измерьте workload и отделите I/O от CPU. Pools создаются на lifecycle приложения, получают limits, timeout/cancellation policy и backpressure; иначе перенос блокировки превратится в исчерпание очереди.

ЧТО БЫЛО ВИДНО В PRODUCTION
  • Event-loop lag и p99 всех routes растут во время построения PDF.
  • CPU одного core достигает 100%, хотя машина имеет свободные cores.
  • Asyncio debug сообщает о медленных callbacks или Tasks без suspension.
08 · НЕ ПЕРЕПУТАЙТЕ

Популярные заблуждения

Миф слева, корректная модель справа.

МИФ

Python построчно выполняет source без компиляции.

НА САМОМ ДЕЛЕ

CPython компилирует code blocks в code objects/bytecode и затем выполняет их.

МИФ

GIL исключает race conditions.

НА САМОМ ДЕЛЕ

Shared state и составные операции требуют locks/queues.

МИФ

asyncio делает blocking library неблокирующей.

НА САМОМ ДЕЛЕ

Sync-вызов блокирует loop; нужен async API или explicit offload.

МИФ

await запускает другой thread.

НА САМОМ ДЕЛЕ

Обычно та же Task и thread отдают управление loop до готовности awaitable.

МИФ

Threads ускоряют pure-Python CPU.

НА САМОМ ДЕЛЕ

В обычном CPython bytecode не идёт параллельно; используют processes/native/free-threaded build.

МИФ

gc.collect гарантированно уменьшает RSS.

НА САМОМ ДЕЛЕ

Objects становятся доступны allocator, но allocator/OS могут сохранить arenas.

09 · САМОПРОВЕРКА

Ответьте своими словами

Если ответ получается объяснить без терминов из документации, ментальная модель уже начала складываться.

  1. Чем Python отличается от CPython?
  2. Когда async def начнёт выполнять body?
  3. Почему time.sleep задержит Tasks?
  4. Зачем reference counting дополняется cyclic GC?
  5. Почему GIL не отменяет Lock?
  6. Когда ProcessPool медленнее последовательного кода?