Хватит спорить — пора запускать и сравнивать.

Тестируем реальные сценарии, измеряем RPS, смотрим на потребление памяти и разбираемся, когда самая разумная стратегия — это просто подождать и обновить Python на free-threading версию. 

Привет, Хабр! Меня зовут Игорь Анохин, я — руководитель платформенной разработки в K2 Cloud и более 8 лет программирую на Python. 

В чём проблема

Хочу поговорить про асинхронность и многопоточность как про осознанный выбор, который мы используем в своём проекте. K2 Облако — это первое публичное облако собственной разработки, которое мы строим с 2009 года. За последние несколько лет мы достаточно сильно выросли и активно нанимаем Python-разработчиков разного уровня в офис и на удалёнку. Я часто провожу технические интервью, и с каждым разом всё сильнее замечаю тенденцию: разработчики, особенно начинающие, практически не смотрят в сторону многопоточности. Многие ограничиваются только асинхронным подходом, а если и слышали про потоки, то всерьёз сравнить их с асинхронностью не могут. Сам я начинал в те времена, когда асинхронность ещё не была мейнстримом, и многопоточность казалась логичным путём.

K2 Облако — это большой многопоточный проект, который пишется уже более 10 лет. Асинхронность мы тоже используем: ещё на Python 2.7 у нас был самописный движок, IOLoop. Поэтому я хорошо представляю, как работают асинхронные библиотеки, знаю их плюсы и ограничения. И, что интересно, это знание в итоге только усиливает мою симпатию к многопоточности. Особенно сейчас, когда появилась версия Python 3.13 с поддержкой No GIL — интерпретатора без глобальной блокировки.

Что брать для CPU-bound: Threading или asyncio

Задачи, которые мы программисты на Python, решаем, делятся на два типа: IO-bound и CPU-bound. Если речь идёт о CPU-bound задачах, то выбор очевиден: лучше использовать multiprocessing, потому что ни асинхронность, ни многопоточность в чистом виде не дают желаемой производительности в условиях GIL.

А вот в случае с IO-bound задачами, на мой взгляд, оба подхода — многопоточность и асинхронность — дают схожий результат. Почему? Разберём на примере.

Пример многопоточности 

Рассмотрим небольшую функцию, которая отправляет запросы к сайту k2.cloud. У неё сетевое ожидание, поэтому если мы хотим ускорить запуск таких задач в многопоточном подходе, то используем ThreadPoolExecutor.

def fetch(_):

   return requests.get("https://k2.cloud")

n_requests = 100

with ThreadPoolExecutor(

    max_workers=n_requests

) as executor:

    results = list(executor.map(fetch,

                                range(n_requests)))

Чтобы понять, как на самом деле работает этот код и как создаются потоки — нужно заглянуть в исходники CPython. Внутри используется вызов pthread_create — стандартная функция из C-библиотеки, которая создаёт «честный» поток.

static long

PyThread_start_new_thread(void (*func)(void ), void arg)

{

    pthread_t th;

    pthread_attr_t attrs;

    /* ... attribute initialization ... */

    if (pthread_create(&th, &attrs,

                       (void ()(void *))func, arg) != 0)

        return -1;

    /* ... cleanup ... */

    return (long)th;

}

Функция pthread_create, которую можно найти в сниппете, означает, что каждый поток Python на самом деле соответствует полноценному потоку операционной системы (OS thread). Такой же поток мы получили бы в любом другом языке программирования с «честной» многопоточностью. 

Операционная система управляет этими потоками через собственный планировщик (scheduler), который не знает, что внутри выполняется код на Python. Планировщик просто распределяет время процессора между потоками, как и для любого другого процесса, вне зависимости от языка программирования. То есть на уровне операционной системы GIL нам никак не мешает.

Чтобы понять, где именно мы теряем скосроть, давайте разберём, как это работает на уровне Python. Первый поток, которому операционная система даст время выполнения, захватывает GIL (Global Interpreter Lock) и выполняется. Когда операционная система даёт процессорное время следующему потоку, он начинает своё выполнение с попытки захватить GIL. Так как GIL занят, поток вынужден ждать его освобождения, поэтому переходит в режим ожидания с помощью системного вызова pthread_cond_wait. Он приостанавливает поток до тех пор, пока GIL не станет доступен.

pthread_cond_wait приостанавливает выполнение потока до тех пор, пока другой поток не освободит GIL. В результате второй и третий потоки находятся в состоянии ожидания, пока GIL не станет доступен. Операционная система самостоятельно определяет, когда поток может быть возобновлён. Такой механизм позволяет эффективно использовать ресурсы и избежать активного ожидания.

