Будущее веб-сервисов Python, похоже, не зависит от GIL

25 октября 2025 г.


В начале месяца был выпущен Python 3.14. Этот релиз был особенно интересен для меня из-за улучшений в "free-threaded" варианте интерпретатора.     
В частности, двумя основными изменениями по сравнению со свободнопоточным вариантом Python 3.13 являются:

Поддержка свободных потоков теперь достигла стадии II, что означает, что она больше не считается экспериментальной

Реализация теперь завершена, а это означает, что обходные пути, введенные в Python 3.13 для придания коду звучания без GIL, теперь отсутствуют, и в свободнопоточной реализации теперь используется адаптивный интерпретатор в качестве варианта с поддержкой GIL. Эти факты, а также дополнительные оптимизации значительно уменьшают  снижение производительности, увеличивая разницу с 35% до 5-10%.    
 

Мигель Гринберг написал замечательную статью о производительности Python 3.14, в которой есть разделы, посвященные свободнопоточному варианту по сравнению с GIL. Его результаты показывают значительное улучшение производительности Python 3.14t по сравнению с 3.13t, что не может не радовать! 

 

В то время как его тесты сосредоточены на работе с процессором, вычислении последовательности Фибоначчи и использовании алгоритма пузырьковой сортировки, огромная часть моего опыта работы с Python сосредоточена на веб-разработке – в конце концов, основными проектами OSS, которые я поддерживаю, являются веб–фреймворк и веб-сервер для Python, - и поэтому я хотел провести правильное сравнение интерпретаторов Python со свободным потоком и GIL в веб-приложениях: даже если 99,9999% существующих веб–сервисов связаны с вводом-выводом - взаимодействием с базой данных или отправкой запросов к другим сервисам – параллелизм здесь является ключевым фактором, и мы потратили десятилетия на то, чтобы делать странные вещи с многопроцессорным модулем, чтобы выполнять больше работы параллельно. Может быть, сейчас наконец-то можно перестать тратить гигабайты памяти только на то, чтобы обслуживать более 1 запроса за раз? 
 

Критерии - это сложно

Давайте посмотрим правде в глаза. Нам было нелегко разобраться в тестах, особенно когда они касались веб–технологий. Интернет полон дискуссий вокруг них, люди спорят о каждом их аспекте: методологии, коде, среде. Самые популярные реакции на тесты такие: "но почему вы не протестировали также X" или "мое приложение, использующее эту библиотеку, не масштабируется так, как это, вы лжете". Я уже слышал подобные комментарии к этой статье. 

 

Но это потому, что мы склонны обобщать результаты тестов, а мы на самом деле не должны этого делать. С моей точки зрения, хороший тест - это очень самодостаточный тест, который тестирует очень маленькую вещь вне реального – и гораздо более широкого – контекста. И почему это так? Потому что хороший бенчмарк должен максимально снижать уровень шума. Меня определенно не интересует, работает ли фреймворк X быстрее, чем Y war – еще и потому, что в этих заявлениях обычно не указано, в какой части, – и меня не очень волнует наличие очень широкой матрицы тестовых примеров. 

 

На самом деле я просто хочу посмотреть, сможем ли мы, используя одно веб-приложение ASGI и одно веб-приложение WSGI, выполняющее одно и то же действие, выявить различия между стандартным Python версии 3.14 и его многопоточным вариантом и принять решения, основанные на этих результатах. Пожалуйста, имейте это в виду, когда смотрите на приведенные ниже цифры. 
 

Методология

Как упоминалось ранее, идея состоит в том, чтобы протестировать два основных прикладных протокола на Python – ASGI и WSGI – на Python 3.14 с включенным и отключенным GIL, сохранив все остальное исправленным: сервер, код, параллелизм, цикл обработки событий.

 

Таким образом, я создал ASGI–приложение, используя FastAPI, и WSGI-приложение, используя Flask - почему именно эти фреймворки? Просто потому, что они самые популярные – с двумя конечными точками: глупым генератором ответов в формате JSON и поддельной конечной точкой с привязкой ввода-вывода. Вот код для версии 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

  
ASGI-бенчмарки 

Как видно, фиктивная I/O-точка ожидания имеет задержку в 10 мс - идея в том, чтобы смоделировать ожидание ответа от базы данных. Да, я понимаю, что здесь игнорируется сериализация/десериализация и прочие реальные детали взаимодействия, но это не цель данного теста.

Мы запускаем эти приложения с помощью Granian и создаём нагрузку с помощью rewrk. Почему Granian? Во-первых, я сам его поддерживаю. А во-вторых — это единственный известный мне сервер, который использует потоки вместо процессов для запуска воркеров в режиме free-threaded Python.

Конфигурация стенда

  • ОС: Gentoo Linux 6.12.47
  • CPU: AMD Ryzen 7 5700X
  • Python: CPython 3.14 и 3.14t (установлены через uv)

    Команды запуска
