При работе с Python да и другими языками программирования часто возникает необходимость ускорения выполнения кода, масштабирования обработки данных или работы с большим количеством сетевых запросов. Именно в Python для решения этих задач существуют три базовых метода. Это: threading, multiprocessing и asyncio. На первый взгляд – механизмы схожие. Но при детальном разборе ясно, что они решают принципиально разные задачи, опираются на разные модели исполнения и обладают своими ограничениями. В статье расскажу об особенностях каждого метода – будет интересно и познавательно.

Конкурентность и параллелизм в Python

Перед тем, как рассказать о конкретных инструментах Python, стоит выделить и определить два базовых понятия �� конкурентность и параллелизм.

  • Конкурентность (concurrency) – это способность ПО логически выполнять несколько задач одновременно. Однако эти задачи не всегда исполняются параллельно на уровне процессора. Если кратко, конкурентность – способ организации работы, при котором задачи быстро переключаются между друг другом, что создает ощущение одновременной работы.

  • Параллелизм (parallelism) – это фактическое одновременное выполнение нескольких вычислений на разных ядрах процессора. Здесь работа невозможна без соответствующей аппаратной поддержки и в Python напрямую упирается в архитектуру интерпретатора.

Для Python принципиально важно наличие Global Interpreter Lock (GIL) — глобальной блокировки интерпретатора. Благодаря ей в каждый момент времени байткод Python исполняется только одним потоком. Подобный подход упрощает управление памятью, но накладывает серьёзные ограничения на использование потоков для задач, значительно нагружающих CPU. В конечном итоге именно наличие/отсутствие GIL определяет различия в поведении модулей threading, multiprocessing и asyncio.

Модуль threading: обеспечиваем многопоточность в границах одного процесса

Threading – стандартный интерфейс, который используется для создания и управления потоками выполнения внутри одного процесса. В Python каждый поток представлен объектом Thread.

  • Поток запускается методом start(), далее интерпретатор передаёт управление целевой функции.

  • Метод join() нужен для ожидания завершения потока, что в итоге позволяет синхронизировать выполнение.

Основным ограничением threading является GIL. Да, потоки могут переключаться часто, но, как и сказано выше, в каждый момент времени только один из них исполняет байткод. По этой причине многопоточность не улучшает производительность для CPU-bound задач. В том числе метод не подойдет для обработки изображений, математических вычислений или сжатия данных.

При этом threading просто необходим для обработки I/O-bound задач, где основное время тратится на ожидание ввода-вывода. Во время блокирующих операций (сетевые запросы или чтение файлов) GIL освобождается, а другие потоки могут свободно работать. Поэтому чаще всего модуль применим для сетевых клиентов, загрузчиков данных и API с внешними сервисами.

Типичные проблемы и синхронизация

Потоки разделяют общую память, по этой причине разработчику нужно самостоятельно обеспечить к��рректный доступ к разделяемым данным. Для выполнения этой задачи в threading есть примитивы синхронизации. В их число входят: Lock, RLock, Semaphore и Condition.

  • Lock — запрещает нескольким потокам одновременно заходить в критическую секцию.

  • RLock (reentrant lock) — позволяет одному и тому же потоку захватывать его многократно без блокировки самого себя.

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

  • Condition — условная переменная, при ее использовании потоки ждут определённое событие и способны сообщать друг другу о моменте, когда оно наступит.

Обратите внимание. Некорректное применение блокировок приводит к двум классическим проблемам многопоточного программирования — race condition и deadlock. В первом случае результат работы программы становится непредсказуемым, во втором — потоки навсегда блокируют друг друга.

Кейс и пример кода

Была задача – скачать и сохранить на локальный SSD 150 файлов с сервера. При последовательной загрузке было бы потрачено много времени на ожидание ответа сервера. При использовании threading каждая загрузка запускалась в отдельном потоке. Пока часть потоков ждала, другие уже работали.

Пример реализации кода с модулем threading
Пример реализации кода с модулем threading

Саммари: threading нужен, когда программа часто ждёт внешние ресурсы (сеть, файлы, API) и важно не простаивать. Не нужен для ускорения тяжёлых вычислений.

Модуль multiprocessing: убираем ограничения GIL