Если изображать выполнение потоков программы схематично, то оно будет выглядеть примерно так:

Когда у нас три потока, из-за GIL выполнять одновременно CPU-операции может только один из них. Пока первый поток выполняет CPU-нагруженную задачу, остальные потоки вынужденно ждут. Однако, когда поток переходит к IO-операции, он отпускает удержание GIL, поэтому, другие потоки могут получить управление и начать параллельную работу. Именно за счёт таких моментов ожидания, связанных с IO, и достигается выигрыш в производительности при многопоточности в Python не за счёт параллельного CPU, а за счёт перекрытия времени ожидания IO другими задачами.

Есть важный нюанс: планировщик операционной системы сам решает, когда выделить потокам время на выполнение. При этом GIL в CPython заставляет поток освобождать его примерно раз в 5 миллисекунд. Из-за этого поток может не успеть дойти до IO-операции, и управление передаётся другому потоку. В результате сначала могут выполниться три CPU-операции в разных потоках, а затем уже начнётся параллельная обработка IO. 

Тем не менее, общее время выполнения задачи не меняется — все три потока завершатся примерно одновременно, когда закончится последний из них. Так устроена многопоточность в Python с GIL.

Пример асинхронности

Как обстоят дела в асинхронной модели? Здесь задействован всего один поток, и задача — максимально эффективно использовать его ресурсы. Это означает, что мы стараемся минимизировать простои этого потока, переключаясь между задачами в моменты ожидания ввода-вывода (IO), чтобы не блокировать выполнение программы.

Вот асинхронный код, который делает то же самое, что и в примере многопоточности:

async def fetch(session: aiohttp.ClientSession):

   return await session.get("https://k2.cloud")

async with aiohttp.ClientSession() as session:

   tasks = [

       asyncio.create_task(fetch(session))

       for _ in range(100)

   ]

   await asyncio.gather(*tasks)

Асинхронность выполняется через event loop, который управляет задачами (tasks). Цикл событий начинает выполнение задачи и продолжает её выполнение до первого await. Встретив первый await, event loop углубляется во вложенные await вызовы до тех пор, пока не встретит операцию, действительно требующую ожидания — например, сетевой запрос или чтение файла.

Встретив ожидание, asyncio регистрирует соответствующий Socket в операционной системе, в данном случае Linux. Этот Socket используется как механизм оповещения: операционная система запишет туда результат работы.

После того, как все созданные Socket’ы зарегистрированы, event-loop опросит их через EPoll. Если другие задачи уже готовы к выполнению, например, задача 2 или 3, epoll вызывается с нулевым таймаутом. Это позволяет сразу получить готовые события без блокировки, не дожидаясь остальных. Если готовых к исполнению задач нет, то event loop рассчитывает подходящий таймаут для сна и вызывает epoll с этим значением, чтобы не нагружать процессор впустую. Как только одно или несколько событий становятся доступны, обработка продолжится, и будет выполняться следующая готовая задача.

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

Сходство и различие асинхронности и многопоточности

Правда, схема очень напоминает предыдущую, с многопоточностью? Если текущая задача выполняет CPU-операцию, то остальные задачи приостанавливаются и ожидают своей очереди. Они смогут продолжить выполнение только тогда, когда текущая задача передаст управление — например, при переходе к операции ввода-вывода (IO). 

В сценарии со множеством задач, где основное время уходит на ожидание IO, асинхронный и многопоточный подходы в Python по сути приходят к схожему финальному времени выполнения. Общая продолжительность выполнения всех задач оказывается сопоставимой. В обоих случаях реальная выгода достигается за счёт немедленного переключения на другую задачу в моменты ожидания, а не параллельного исполнения CPU-операций.

Что же общего и разного у этих подходов? Давайте разбираться. 

  • Сходство в ускорении

    И в многопоточном, и в асинхронном подходе ускорение достигается главным образом за счёт IO операций. Именно во время их ожидания появляются возможности для параллельной или чередующейся обработки других задач.

  • Важное отличие в переключении задач 

    В многопоточности переключением задач занимается операционная система, тот самый Scheduler. Планировщик самостоятельно распределяет выполнение между потоками, переключает thread’с между собой. В асинхронности переключение задач контролируется библиотекой asyncio через await. Если не поставить await перед операцией, то можно заблокировать весь event loop, например, тяжёлыми операциями.

  • Сложность — несопоставима 

    Асинхронный код сложнее для восприятия и изучения. Ошибки в нём могут обойтись дороже из-за их критичности и неочевидности. Ведь долгие годы экспертиза разработчиков формировалась именно в многопоточном стиле программирования, и чтобы переписать многопоточный проект на асинхронный манер, нужно чуть ли не нанимать новую команду. Дебажить такой проект и понимать, как он работает внутри — сложнее. Сейчас поясню, почему. 

