Команда 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 и его экосистеме. Подписывайтесь, чтобы ничего не пропустить!

Комментарии (0)