$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогPython 3.15: ленивые импорты, frozendict, sentinel, JIT и жизнь без GIL на реальном FastAPI

Python 3.15: ленивые импорты, frozendict, sentinel, JIT и жизнь без GIL на реальном FastAPI

avatar
Timur
·6 ноября 2026 г.·8 мин·11

Один сервис на FastAPI, код для 3.12 и 3.15 и честные замеры: старт в 2,5 раза быстрее, 8 потоков без GIL в 4 раза быстрее — и где 3.15 медленнее.

Содержание
Что сравниваем1. Ленивые импорты: старт в 2,5 раза быстрее2. Распаковка в генераторах: читаемость, а не скорость3. frozendict: словарь как ключ кэша4. sentinel: «поля не передали» против «передали null»5. JIT: честные 8–10 %6. Жизнь без GIL: в 4 раза быстрее на 8 потокахВесь код на Python 3.15Что дальше

Обзоров Python 3.15 много: пересказ changelog, пара примеров из документации — и непонятно, зачем это в реальном проекте. Здесь наоборот: один маленький сервис на FastAPI, две версии кода — для 3.12 и для 3.15 — и замеры на одной машине. По каждой новинке: какую боль она закрывает, как жили раньше и что изменилось.

Что сравниваем

Сервис аналитики транзакций: миллион транзакций в памяти, ручка /analyze считает суммы по клиентам и подозрительные операции на чистом Python, ручка PATCH /accounts/{id} обновляет данные клиента, редкая ручка /report строит отчёт через pandas. Базу данных специально не берём: запрос в базу — это ожидание сети, а не работа Python, и разница версий в нём утонет.

Результаты на MacBook (M-серия), один и тот же код:

ВерсияСтарт (импорты)1 запрос8 запросов в 8 потоках
Python 3.12~1 050 мс0,13 с1,02 с
Python 3.15~420 мс0,11 с0,90 с
Python 3.15 + JIT~420 мс0,10 с0,81 с
Python 3.15t (без GIL)~420 мс0,12 с0,24 с

Версии переключали через uv:

code
uv run 3_12.py                                   # Python 3.12
uv run --python 3.15+gil 3_15.py                 # обычный 3.15
PYTHON_JIT=1 uv run --python 3.15+gil 3_15.py    # 3.15 с JIT
uv run --python 3.15t 3_15.py                    # 3.15 без GIL

Ловушка, на которой легко получить неверные цифры: после запуска на 3.15t команда с --python 3.15 у uv может молча взять то же окружение без GIL. Пишите 3.15+gil явно и печатайте в начале замера sys._is_gil_enabled().

1. Ленивые импорты: старт в 2,5 раза быстрее

pandas нужен только в редкой ручке отчёта, а загружается при каждом старте — сотни миллисекунд. Это бьёт по CLI-утилитам, serverless (холодный старт), --reload при разработке и тестам.

code
lazy import pandas as pd   # ничего не загружает
lazy import uvicorn

df = pd.DataFrame(...)     # вот здесь pandas реально загрузится

Раньше делали импорт внутри функции. Это работает, но зависимости размазаны по коду, линтеры ругаются. Подвох нового способа: если модуля нет, ошибка всплывёт не на старте, а на первом обращении — посреди запроса в проде. И lazy разрешён только на верхнем уровне модуля: в функции, классе и try это SyntaxError.

Что такое import и модули с нуля — урок «Модули и import» бесплатного курса Python. Как ленивый импорт устроен внутри, глобальный режим -X lazy_imports=all, фильтры и __lazy_modules__ для совместимости — урок курса Senior Python.

2. Распаковка в генераторах: читаемость, а не скорость

Транзакции приходят пачками, их надо склеить:

code
# 3.12: два for, читается справа налево
all_tx = [tx for batch in batches for tx in batch]

# 3.15
all_tx = [*batch for batch in batches]
merged = {**stats for stats in partial_stats}   # со словарями так же

На скорость это почти не влияет — и это нормально: не каждая новинка про скорость. Генераторы списков с нуля — урок бесплатного курса.

3. frozendict: словарь как ключ кэша

Хотим закэшировать результат /analyze по параметрам запроса — и lru_cache падает с TypeError: unhashable type: 'dict'. Обычный словарь изменяемый, а изменяемое нельзя хэшировать: если ключ поменяется после того, как его положили в таблицу, его уже не найти.