Когда асинхронность оказывается многопоточностью

Вот пример асинхронной программы, которую сгенерировал Chat GPT для написания файлов. Кажется, что это асинхронность, ведь в ней есть async и await:

import aiofiles

async def write_file(i):

   filename = os.path.join(dir_path, f"file_{i}.txt")

   async with aiofiles.open(filename, "w") as f:

       await f.write("Hello, world!\n" * 10)

Но есть нюанс: если открыть реализацию, то окажется, что внутри асинхронных вызовов используется “await loop.run_in_executor()”, то есть, операция записи не считается по-настоящему асинхронной. Внутри запускается новый поток:

async def write(self, s):

    ...

    cb = partial(self._file.write, s)

    return await self._loop.run_in_executor(self._executor, cb)

Если сравнить этот пример со схемами из примеров выше, то выполнение будет выглядеть так:

CPU-нагрузка может быть и внутри async-кода, но большинство операций, таких как работа с файлами через aiofiles, на самом деле выполняются в thread pool. Это означает, что прироста в производительности относительно многопоточности не будет. Архитектура усложняется, чтобы сохранить совместимость с асинхронным интерфейсом. И всё ради того, чтобы асинхронное приложение могло использовать библиотеки, которые по сути остаются синхронными.

Можно подумать, что это нерелевантно для микросервисов или работы с БД. Но похожее происходит и в больших библиотеках. Например,  Motor — асинхронный драйвер для MongoDB. Это серьёзный проект, и в его документации указано, что он способен обрабатывать десятки тысяч запросов в секунду. Но проблема в том, что внутри Motor используется PyMongo, старый добрый синхронный драйвер для БД. По сути, Motor внутри себя создаёт несколько экземпляров PyMongo и оборачивает их в thread pool, предлагаю асинхронный интерфейс. То есть под капотом это всё та же многопоточность.

А что насчёт более крупных проектов? Например, Django — классический синхронный фреймворк, который в какой-то момент начал двигаться в сторону асинхронности. В нём добавили возможность писать асинхронные view-функци, middleware и прочие обработчики.

При этом асинхронные middleware в Django выглядят довольно громоздко. Чтобы их реализовать, нужно использовать специальный декоратор, а внутри описать сразу две функции. Одна нужна для синхронного формата, другая — для асинхронного. Можете сами посмотреть, как это выглядит:

Код
def sync_view(request):
    http_call_sync()
    return HttpResponse("Blocking HTTP request")

async def async_view(request):
    loop = asyncio.get_event_loop()
    loop.create_task(http_call_async())
    return HttpResponse("Non-blocking HTTP request")

Пользователи написали асинхронные MiddleWare:

 def simple_middleware(get_response):
  # One-time configuration and initialization goes here.
    if iscoroutinefunction(get_response):
        async def middleware(request): ...
    else:
        def middleware(request): …
          return middlewar

При этом в Django всё ещё используется множество middleware, которые работают только в синхронном режиме. Из-за этого Django вынужден внутри себя переключаться между асинхронным и синхронным контекстом. Эти переключения занимают время и добавляют накладные расходы, замедляя выполнение кода.

Значит ли это, что асинхронность переоценена? Зачем вообще большие библиотеки на неё переходят? Для примера можно посмотреть на PsycoPG 3, которая фактически задублировала кодовую базу и реализовала аналогичные методы и классы, но уже с префиксом async в названиях. Это очень похоже на то, как выглядит Django. Однако PsycoPG 3 при этом получила вот такие показатели по скорости:

В синхронной третьей версии по замерам мы получали около 700-800 RPS. А в асинхронной — 2200-2500, то есть в 3-4 раза больше. 

Сравнение скорости работы

Как и почему происходит такой рост? Разберём на примере:

# THREADING

def sync_task(_):

   return time.sleep(0.1)

with ThreadPoolExecutor(max_workers=n_requests) as executor:

   results = list(executor.map(fetch, range(n_tasks)))

# Async

async def async_task():

   return await asyncio.sleep(0.1)

async def run():

   tasks = [asyncio.create_task(async_task()) for  in range(ntasks)]

   return await asyncio.gather(*tasks)

В верхней части примера — синхронный код, реализующий ожидание и выполнение операций последовательно. В нижней части — асинхронный код, в котором каждая операция оформляется как отдельная задача (task) и управляется через event loop.

