Команда Python for Devs подготовила перевод статьи о том, как "free-threaded" Python меняет правила игры для веб-сервисов. Автор сравнивает Python 3.14 с GIL и без него на реальных ASGI и WSGI приложениях — и приходит к неожиданному выводу: несмотря на локальные просадки в производительности, "free-threaded" Python уже сейчас может упростить масштабирование и снизить накладные расходы.
Python 3.14 вышел в начале месяца. Этот релиз был для меня особенно интересным благодаря улучшениям в "free-threaded" варианте интерпретатора.
Если сравнивать именно "free-threaded" версию Python 3.14 и Python 3.13, то видно два крупных изменения:
"Free-threaded" поддержка перешла во вторую фазу — её больше не считают экспериментальной.
Реализация завершена целиком. Обходные механизмы, добавленные в Python 3.13, чтобы код без GIL работал корректно, убраны. "Free-threaded" версия теперь использует adaptive interpreter так же, как и вариант с GIL. Это, плюс дополнительные оптимизации, сильно уменьшило падение производительности: с 35% до примерно 5–10%.
Мигель Гринберг опубликовал отличную статью о производительности Python 3.14, с отдельными секциями про free-threaded версию в сравнении с вариантом с GIL. Его результаты показывают значительный рост производительности в Python 3.14 по сравнению с 3.13 — и это очень впечатляет!
Хотя его бенчмарки сосредоточены на CPU-нагруженной работе — вычислении Фибоначчи и пузырьковой сортировке — большая часть моего опыта с Python связана с веб-разработкой. Основные мои проекты в Open source — это веб-фреймворк и веб-сервер для Python. Поэтому мне хотелось сравнить free-threaded и «обычный» интерпретаторы именно на веб-приложениях. Даже если 99,9999% веб-сервисов — I/O-нагруженные (общение с базой данных или с внешними сервисами), ключевую роль играет конкурентность. Мы десятилетиями извращались с multiprocessing только чтобы уметь выполнять больше работы параллельно. Может, наконец наступил момент, когда больше не нужно тратить гигабайты памяти лишь для того, чтобы обслуживать больше одного запроса одновременно?
Бенчмарки — это сложно
Давайте честно: бенчмарки — вещь непростая. Особенно когда речь идёт о веб-технологиях. Интернет переполнен дискуссиями о них: люди спорят о методологии, о коде, об окружении. Самые популярные реакции: «а почему вы не протестировали ещё и X?» или «мое приложение с этой библиотекой так не масштабируется, вы всё выдумали». Я уже слышу эти комментарии к этой статье.
Но всё это происходит из-за того, что мы склонны чрезмерно обобщать результаты бенчмарков, а делать так нельзя. На мой взгляд, хороший бенчмарк — это очень замкнутый маленький тест, вырванный из большого контекста. Почему? Потому что хороший бенчмарк должен максимально снижать шум. Меня абсолютно не интересуют войны «фреймворк X быстрее Y» — такие утверждения всегда страдают отсутствием уточнения «на какой части?» — и я не стремлюсь составлять огромную матрицу тестов.
Мне просто важно понять: если взять одно ASGI-приложение и одно WSGI-приложение, выполняющие одно и то же, сможем ли мы увидеть разницу между обычным Python 3.14 и free-threaded, и какие выводы можно сделать. Держите это в голове, смотря на цифры ниже.
Методология
Как уже сказал, цель — протестировать два основных протокола приложений в Python, ASGI и WSGI, на Python 3.14 с включённым и отключённым GIL, сохранив неизменными: сервер, код, уровень конкурентности, event loop.
Я создал ASGI-приложение на FastAPI и WSGI-приложение на Flask. Почему именно их? Просто потому, что они самые популярные. В каждом приложении — два эндпоинта: простой генератор JSON и фейковый I/O-эндпоинт. Вот код FastAPI-версии:
import asyncio
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse, JSONResponse
app = FastAPI()
@app.get("/json")
async def json_data():
return JSONResponse({"message": "Hello, world!"})
@app.get("/io")
async def io_fake():
await asyncio.sleep(0.01)
return PlainTextResponse(b"Hello, waited 10ms")
И вот код Flask-версии:
import json
import time
import flask
app = flask.Flask(__name__)
app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
@app.route("/json")
def json_data():
return flask.jsonify(message="Hello, world!")
@app.route("/io")
def io_fake():
time.sleep(0.01)
response = flask.make_response(b"Hello, waited 10ms")
response.content_type = "text/plain"
return response
Как видно, фейковый I/O-эндпоинт ждёт 10 мс — идея в том, чтобы сэмулировать задержку, похожую на ожидание результата от базы данных. Да, я игнорирую сериализацию/десериализацию и понимаю, что JSON-эндпоинт редко выглядит так в реальном приложении — но опять же, это не цель теста.
Приложения запускаются через Granian, а нагрузка создаётся с помощью rewrk. Почему Granian? Во-первых, я сам поддерживаю проект, но куда важнее, что это единственный сервер, который я знаю, использующий потоки вместо процессов для воркеров на free-threaded Python.
Тесты запускались на одной машине со следующими характеристиками:
Gentoo Linux 6.12.47
AMD Ryzen 7 5700X
CPython 3.14 и 3.14t, установленные через uv
Бенчмарки ASGI
FastAPI-приложение запускалось с 1 и 2 воркерами, с конкурентностью 128 и 256 соответственно. Команды:
granian --interface asgi --loop asyncio --workers {N} impl_fastapi:app
rewrk -d 30s -c {CONCURRENCY} --host http://127.0.0.1:8000/{ENDPOINT}
JSON эндпоинт
Python |
workers |
RPS |
Latency avg |
Latency max |
CPU |
RAM |
|---|---|---|---|---|---|---|
3.14 |
1 |
30415 |
4.20ms |
45.29ms |
0.42 |
90MB |
3.14t |
1 |
24218 |
5.27ms |
59.25ms |
0.80 |
80MB |
3.14 |
2 |
59219 |
4.32ms |
70.71ms |
1.47 |
147MB |
3.14t |
2 |
48446 |
5.28ms |
68.17ms |
1.73 |
90MB |
Как видно, free-threaded версия примерно на 20% медленнее, но использует меньше памяти.
Эндпоинт I/O
Python |
workers |
RPS |
Latency avg |
Latency max |
CPU |
RAM |
|---|---|---|---|---|---|---|
3.14 |
1 |
11333 |
11.28ms |
40.72ms |
0.41 |
90MB |
3.14t |
1 |
11351 |
11.26ms |
35.18ms |
0.38 |
81MB |
3.14 |
2 |
22775 |
11.22ms |
114.82ms |
0.69 |
148MB |
3.14t |
2 |
23473 |
10.89ms |
60.29ms |
1.10 |
91MB |
Здесь обе версии почти идентичны, free-threaded чуть быстрее. И снова — расход памяти ниже.
Бенчмарки WSGI
Запуск WSGI-приложения с двумя эндпоинтами — слабозагруженным по CPU и I/O-нагруженным — с одинаковой конфигурацией куда сложнее. Почему? Потому что в GIL-версии для CPU-нагруженных эндпоинтов нужно минимизировать конкуренцию за GIL (то есть как можно меньше потоков), а для I/O-нагруженных — наоборот, много потоков, чтобы обслуживать запросы, пока другие ждут I/O.
Чтобы проиллюстрировать, что происходит в Python 3.14 с GIL при одном воркере и разном числе потоков:
endpoint |
threads |
RPS |
Latency avg |
Latency max |
|---|---|---|---|---|
JSON |
1 |
19377 |
6.60ms |
28.35ms |
JSON |
8 |
18704 |
6.76ms |
25.82ms |
JSON |
32 |
18639 |
6.68ms |
33.91ms |
JSON |
128 |
15547 |
8.17ms |
3949.40ms |
I/O |
1 |
94 |
1263.59ms |
1357.80ms |
I/O |
8 |
781 |
161.99ms |
197.73ms |
I/O |
32 |
3115 |
40.82ms |
120.61ms |
I/O |
128 |
11271 |
11.28ms |
59.58ms |
Как видно, чем больше потоков, тем ближе производительность I/O-эндпоинта к ожидаемой, но тем хуже работает JSON-эндпоинт. При развёртывании WSGI-приложений много времени уходит на поиски баланса между конкуренцией за GIL и правильной параллельностью. Из-за этого 20 лет люди спорят о количестве потоков — часто используются чисто эмпирические формулы вроде 2*CPU+1. Также по этой причине gevent был нужен до появления asyncio.
На free-threaded Python об этом можно забыть — потоки действительно выполняют код параллельно, без ожидания GIL. Но возникает новый вопрос: увеличивать воркеры или потоки? Ведь в конечном счёте воркеры — те же потоки. Давайте посмотрим на JSON-эндпоинт:
workers |
threads |
RPS |
Latency avg |
Latency max |
|---|---|---|---|---|
1 |
2 |
28898 |
4.42ms |
86.96ms |
2 |
1 |
28424 |
4.49ms |
75.80ms |
1 |
4 |
54669 |
2.33ms |
112.06ms |
4 |
1 |
53532 |
2.38ms |
121.91ms |
2 |
2 |
55426 |
2.30ms |
124.16ms |
Увеличение числа воркеров даёт накладные расходы — логично — и оптимум достигается балансом параметров. Но для поддержки I/O всё равно требуется большое число потоков: Granian не может сам понять, что приложение ждёт I/O — ни в GIL-версии, ни в free-threaded.
С учётом этого Flask-приложение запускалось с 1 и 2 воркерами, при фиксированных 64 потоках на воркер. Конкурентность — 128 и 256, как в тестах ASGI.
Команды:
granian --interface wsgi --workers {N} --blocking-threads 64 impl_flask:app
rewrk -d 30s -c {CONCURRENCY} --host http://127.0.0.1:8000/{ENDPOINT}
JSON-эндпоинт
Python |
workers |
RPS |
Latency avg |
Latency max |
CPU |
RAM |
|---|---|---|---|---|---|---|
3.14 |
1 |
18773 |
6.11ms |
27446.19ms |
0.53 |
101MB |
3.14t |
1 |
70626 |
1.81ms |
311.76ms |
6.50 |
356MB |
3.14 |
2 |
36173 |
5.73ms |
27692.21ms |
1.31 |
188MB |
3.14t |
2 |
60138 |
4.25ms |
294.55ms |
6.56 |
413MB |
Для CPU-нагруженных задач огромный выигрыш free-threaded версии очевиден — она может задействовать гораздо больше CPU. Но расход памяти у неё сильно выше. Неясно, связано ли это с большей конкурентностью или же GC работает менее эффективно.
I/O-эндпоинт
Python |
workers |
RPS |
Latency avg |
Latency max |
CPU |
RAM |
|---|---|---|---|---|---|---|
3.14 |
1 |
6282 |
20.34ms |
62.28ms |
0.40 |
105MB |
3.14t |
1 |
6244 |
20.47ms |
164.59ms |
0.42 |
216MB |
3.14 |
2 |
12566 |
20.33ms |
88.34ms |
0.65 |
180MB |
3.14t |
2 |
12444 |
20.55ms |
124.06ms |
1.18 |
286MB |
Для I/O-нагруженных задач обе версии очень близки по производительности. Но снова: потребление памяти у free-threaded версии заметно выше.
Итоги
Хотя выполнение чистого Python-кода на free-threaded 3.14 примерно на 20% медленнее, преимущества у этого варианта весьма серьёзные.
В асинхронных протоколах типа ASGI, несмотря на то что модель конкурентности меняется не радикально (один event loop на поток вместо одного на процесс), сам факт, что нам больше не нужно раздувать память только ради того, чтобы использовать больше CPU, — огромное преимущество. Даже если память стоит дешевле CPU, на современном железе это может заметно повлиять на стоимость — и для крупных развёртываний, и для небольших проектов на одной VM. Теперь можно помещать больше сервисов на один сервер и масштабироваться только когда упираемся в CPU, а не в память.
Что касается пропускной способности — стоит заметить, что все тесты выше использовали стандартный asyncio event loop. Проекты вроде uvloop или rloop могут улучшать пропускную способность и задержки. Но даже сейчас задержка для I/O-нагруженных задач в free-threaded версии ниже. А учитывая, что, цитируя DHH, «мы все CRUD-обезьянки», и большую часть времени приложения просто ждут базу данных, free-threaded Python уже сегодня может быть лучшим выбором для ASGI-приложений.
В синхронных протоколах вроде WSGI впечатления могут быть смешанными из-за расхода памяти. Но очень возможно, что Granian просто нужно доработать GC-поведение. Если это так — WSGI снова становится приятным в использовании: можно перестать балансировать потоки вручную, перестать монткипатчить приложения под gevent, перестать планировать «переписать всё на asyncio» и просто работать, не думая о блокирующих операциях.
Для таких людей, как я, которые управляют тысячами контейнеров ASGI и WSGI в крупной инфраструктуре (да, я работаю в Sentry, если вы пропустили), free-threaded Python может стать огромным облегчением. Но и для всех, кто пишет веб-приложения на Python, упрощение конкуренции и деплоя — однозначно плюс.
Уверен, весь проект по удалению GIL изначально не был нацелен на веб-приложения. И путь впереди ещё долгий. Но для меня будущее Python-веб-сервисов явно выглядит без GIL.
Русскоязычное сообщество про Python

Друзья! Эту статью подготовила команда Python for Devs — канала, где каждый день выходят самые свежие и полезные материалы о Python и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!