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

Один сервис на FastAPI, код для 3.12 и 3.15 и честные замеры: старт в 2,5 раза быстрее, 8 потоков без GIL в 4 раза быстрее — и где 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:
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 при разработке и тестам.
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. Распаковка в генераторах: читаемость, а не скорость
Транзакции приходят пачками, их надо склеить:
# 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'. Обычный словарь изменяемый, а изменяемое нельзя хэшировать: если ключ поменяется после того, как его положили в таблицу, его уже не найти.
@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 уже занят, нужен отдельный маркер:
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
# /// 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 %.