В синхронной версии на каждый «псевдо-запрос» создаётся поток (thread). В асинхронной версии на каждый запрос создаётся задача (task), которая которая на время ожидания await будет передавать управления event loop.

Теперь запустим оба варианта на разных объёмах входных данных — чтобы сравнить производительность и поведение в зависимости от количества запросов.

Число запросов

Threading

Async

100

1.01

1.01

1000

1.07

1.01

Видно, что при количестве запросов до 1000 разницы в скорости практически нет. От запуска к запуску она будет лишь в пределах допустимой погрешности. Разница становится заметной начиная с 1000 запросов и больше, но и она не очень велика.

Но что будет, если увеличить число запросов ещё в 10 раз, до 10 тысяч? В случае асинхронности код справится за 1.05, а в случае многопоточности — будет ошибка.

Число запросов

Threading

Async

100

1.01

1.01

1000

1.07

1.01

10000

RuntimeError: can’t start new thread

1.06

Причина в том, что нельзя создавать такое количество threads в многопоточности. Проблема совсем не в скорости, а в объёме и в количестве затрачиваемых ресурсов. В многопоточном подходе мы вынуждены ограничивать количество создаваемых потоков — это ограничения операционных систем. Поэтому для обработки 10 тысяч запросов уже не получится создать такое же количество потоков.

В случае асинхронности в тот самый единственный работающий поток просто добавляется новый сокет на операционную систему, не превышая её лимиты. Вот как это можно представить:

Способность сокетов к масштабированию выше, чем у многопоточности. Это выражается не только в количественных показателей ограничения потоков на 1 процесс, но и в занятой оперативной памяти. Каждый созданный thread в операционной системе требует 4 МБ. В них нужно хранить стек, информацию, а это дорого. Каждая созданная задача в asyncio занимает всего 4 КБ, ведь в этом случае достаточно создать сокет. Таким образом, тысяча потоков займут 4 ГБ оперативной памяти, в то время как асинхронность займёт порядка 100 МБ. Поэтому даже при примерно одинаковой скорости работы, асинхронность будет менее ресурснозатратна.

На больших данных в скорости тоже будут появляться изменения. Асинхронность будет выигрывать из-за переключения контекста, происходящего, когда поток готов работать. Потому что когда у нас тысяча потоков, переключение контекста в многопоточности начинает занимать ощутимое время. А ещё у нас повысится нагрузка на планировщик задач. Даже если поток спит, планировщик задач видит его в своём списке, и должен его в этом случае игнорировать. Это требует чуть больше логики, которая тоже влияет на производительность. В asyncio работа происходит только с тем, что готово работать.

Кто победил, или пара слов о No GIL

Значит ли это, что Async её победил? И да, и нет. 

В примере, который я показывал, нагрузка была полностью направлена на ввод-вывод — IO-bound без учёта реальных особенностей современных веб-приложений. Однако типичное веб-приложение сегодня устроено иначе, особенно популярные решения на Python, где активно используются Pydantic, DTO-классы и сериализация в JSON. 

Процесс обработки запроса обычно происходит так: приходит HTTP-запрос -> происходит роутинг и валидация через Pydantic -> формируется DTO -> данные сериализуются в JSON -> выполняется запись или чтение из базы данных через ORM.

Если оценить долю CPU и IO операций, то примерно 20% времени уходит на CPU-операции, а 80% — на IO. С появлением Python сборок без GIL многопоточность может использовать эти 20% CPU-времени значительно эффективнее. Асинхронность же в новых версиях Python никак не начинает эффективнее работать с CPU операциями. Поэтому при смешанных нагрузках подход с многопоточностью может оказаться производительнее. Общая схема  по CPU и IO операциям будет выглядеть так:

Давайте возьмем простой пример FastAPI приложения в своей синхронной и асинхронной версии и посмотрим, как оно будет работать:

@app.post("/sync-store")

def sync_store(item: Item):

   try:

       sync_redis.set(item.key, item.value)

       return {"status": "success"}

   except Exception as e:

       raise HTTPException(status_code=500, detail=str(e))

@app.post("/async-store")

async def async_store(item: Item):

   try:

       await async_redis.set(item.key, item.value)

       return {"status": "success"}

   except Exception as e:

       raise HTTPException(status_code=500, detail=str(e))

В приведённом выше примере используется упрощённый код одного из наших сервисов, на котором мы тестировали возможности Python 3.13 с No GIL. Первый вариант делает POST-запросы синхронно с использованием внутренней многопоточности FastAPI, второй — асинхронно. Этот код мы запускаем в Python3.13 с No GIL и на обычном Python 3.13. 

Многопоточность с GIL даёт около 2800 запросов в секунду. Этот результат можно принять за базовую точку сравнения.