code
@lru_cache
def analyze_cached(params: frozendict):
    return analyze(params)

analyze_cached(frozendict(threshold=4900, top=5))   # повтор — 1–2 мкс из кэша

В 3.12 приходилось писать tuple(sorted(params.items())) и собирать словарь обратно. Учтите: frozendict не наследник dict, isinstance(fd, dict) вернёт False.

Что такое хэш и почему словарь ищет по ключу мгновенно — урок «Словари» бесплатного курса, там же задача на кэш с frozendict.

4. sentinel: «поля не передали» против «передали null»

{"phone": null} значит «удали телефон», отсутствие поля — «не трогай». None уже занят, нужен отдельный маркер:

code
MISSING = sentinel("MISSING")       # 3.15: в логах так и будет — MISSING

def update_account(acc_id, phone=MISSING, email=MISSING):
    if phone is not MISSING:
        ...

В 3.12 писали MISSING = object(), и в логах было <object object at 0x104f3a2b0>. Ловушка: bool(MISSING) — True, поэтому проверяйте только через is. Как жили до 3.15 (sentinel на Enum, dataclasses.MISSING) и как это стыкуется с pydantic — урок Senior Python.

5. JIT: честные 8–10 %

JIT замечает горячий код и превращает его в машинный. На нашем цикле по миллиону транзакций это дало 0,11 с → 0,10 с. Не «в разы» — и это нормально: JIT ускоряет чистые Python-циклы и вызовы маленьких функций, но не ввод-вывод и не numpy (он и так на C).

Включается переменной PYTHON_JIT=1, если интерпретатор собран с JIT; проверить — sys._jit.is_enabled(). В официальном docker-образе python:3.15-slim JIT не собран, во free-threaded сборке недоступен. Как JIT вырастает из eval loop и как мерить без самообмана — урок Senior Python.

6. Жизнь без GIL: в 4 раза быстрее на 8 потоках

FastAPI запускает обычные def-ручки в пуле потоков. На 3.12 из-за GIL восемь параллельных запросов считают по очереди на одном ядре — 1,02 с. На сборке 3.15t без GIL они расходятся по ядрам — 0,24 с.

Честная хронология, чтобы не спорить в комментариях: экспериментальная сборка без GIL появилась в 3.13, официальной стала в 3.14, а в 3.15 у неё стабильный ABI, поэтому библиотекам проще выпускать колёса. FastAPI, pydantic, pandas и uvicorn под 3.15t работают, GIL не включается обратно.

Где 3.15 медленнее: в одном потоке сборка без GIL проигрывает обычной (0,12 с против 0,11 с) — это цена атомарного подсчёта ссылок. Если сервис однопоточный или упирается в базу, 3.15t не нужен.

Почему GIL держался 30 лет, что придумали в PEP 703 и когда в проде брать потоки, процессы, asyncio или сборку без GIL — открытый урок курса Senior Python, его можно прочитать без покупки.

Весь код на Python 3.15

code
# /// script
# requires-python = ">=3.15"
# dependencies = ["fastapi", "uvicorn", "pandas"]
# ///
"""Сервис аналитики транзакций на Python 3.15.

    uv run --python 3.15+gil 3_15.py               замер: 1 запрос, 8 запросов в 8 потоках, кэш
    PYTHON_JIT=1 uv run --python 3.15+gil 3_15.py  то же с JIT
    uv run --python 3.15t 3_15.py                  сборка без GIL (free-threading)
    uv run --python 3.15t 3_15.py serve            сервер на http://127.0.0.1:8000/docs

+gil обязателен: после запуска на 3.15t просто «3.15» молча берёт ту же сборку без GIL.
"""
import time
START = time.perf_counter()

import random
import sys
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache

# 1. ЛЕНИВЫЕ ИМПОРТЫ (PEP 810)
# pandas грузится сотни миллисекунд, а нужен только в редкой ручке /report.
# `lazy import` ничего не загружает сейчас: модуль подтянется при первом обращении к pd.
lazy import pandas as pd
lazy import uvicorn  # нужен только в режиме serve

from fastapi import FastAPI
from pydantic import BaseModel