granian --interface asgi --loop asyncio --workers {N} impl_fastapi:app rewrk -d 30s -c {CONCURRENCY} --host http://127.0.0.1:8000/{ENDPOINT} 

JSON endpoint

PythonworkersRPSСредняя задержкаМакс. задержкаCPURAM
3.141304154.20ms45.29ms0.4290MB
3.14t1242185.27ms59.25ms0.8080MB
3.142592194.32ms70.71ms1.47147MB
3.14t2484465.28ms68.17ms1.7390MB

Как видно из таблицы, free-threaded реализация примерно на 20% медленнее, но использует меньше памяти.

I/O endpoint

PythonworkersRPSСредняя задержкаМакс. задержкаCPURAM
3.1411133311.28ms40.72ms0.4190MB
3.14t11135111.26ms35.18ms0.3881MB
3.1422277511.22ms114.82ms0.69148MB
3.14t22347310.89ms60.29ms1.1091MB

Здесь результаты почти одинаковы, с лёгким преимуществом у free-threaded реализации, при этом она снова потребляет меньше памяти.

WSGI-бенчмарки

Запуск WSGI-приложения, которое содержит как CPU-нагруженные, так и I/O-нагруженные эндпоинты, сложнее. Почему? Потому что в обычном (с GIL) Python при CPU-нагрузке нужно минимизировать количество потоков, чтобы избежать блокировок GIL, а при I/O-нагрузке, наоборот, увеличить число потоков, чтобы не простаивать во время ожидания операций ввода/вывода.

Влияние количества потоков (Python 3.14 с GIL)

endpointthreadsRPSСредняя задержкаМакс. задержка
JSON1193776.60ms28.35ms
JSON8187046.76ms25.82ms
JSON32186396.68ms33.91ms
JSON128155478.17ms3949.40ms
I/O1941263.59ms1357.80ms
I/O8781161.99ms197.73ms
I/O32311540.82ms120.61ms
I/O1281127111.28ms59.58ms

Чем больше потоков, тем лучше результаты у I/O, но хуже у JSON. Именно поэтому в WSGI всегда приходилось искать баланс между числом потоков и блокировками GIL.

В free-threaded Python мы можем об этом забыть - каждый поток может реально исполняться параллельно. Но теперь возникает другой вопрос: что масштабировать — workers или threads? В конце концов, воркеры — тоже потоки.

Эксперимент с количеством воркеров и потоков (JSON endpoint)

workersthreadsRPSСредняя задержкаМакс. задержка
12288984.42ms86.96ms
21284244.49ms75.80ms
14546692.33ms112.06ms
41535322.38ms121.91ms
22554262.30ms124.16ms

Увеличение количества воркеров вносит некоторый оверхед, поэтому баланс нужно подбирать под конкретную нагрузку. Поскольку для I/O всё равно требуется высокий уровень параллелизма, этот фактор не столь критичен.

Команда запуска Flask

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 endpoint (Flask)

PythonworkersRPSСредняя задержкаМакс. задержкаCPURAM
3.141187736.11ms27446.19ms0.53101MB
3.14t1706261.81ms311.76ms6.50356MB
3.142361735.73ms27692.21ms1.31188MB
3.14t2601384.25ms294.55ms6.56413MB

Для CPU-нагруженных сценариев преимущество free-threaded версии очевидно — она задействует больше CPU и показывает значительно выше RPS. Однако её использование памяти заметно больше — возможно, из-за особенностей сборщика мусора.

I/O endpoint (Flask)

PythonworkersRPSСредняя задержкаМакс. задержкаCPURAM
3.141628220.34ms62.28ms0.40105MB
3.14t1624420.47ms164.59ms0.42216MB
3.1421256620.33ms88.34ms0.65180MB
3.14t21244420.55ms124.06ms1.18286MB

При I/O-нагрузке результаты примерно одинаковые, но free-threaded версия опять же потребляет больше памяти.

Итоговые выводы

Хотя выполнение чисто Python-кода примерно на 20% медленнее на free-threaded Python 3.14, он демонстрирует целый ряд преимуществ:

  • ASGI: отсутствие необходимости масштабировать процессы для использования всех ядер CPU - теперь это можно делать потоками с меньшими затратами памяти.
  • WSGI: возможность работать без опасений по поводу блокировок GIL и сложных компромиссов между потоками и процессами.
  • Упрощение архитектуры: можно перестать использовать monkeypatch (например, через gevent) или планировать миграцию на asyncio ради масштабирования.

Даже если free-threaded Python изначально не создавался с прицелом на веб-приложения, он уже сейчас показывает, что будущее Python-сервисов может быть без GIL - проще, эффективнее и чище в архитектурном плане.

Автор: инженер из компании Sentry.


Оригинал
PREVIOUS ARTICLE