Если использовать асинхронный маршрут, получаем почти 3500 запросов в секунду. Это прирост примерно на 600 запросов по сравнению с многопоточностью. 

С No GIL асинхронность остаётся на том же самом уровне. Она чуть быстрее, чем была. С многопоточностью в No GIL получаем 3540. Таким образом, у нас многопоточность в этом варианте становится чуть быстрее асинхронности. Таким образом, просто поменяв версию Python, мы получили прирост скорости, равный переписыванию всего нашего кода с синхронного на асинхронный формат.

Выводы

Для меня асинхронность — это, в первую очередь, не про скорость, а про экономию ресурсов. Если у нас до 1000 одновременных запросов, выбор между многопоточностью и асинхронностью не критичен. Многопоточность займёт, например, 4 ГБ оперативной памяти, и это допустимо. Если же речь идёт о более чем 10 000 одновременных соединений, асинхронность действительно эффективнее. При такой нагрузке мы обычно уже масштабируем приложение по горизонтали. Горизонтальное масштабирование эффективно для асинхронного и многопоточного подхода и будет давать прирост RPS. 

Единственное реальное преимущество асинхронности — это снижение стоимости эксплуатации. Она позволяет эффективнее использовать ресурсы и уменьшать количество серверов. Но важно задать себе вопрос: действительно ли у вас больше 10 000 одновременных запросов? Часто мы говорим о «хайлоаде», но на практике подобная нагрузка встречается далеко не у всех. Десятки тысяч RPS — это, скорее, специфичный кейс для нескольких крупных бигтех компаний. Даже у нас в облаке далеко не каждый сервис выходит на такой уровень. Благодаря большому опыту скорость разработки многопоточного кода выше, чем асинхронного.

Второй важный момент — это баланс CPU и IO операций. Многопоточность с No GIL начинает выигрывать именно там, где увеличивается доля CPU-операций. Это как раз тот случай, когда баланс сдвигается, например, с 20/80 в сторону 40/60. Чем больше у вас вычислений, тем больший прирост вы получите с многопоточностью после релиза No GIL. Cам по себе он не ускоряет обработку операций, а просто снимает ограничение на использование CPU несколькими потоками одновременно. Поэтому в сценариях с высокой нагрузкой на процессор это становится преимуществом. В случаях, когда баланс смещается в сторону IO — так бывает в случае работы с LLM агентами — проценты CPU и IO операций могут быть 5/95. В таком случае конечно же будет правильнее сразу смотреть на асинхронный подход.

Что выбираете вы для своих задач и проектов? Поделитесь в комментариях.

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


  1. segment
    06.10.2025 10:54

    Подскажите, а в чем заключается цель использования и усложнения кода на python, если он вообще не про производительность? Почему для backend не использовать обычные и более подходящие языки типа Go/C#/etc?


    1. WLMike
      06.10.2025 10:54

      Начинаете вы когда производительность не важна, а со временем она может стать важной. И перед вами выбор - переписать все на более быстрый язык, или добавить костылики внутри языка и увеличить скорость не переписывая. Для многих переписать все не вариант


      1. segment
        06.10.2025 10:54

        Но почему выбирают именно python для работы над задачей, которая заведомо не будет про "производительность"? На том же C# сделать API вроде не настолько сложнее, чем на python.


        1. WLMike
          06.10.2025 10:54

          Вы на старте не знаете нужна ли вам производительность - может через пол года ваш маленький проект умрет или всегда останется маленьким. При этом есть поверье, что на языках вроде Python, Ruby легче писать код с нуля за счет гибкости языка и библиотечек вроде рельсы или Джанго. Когда вы упретесь в производительность скорее всего будут ресурсы, как-то решить эту проблему


  1. MechanicZelenyy
    06.10.2025 10:54

    Вся статья строиться на измерениях текущей реализации asyncio, учитывающей GIL и работающей в одном потоке, но no GIL так же позволит переписать планировщик корутин, что бы он использовал пулы потоков и тогда async станет эффективным и в CPU bound задачах (за счёт параллельного выполнения корутин в разных потоках из пула).

    Вообще глобально многопоточность и асинхронность корутин это не много ортогональные вещи, но если говорить с точки зрения практики, но корутины всегда в пределе будут эффективнее потоков, так потоки управляются внешним планировщиком ОС, а корутины внутренним и имеют больше информации для оптимизации потока выполнения.


    1. WLMike
      06.10.2025 10:54

      Текущую реализацию очень сложно перевести в многопоточную - крайне мало вероятно, что это случится. Там слишком много завязано на то, что переключение может произойти только в очень определенных местах кода