print(f"импорты: {(time.perf_counter() - START) * 1000:.0f} мс")

app = FastAPI()


# 2. РАСПАКОВКА В COMPREHENSIONS (PEP 798)
# Транзакции приходят пачками по дням: (клиент, сумма). Склеиваем в один список.
random.seed(42)
batches = [
    [(random.randrange(1000), random.uniform(1, 5000)) for _ in range(100_000)]
    for day in range(10)
]
TXS = [*batch for batch in batches]  # со словарями так же: {**d for d in dicts}


# 3. JIT + FREE-THREADING: чистый Python-цикл, никакого numpy.
# numpy написан на C, его не ускорит ни JIT, ни отсутствие GIL.
def risk_score(amount, threshold):
    # чем ближе к порогу, тем подозрительнее; «круглые» суммы — ещё подозрительнее
    score = amount / threshold
    if amount % 100 < 1:
        score += 0.5
    return score


def analyze(params):
    totals = {}
    suspicious = 0
    for client, amount in TXS:
        totals[client] = totals.get(client, 0) + amount
        if risk_score(amount, params["threshold"]) > 1:
            suspicious += 1
    top = sorted(totals.items(), key=lambda kv: kv[1], reverse=True)[: params["top"]]
    return {"suspicious": suspicious, "top": top}


# 4. FROZENDICT (PEP 814)
# Обычный dict изменяемый, поэтому нехэшируемый: lru_cache падает
# с "unhashable type: 'dict'". frozendict неизменяемый — его можно хэшировать.
@lru_cache
def analyze_cached(params: frozendict):
    return analyze(params)


@app.get("/analyze")
def analyze_endpoint(threshold: float = 4900, top: int = 5):
    # def, а не async def: FastAPI запускает такие ручки в пуле потоков.
    # С GIL потоки считают по очереди, без GIL — на разных ядрах.
    return analyze_cached(frozendict(threshold=threshold, top=top))


# 5. SENTINEL (PEP 661)
# {"phone": null} — «удали телефон», поля нет — «не трогай». None занят, нужен маркер.
MISSING = sentinel("MISSING")  # repr в логах: MISSING
ACCOUNTS = {1: {"phone": "+998901234567", "email": "student@sudoteach.com"}}


def update_account(acc_id, phone=MISSING, email=MISSING):
    print(f"update_account({acc_id}, phone={phone!r}, email={email!r})")
    acc = ACCOUNTS[acc_id]
    if phone is not MISSING:
        acc["phone"] = phone
    if email is not MISSING:
        acc["email"] = email
    return acc


class AccountPatch(BaseModel):
    phone: str | None = None
    email: str | None = None


@app.patch("/accounts/{acc_id}")
def patch_account(acc_id: int, body: AccountPatch):
    # exclude_unset: в словарь попадут только поля, которые клиент реально прислал
    return update_account(acc_id, **body.model_dump(exclude_unset=True))


@app.get("/report")
def report():
    # Только здесь pandas реально загрузится, поэтому первый вызов дольше остальных.
    df = pd.DataFrame(TXS[:10_000], columns=["client", "amount"])
    return df["amount"].describe().round(2).to_dict()


def bench():
    print(f"Python {sys.version.split()[0]} | GIL: {sys._is_gil_enabled()} | JIT: {sys._jit.is_enabled()}")
    params = frozendict(threshold=4900, top=5)

    t = time.perf_counter()
    analyze(params)
    print(f"1 запрос:                {time.perf_counter() - t:.2f} с")

    t = time.perf_counter()
    with ThreadPoolExecutor(8) as pool:
        list(pool.map(analyze, [params] * 8))
    print(f"8 запросов в 8 потоках:  {time.perf_counter() - t:.2f} с")

    analyze_cached(params)
    t = time.perf_counter()
    analyze_cached(params)
    print(f"повтор из кэша:          {(time.perf_counter() - t) * 1e6:.0f} мкс")


if __name__ == "__main__":
    if "serve" in sys.argv:
        uvicorn.run(app)  # вот здесь uvicorn и загрузится
    else:
        bench()

Версия для 3.12 отличается только новинками: обычный import вместо lazy import, два for вместо распаковки, tuple(sorted(...)) вместо frozendict и object() вместо sentinel.

