Підходи до паралельного виконання задач у Python

У сучасній розробці ми часто стикаємося із задачами, які або довго чекають на відповідь (мережа, диск), або сильно навантажують процесор, сповільнюючи роботу всієї програми. Щоб ефективно використовувати ресурси та не блокувати виконання коду, Python пропонує три базові підходи:

  • асинхронність (asyncio) — конкурентність в одному потоці через event loop, оптимально для I/O-операцій (мережа, файли, БД);
  • багатопотоковість (threading) — кілька потоків в одному процесі, корисно для I/O-задач і інтеграцій, але в CPython є обмеження через GIL;
  • багатопроцесність (multiprocessing) — кілька процесів, що дає справжній паралелізм для CPU-задач (обхід GIL).

1) Асинхронне програмування (asyncio)

Що це таке

Асинхронність дозволяє виконувати тисячі задач “одночасно” в межах одного потоку, ефективно використовуючи час, поки програма чекає на зовнішні події. Замість того щоб простоювати в очікуванні відповіді сервера, код перемикається на виконання іншої корисної роботи.

asyncio — стандартна бібліотека Python для написання конкурентного коду через async/await. Вона використовується як основа для багатьох асинхронних фреймворків і бібліотек (веб-сервери, драйвери БД, черги тощо).

Базова модель:

  • є event loop;
  • корутини (async def) створюють tasks;
  • задачі кооперативно віддають керування під час await (наприклад, очікування мережевої відповіді).

Офіційний концептуальний огляд більш детально пояснює ці “будівельні блоки” (event loop, coroutine, task, await).

Коли asyncio дає виграш:

Asyncio найкраще працює для I/O-bound навантажень:

  • багато мережевих запитів (HTTP API, веб-скрейпінг з обмеженнями);
  • паралельна робота з БД/чергами (за наявності async-драйверів);
  • одночасне обслуговування багатьох клієнтів (асинхронні веб-сервери).

Якщо задача CPU-bound (компресія, криптографія, складні обчислення), asyncio не пришвидшить виконання — бо обчислення не роблять await і блокують event loop.

Приклад коду

import asyncio

async def fetch_simulated(i: int) -> str:
    await asyncio.sleep(0.2)  # імітуємо очікування I/O
    return f"result-{i}"

async def main():
    # запускає задачі конкурентно в межах одного event loop
    results = await asyncio.gather(*(fetch_simulated(i) for i in range(10)))
    print(results)

asyncio.run(main())

Практичні зауваження

  • Якщо у вас є “звичайна” (синхронна) функція, яка блокує потік, у asyncio її варто винести в окремий потік (наприклад, через asyncio.to_thread(…) у сучасних версіях Python) або використати пул потоків через loop.run_in_executor. Розділ про взаємодію з потоками/виконанням поза loop детально описаний у документації asyncio Tasks.
  • Уникайте time.sleep() всередині async def: він блокує event loop; використовуйте asyncio.sleep().

2) Багатопотоковість (threading)

Що це таке

Багатопотоковість — це класичний спосіб виконувати кілька операцій паралельно, розділяючи спільну пам’ять одного процесу. Це дозволяє програмі залишатися чутливою (responsive), виконуючи фонові задачі без зупинки основного коду.

Модуль threading дає потоки всередині одного процесу, які ділять пам’ять. Для I/O-операцій це часто зручно: один потік чекає мережу/диск, інший продовжує роботу.

Важлива деталь CPython: через Global Interpreter Lock (GIL) тільки один потік може виконувати Python-байткод одночасно, тому для CPU-задач багатопотоковість зазвичай не дає прискорення. Офіційна документація threading прямо про це попереджає і радить для multi-core використовувати multiprocessing або concurrent.futures.ProcessPoolExecutor.

Thread Safety (Потокобезпека)

Оскільки потоки мають спільну пам’ять, існує ризик стану гонитви (race condition), коли кілька потоків одночасно змінюють одні й ті самі дані. Тому при роботі зі спільними змінними критично важливо використовувати механізми синхронізації (наприклад, Lock), щоб уникнути непередбачуваної поведінки.

Коли потоки — правильний вибір

  • I/O-bound задачі з синхронними бібліотеками (старі SDK, драйвери, файлові операції);
  • паралельне виконання “легких” I/O-викликів, де asyncio недоречний або складний для інтеграції;
  • інтеграції, де потрібен простий паралелізм без переписування під async.

Приклад через ThreadPoolExecutor (рекомендований high-level API)

Стандартний модуль concurrent.futures надає єдиний інтерфейс для потоків і процесів.

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def io_task(i: int) -> str:
    time.sleep(0.2)  # імітація блокуючого I/O
    return f"done-{i}"

with ThreadPoolExecutor(max_workers=10) as ex:
    futures = [ex.submit(io_task, i) for i in range(20)]
    for f in as_completed(futures):
        print(f.result())