В отличие от threading модуль multiprocessing создаёт в ОС отдельный процесс для выполнения каждой задачи. У каждого отдельного процесса есть собственное адресное пространство и выделенный экземпляр интерпретатора Python. Также каждому процессу назначается свой GIL. Это отличие принципиально. Благодаря ему модуль multiprocessing стал основным в Python для распараллеливания CPU-bound задач.

Базовая единица работы здесь – объект Process. Его интерфейс схож с threading.Thread, что часто вводит в заблуждение junior-специалистов.

  • Для запуска процесса используется метод start().

  • Завершение отслеживается через join().

Несмотря на схожесть кода в данном модуле применима иная модель исполне��ия с другими требованиями к архитектуре, чем в threading. Главный плюс multiprocessing – он способен задействовать несколько ядер процессора для параллельных вычислений. Это позволяет задействовать модуль для:

  • задач численного анализа;

  • обработки изображений;

  • машинного обучения;

  • криптографических операций.

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

Межпроцессное взаимодействие

Из-за изоляции процессов они не разделяют память напрямую. Чтобы обеспечить обмен данными, здесь используются отдельные очереди и каналы (Queue, Pipe). Также применимы структуры совместной памяти, например, Value и Array. В новых версиях Python (3.8 и выше) стала доступна полноценная shared memory. Что делают основные механизмы модуля:

  • Queue — очередь для передачи данных между процессами. Основана на сериализации объектов через pickle. При передаче больших объёмов данных часто снижает производительность.

  • Pipe — обоюдный канал связи между двумя процессами. Работает быстрее, чем Queue, но также использует сериализацию и применим только для ограниченного числа участников.

  • Value и Array — примитивы совместной памяти. Служат для хранения простых типов данных. С ними не нужна сериализация, но они ограничены по структуре и требуют аккуратной синхронизации доступа.

  • Shared memory (Python 3.8+) — техника совместного использования памяти без копирования данных. Нужна для работы с объёмными БД, но требует ручного управления жизненным циклом памяти.

Также можно использовать прокси-объект Manager. Его задача – обеспечивать работу с общим состоянием (словари, списки и другие структуры) для сразу нескольких процессов. Объект удобен, но отличается высоким overhead из-за постоянного межпроцессного взаимодействия.

Обратите внимание. В зависимости от того, какую ОС вы используете, процессы создаются по-разному. На Unix-подобных системах применима функция вызова ядра ОС «fork», и она даёт возможность относительно быстро клонировать процесс. Для Windows потребуется «spawn». Его минус: процесс не клонируется, вместо этого выполняется перезапуск интерпретатора с нуля. Так как весь модуль стартует заново, здесь обязательно наличие конструкции if name == "__main__".

Кейс и примеры кода

Один из моих знакомых IT-специалистов должен был обработать массив, содержащий 1,2 млн телеметрических записей от промышленного оборудования. Попытка обработки через threading результатов не дала (помешали ограничения GIL), вообще без дополнительных модулей программа обрабатывала информацию 15 минут. Неплохо, но при использовании метода multiprocessing общее время вычислений сократилось до 2 минут.

Код с модулем для окружающей среды Ubuntu 22.04 LTS (8-ядерный CPU), Python 3.10
Код с модулем для окружающей среды Ubuntu 22.04 LTS (8-ядерный CPU), Python 3.10

Если бы он работал на Windows и через «spawn», код был бы таким:

В Windows был аналогичный CPU
В Windows был аналогичный CPU

В качестве эксперимента мы запустили этот код на Windows. Если в Ubuntu время вычислений составило 2 минуты, то на ОС Win10 (с тем же 8-ядерным процессором) – 2 минуты 30 секунд.

Саммари: multiprocessing – метод мощный, но ресурсоёмкий. Учитывайте, что все функции должны быть сериализуемыми, а глобальные объекты нужно использовать с осторожностью. Подход хорош для CPU-bound задач с большим объёмом независимых вычислений, но неэффективен для I/O-bound.

