Асинхронный Python и различные формы многозадачности

Не один раз видел, что не все программисты на Python понимают, в каких случаях стоит создавать отдельный процесс, а когда можно обойтись потоком. Да и к тому-же, сейчас многие могут думать, что AsyncIO — самый правильный способ писать веб-приложения. Я попробую объяснить разницу между этими формами многозадачности в Python и когда их лучше применять.

Задачи в компьютере делятся на два вида:

CPU Bound — операции, задействующие центральный процессор. Как правило, это вычисления: работа с матрицами, изображениями, анализ больших массивов данных, вычисление последовательности Фибоначчи или майнинг биткоинов.

I/O Bound — задачи, использующие ввод/вывод: работу с диском или сетью. К таковым относятся веб-сервера или часть веб-приложения, которая принимает запросы от клиентов.

Процессы и потоки

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

Потоки, они же треды или нити — единица исполнения внутри процесса. Часто у процесса один поток, называемый основным, но по желанию, программа может создать любое их количество. При старте потоку не выделяется отдельных ресурсов, вместо этого он использует память и ресурсы породившего его процесса. За счет этого потоки быстро стартуют и останавливаются.

Обеспечением многозадачности занимается планировщик — часть ядра ОС, которая по очереди загружает потоки исполнения в центральный процессор.

На одном ядре процессора в единицу времени выполняется один поток. Он работает до тех пор, пока не израсходует свой квант времени (по умолчанию равен 100 мс) или сам не уступит управление следующему потоку, совершив системный вызов. В Python каждый процесс и поток — это нативный процесс и поток операционной системы, так что, для него эти утверждения так-же верны.

Но в эталонной реализации Python — CPython присутствует печально известный GIL (Global Interpreter Lock), по сути, глобальный семафор, который не дает одновременно работать больше чем одному потоку в рамках процесса интерпретатора.

Несколько фактов о GIL

  • GIL защищает структуры данных работающего потока от проблем конкурентного доступа. Например предотвращает состояние гонки при изменении значения счетчика ссылок объекта.

  • GIL упрощает интеграцию non thread safe библиотек на С. Благодаря GIL у нас так много быстрых модулей и биндингов почти ко всему.

  • Библиотекам на C доступен механизм управления GIL. Так например NumPy отпускает его на долгих операциях.

Когда поток начинает работу, он выполняет захват GIL. Спустя какое-то время планировщик процессов решает, что текущий поток поработал достаточно, и передает управление следующему потоку. Поток №2 видит, что GIL захвачен, так что он не продолжает работу, а погружает себя в сон, уступая процессор потоку №1.

Но поток не может удерживать GIL бесконечно. До Python 3.3 GIL переключался каждые 100 инструкций машинного кода. В поздних версиях GIL может быть удержан потоком не дольше 5 мс. GIL так-же освобождается, если поток совершает системный вызов, работает с диском или сетью.

По сути, GIL в питоне делает бесполезной идею применять потоки для параллелизма в вычислительных задачах. Они будут работать последовательно даже на многопроцессорной системе. На CPU Bound задачах программа не ускорится, а только замедлится, так как теперь потокам придется делить пополам процессорное время. При этом I/O операции GIL не замедлит, так как перед системным вызовом поток отпускает GIL.

На этой грустной ноте можно придти к выводу, что для распараллеливания задач, которые завязаны на ввод/вывод хватит и тредов. А вот вычислительные задачи следует запускать в отдельных процессах.

Сопрограммы и AsyncIO

Теперь представим, что мы пишем HTTP или WebSocket сервер, который каждое подключение обрабатывает в отдельном потоке.

Здесь вполне можно создать 100, может даже 500 потоков, чтобы обработать нужное количество одновременных соединений. Для коротких запросов это даже будет работать и позволит выдержать нагрузку в 5000 RPS на самом дешевом инстансе в DO за пять баксов — вполне неплохо. Если у вас меньше, возможно здесь и не нужны никакие AsyncIO/Tornado/Twisted.

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

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

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

Есть старые добрые сопрограммы — то, что сейчас предлагает AsyncIO и Tornado. Их еще называют корутинами или просто потоками на уровне пользователя. Модная нынче штука, но, далеко не новая, а использовалась еще во времена, когда в ходу были ОС без поддержки многозадачности.

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

import asyncio
import aiohttp

async def simulate_io():
    async with aiohttp.ClientSession() as session:
        async with session.get('http://python.org') as resp:
            await resp.text()

async def coro(name):
    await simulate_io()
    print(f'{name}-1')
    await simulate_io()
    print(f'{name}-2')

async def main():
    await asyncio.gather(coro('A'), coro('B'))

loop = asyncio.get_event_loop()
loop.run_until_complete(main())

Результаты получим примерно в таком порядке: A-1 B-1 A-2 B-2. Из примера видно, что сопрограммы работали по очереди, при этом все происходит в одном потоке. Когда сопрограмма A вызывает simulate_io(), управление передаётся сопрограмме B. Она делает тот-же вызов и управление возвращается сопрограмме A, которая печатает результат, так как I/O для неё уже завершился и переходит ко следующему вызову simulate_io().

Как и в случае с тредами, асинхронщина бесполезна для вычислений. Тут ситуация даже хуже, так как зависший на вычислениях поток рано или поздно GIL отпустит, а вот блокирующий код в сопрограмме заблокирует весь поток, до тех пор, пока не исполнится весь. В отличии от нативных тредов, у сопрограмм отсутствует прерывание по таймеру. Передача управления следующей сопрограмме происходит вручную, при явном вызове конструкции await (или yield, если используются generator-based корутины). Поэтому важно следить, чтобы в асинхронных программах не было блокирующего кода и использовались только асинхронные вызовы, а все вычисления происходили в отдельных процессах.

def fib(n):
    if n <= 2:
        return 1
    return fib(n - 1) + fib(n - 2)

async def coro(name):
    await simulate_io()
    print(f'{name}-1')
    fib(35)  # здесь весь поток блокируется
    await simulate_io()
    print(f'{name}-2')

Итого

Потоки будут проще, если у вас типичное веб-приложение, которое не зависит от внешних сервисов, и относительно конечное количество клиентов, для которых время ответа будет предсказуемо-коротким.

AsyncIO подойдет, если приложение большую часть времени тратит на чтение/запись данных, а не их обработку. Например, у вас много медленных запросов — вебсокеты, long polling или есть медленные внешние синхронные бекенды, запросы к которым неизвестно когда завершатся.

Ссылки по теме