Что дальше

Если вы только начинаете — бесплатный курс Python: с нуля, с задачами и автопроверкой кода прямо в браузере. Песочница сайта уже работает на Python 3.15, так что всё из этой статьи можно запустить в задачах.

Если хотите понимать Python так же глубоко, как его авторы, — курс Senior Python: система импортов, подсчёт ссылок и GC, байткод, async изнутри и отдельные уроки про всё новое в 3.15. До 15 ноября по промокоду PY315 — скидка 20 %.

Войдите, чтобы поставить лайк и оставить комментарий.

avatar

Автор

Timur

Senior Python

Курс по теме

Senior Python

Senior Python: классы под микроскопом — погружение во внутреннее устройство CPython для middle/senior: от декораторов и метаклассов до байткода и GC. Разбор исходников языка, трассировки и тесты к каждому уроку.

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Discord-боты на Python: с нуля до PRO

Discord-боты на Python: с нуля до PRO

Свой Discord-бот за первый вечер, а к концу курса — бот для сервера с тикетами, экономикой, модерацией, уровнями и ИИ. Python и discord.py 2.7, slash-команды, Components V2, запуск 24/7.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Solidity DLC - поймай, если сможешь

Solidity DLC - поймай, если сможешь

Аудит смарт-контрактов на реальных взломах. The DAO, Parity, Wormhole, Ronin, Euler и ещё 7 разборов: что пошло не так, сколько потеряли и что бы спасло. Атаки и защиты на Foundry, план входа в профессию аудитора.

Перейти к курсу

Другие статьи

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость
python

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость

Поймём, где чаще всего ломается воспроизводимость (версии данных, фильтры, дрейф) и как оформить пайплайн признаков так, чтобы результаты совпадали между запусками и окружениями.

17 сентября 2026 г.
1830
Как устроен GIL в Python: что он ограничивает и как это влияет на реальный код
устроен

Как устроен GIL в Python: что он ограничивает и как это влияет на реальный код

Разберём, почему GIL мешает CPU-bound потокам, а для IO-bound иногда всё “выглядит как будто работает”. Поймём, когда выбирать потоки, процессы и asyncio, и как измерить эффект на вашей задаче.

16 сентября 2026 г.
1620
Как выучить программирование самому и бесплатно: с чего начать и в каком порядке
выучить

Как выучить программирование самому и бесплатно: с чего начать и в каком порядке

Разберём понятный маршрут для новичка: как выбрать первый язык, что учить в первую очередь, какие мини-проекты делать и как не забросить занятия. Дам бесплатные ориентиры и план, чтобы знания складывались в портфолио.

21 сентября 2026 г.
2420
С чего начать учить Python, если вы вообще не программист: план на 30 минут в день
учить

С чего начать учить Python, если вы вообще не программист: план на 30 минут в день

Разберём, что установить и как устроить обучение: первые переменные, вывод текста, простые условия и циклы — на понятных примерах без “магии”. В конце соберёте мини-скрипт, который что-то делает по вводу пользователя.

6 ноября 2026 г.
180
Какой первый проект выбрать новичку, чтобы не бросить обучение
первый

Какой первый проект выбрать новичку, чтобы не бросить обучение

Подберём 5–7 идей под уровень “с нуля”, объясним, что делать по шагам и как довести проект до результата без перегруза. В конце — как оформить мини-портфолио и что показать, даже если проект маленький.

23 сентября 2026 г.
2270
Как выбрать первый сайт-проект новичку: что сделать за вечер и расширять дальше
выбрать

Как выбрать первый сайт-проект новичку: что сделать за вечер и расширять дальше

Подберём идею под ваш уровень и объясним, как превратить её в мини-портфолио: от одной страницы/формы до простого функционала и следующего шага. В конце — список конкретных вариантов проектов и план разработки на первые дни.

4 ноября 2026 г.
430

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Что сравниваем1. Ленивые импорты: старт в 2,5 раза быстрее2. Распаковка в генераторах: читаемость, а не скорость3. frozendict: словарь как ключ кэша4. sentinel: «поля не передали» против «передали null»5. JIT: честные 8–10 %6. Жизнь без GIL: в 4 раза быстрее на 8 потокахВесь код на Python 3.15Что дальше