Асинхронная модель без потоков и процессов - asyncio

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

  • Создаётся цикл событий (event loop) – служит для управления выполнением всех корутин и задач.

  • Определяются корутины – функции, объявленные через async def. По факту это «отложенные» задачи, которые можно приостанавливать и запускать снова.

  • В корутинах выставляются точки приостановки с await – при данном условии выполнение функции приостанавливается на время, управление возвращается циклу событий.

  • Цикл событий распределяет выполнение – если одна корутина стоит на паузе, цикл способен запускать другие корутины. Благодаря этому обеспечивается быстрое переключение между задачами без необходимости создания дополнительных потоков.

Модель исключает проблемы синхронизации и состояния гонки на уровне памяти. Но есть нюанс – любые операции ввода-вывода должны быть неблокирующими или вынесены в отдельные исполнители (executor). В противном случае они способны остановить выполнение всего цикла.

Преимущества и ограничения asyncio

В процессе взаимодействия с модулем были выявлены следующие плюсы:

  • один поток способен обслуживать тысячи I/O-операций параллельно без значительных накладных расходов;

  • минимальное потребление ресурсов системы при высоком числе соединений;

  • подходит для сетевых приложений (веб-серверы, прокси, API-шлюзы).

Дополнительное преимущество – цикл событий распределяет задачи централизованно, из-за чего упрощается контроль за выполнением I/O. Нашли и минусы:

  • asyncio не подходит для CPU-bound задач, потому что длительные вычисления блокируют event loop и замедляют выполнение кода;

  • в модуле все тяжёлые вычисления нужно выносить в multiprocessing или в пул потоков (loop.run_in_executor);

  • не все сторонние библиотеки поддерживают неблокирующий ввод-вывод (подходят aiohttp, asyncpg, aioredis, aiofiles и ряд других).

Отдельная проблема – сложности в сочетании синхронного и асинхронного кода. При невнимательном проектировании возможны блокировки программы или «зависание» event loop.

Обратите внимание. Отладка асинхронного кода сложнее из-за нелинейного порядка выполнения корутин.

Кейс по асинхронной обработке сетевых запросов

IT-команде нужно было получить данные с 1 500 внешних API, обработать их и сохранить в БД. При синхронных запросах через requests полный рабочий цикл занимал около 45 минут. Интеграция в код модуля asyncio сократила это время буквально до 1,5 минут. Проект реализовали на Win 10, Python 3.10, Intel Core i7-10700K (8 ядер, 16 потоков, частота до 5,1 ГГц).

Пример кода с использованием asyncio
Пример кода с использованием asyncio

Саммари: вам подойдет asyncio, если требуется выполнение I/O-bound задач, работа с сетью, файлами или БД. Но модуль неэффективен для CPU-bound задач (долгие вычисления блокируют event loop).

Итоговое сравнение модулей

Параметр / показатель

Threading

Multiprocessing

Asyncio

Тип параллельности

Логическое переключение задач

Многопроцессорное выполнение

Event loop

Использование CPU

Ограничено GIL

Полностью использует ядра CPU

Не нагружает CPU

Использование памяти

Общая память между потоками

У каждого процесса свое пространство

Один поток

Поддержка I/O-bound задач

Отлично

Хорошо

Отлично

Поддержка CPU-bound задач

Плохо

Отлично

Плохо

Накладные расходы на создание задач

Низкие

Высокие

Низкие

Сложность синхронизации

Высокая

Средняя

Низкая

Взаимодействие между задачами

Общая память, примитивы синхронизации

Очереди, каналы, shared memory, Manager

Передача через await, coroutines

Поддержка платформ

Кроссплатформенная

Сложность отладки

Средняя

Средняя

Высокая

Тип задач

I/O-bound

CPU-bound

I/O-bound

Примеры использования

API-клиенты, загрузчики файлов, сетевые клиенты

Обработка массивов данных, численный анализ, ML, криптография

HTTP-клиенты/серверы, WebSocket, брокеры сообщений, асинхронные файлы

Старт задач

Немедленный

Медленный на Windows, быстрый на Unix

Немедленный

Поддержка сторонних библиотек

Все синхронные

Все синхронные

Асинхронные или через run_in_executor

Недостатки

Deadlock, race condition

Высокие накладные расходы, сериализация

Блок event loop, сложная архитектура

Простота написания кода

Средняя

Средняя

Средняя / высокая

Масштабируемость

Ограничена числом потоков и GIL

Масштабируется по ядрам CPU

Масштабируется по количеству I/O задач

Заключение

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