3) Багатопроцесність (multiprocessing)

Що це таке

Багатопроцесність створює повні незалежні копії інтерпретатора Python для кожної задачі. Це єдиний спосіб у Python задіяти всі ядра процесора для важких обчислень, оскільки кожен процес працює автономно.

multiprocessing запускає окремі процеси, а не потоки. Це дозволяє “обійти” GIL, бо кожен процес має свій інтерпретатор і свою пам’ять. Офіційна документація зазначає, що multiprocessing “effectively side-stepping the GIL” і дозволяє повноцінно використовувати кілька CPU.

Коли процеси — правильний вибір

  • CPU-bound задачі: обробка великих масивів/файлів, кодування/декодування, важкі перетворення;
  • паралельні обчислення на багатоядерних машинах;
  • коли потрібне реальне прискорення саме обчислень.

Приклад через ProcessPoolExecutor

from concurrent.futures import ProcessPoolExecutor
import math

def cpu_task(n: int) -> int:
    # імітація CPU-навантаження
    s = 0
    for i in range(1, n):
        s += int(math.sqrt(i))
    return s

if __name__ == "__main__":
    with ProcessPoolExecutor() as ex:
        results = list(ex.map(cpu_task, [200_000, 220_000, 240_000, 260_000]))
    print(results)

ProcessPoolExecutor — частина concurrent.futures, який прямо описує, що асинхронне виконання може виконуватись потоками або процесами з однаковим інтерфейсом.

Критичні нюанси multiprocessing

  • Захист точки входу (if __name__ == “__main__”:): Це обов’язкова конструкція для multiprocessing. Без неї на Windows та macOS (де використовується метод запуску “spawn”) нові процеси будуть намагатися знову імпортувати скрипт, що призведе до нескінченної рекурсії та краху програми.
  • Використання пам’яті: Процеси ізольовані і не ділять спільну пам’ять так легко, як потоки. Кожен процес споживає додаткову оперативну пам’ять (RAM), тому створення тисяч процесів може швидко вичерпати ресурси системи.
  • Серіалізація (pickle): Аргументи та результати передаються між процесами через механізм pickle, тому об’єкти мають бути серіалізованими, а передача великих обсягів даних може бути повільною.

Як обрати підхід

I/O-bound (мережа/диск/БД)

  • якщо бібліотеки підтримують async → asyncio
  • якщо бібліотеки синхронні → ThreadPoolExecutor / threading

CPU-bound (обчислення)

  • multiprocessing або ProcessPoolExecutor (реальний паралелізм, обхід GIL)

Змішане навантаження

  • I/O частина в asyncio, важкі обчислення винести в ProcessPoolExecutor;
  • або синхронний код + ThreadPoolExecutor для I/O і ProcessPoolExecutor для CPU.

Типові помилки

Помилка 1: очікувати, що потоки прискорять CPU-обчислення в CPython

Через GIL у CPython один потік виконує Python-код одночасно; для CPU-задач обирайте процеси.

Помилка 2: блокуючі виклики всередині asyncio

time.sleep(), синхронні HTTP-клієнти або драйвери БД блокують event loop. Для таких викликів — потрібне використання async-бібліотеки або винесення в thread pool (to_thread / executor).

Помилка 3: відсутність timeouts та обробки скасування

У asyncio передбачайте timeouts і коректне скасування задач; у пулах — обробляйте винятки Future і не залишайте завислі воркери.

Підсумок

Вибір між асинхронністю, потоками та процесами — це завжди компроміс між складністю коду та ефективністю використання ресурсів. Універсального рішення не існує, але є чіткі сценарії для кожного інструменту:

  • Asyncio — це стандарт сучасної веб-розробки в Python. Обирайте його для високонавантажених мережевих сервісів (HTTP, WebSocket), де потрібно тримати тисячі з’єднань одночасно. Це найлегший спосіб масштабування I/O-операцій, але він вимагає повної підтримки з боку бібліотек.
  • Багатопотоковість (Threading) залишається актуальною для скриптів автоматизації, роботи з файловою системою або інтеграції з legacy-кодом, який не вміє працювати асинхронно. Це “дешевий” спосіб отримати конкурентність для I/O, але пам’ятайте про GIL — він не дозволить прискорити обчислення.
  • Багатопроцесність (Multiprocessing) — це “важка артилерія” для CPU-задач. Якщо вашій програмі потрібно рахувати математику, обробляти зображення чи ML-моделі на всіх ядрах процесора — це єдиний шлях обійти GIL. Плата за це — вище споживання оперативної пам’яті.

Також, варто зауважити, що не потрібно ускладнювати архітектуру без потреби. Для багатьох типових задач високорівневий інтерфейс concurrent.futures (ThreadPoolExecutor/ProcessPoolExecutor) буде достатнім і зробить ваш код значно чистішим та безпечнішим, ніж ручне керування потоками чи процесами.