Пулы потоков и процессов

Создавать поток на каждую задачу — расточительно. Пул переиспользует фиксированный набор воркеров.

Пул (pool) — заранее созданный фиксированный набор потоков или процессов-воркеров, которым раздаются задачи из общей очереди.

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

concurrent.futures

Стандартная библиотека даёт единый удобный интерфейс — ThreadPoolExecutor (для I/O) и ProcessPoolExecutor (для CPU). Менять одно на другое — буквально одна строка.

from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    return f"загружено {url}"

urls = [f"site{i}" for i in range(100)]

with ThreadPoolExecutor(max_workers=8) as pool:
    # 8 воркеров разбирают 100 задач
    results = list(pool.map(fetch, urls))

print(results[0], "...", len(results), "шт.")

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

Очередь задач на фиксированных воркерах

import collections

tasks = collections.deque(range(1, 7))  # 6 задач
workers = 2                              # 2 воркера
round_no = 0

while tasks:
    round_no += 1
    batch = []
    for _ in range(workers):
        if tasks:
            batch.append(tasks.popleft())
    print(f"раунд {round_no}: воркеры взяли {batch}")

Вывод:

раунд 1: воркеры взяли [1, 2]
раунд 2: воркеры взяли [3, 4]
раунд 3: воркеры взяли [5, 6]

Зачем ограничивать число воркеров

  • Контроль ресурсов. 8 воркеров не положат сервер 10000 одновременными запросами.
  • Меньше переключений. Не плодим тысячи потоков — меньше накладных расходов.
  • Предсказуемость. Нагрузка ограничена сверху, систему легче рассчитать.

Как работает под капотом

Пул держит внутреннюю потокобезопасную очередь задач. Свободный воркер берёт задачу, выполняет её и возвращается за следующей. Результат каждой задачи оборачивается в объект Future — «обещание результата», который можно подождать (.result()) или опросить. map раздаёт задачи и собирает результаты по порядку, а submit возвращает отдельные Future, удобные, когда задачи завершаются вразнобой.

Частые ошибки

  • Брать ThreadPoolExecutor для CPU-задач. GIL не даст ускорения — нужен ProcessPoolExecutor.
  • Ставить max_workers в огромное число. Тогда теряется весь смысл пула — снова тысячи потоков.
  • Не обрабатывать исключения в задачах. Ошибка внутри воркера всплывёт при .result() — её надо ловить.

Итог

  • Пул переиспользует фиксированный набор воркеров вместо создания нового на каждую задачу.
  • ThreadPoolExecutor — для I/O, ProcessPoolExecutor — для CPU.
  • Ограничение воркеров контролирует нагрузку и переключения.
  • Результаты приходят через Future; исключения всплывают при получении результата.
Проверьте себя
1. Зачем использовать пул потоков вместо создания нового потока на каждую задачу?
AПул запрещает многопоточность
BПул переиспользует фиксированный набор воркеров, экономя на создании и переключениях
CПул всегда работает на одном потоке
DПул отключает GIL
2. Какой executor выбрать для CPU-bound задач в Python?
AThreadPoolExecutor
BProcessPoolExecutor
CЛюбой, разницы нет
DНикакой — CPU-задачи нельзя ускорить
3. Что представляет собой объект Future, возвращаемый пулом?
AНовый поток
B«Обещание результата» задачи, который можно подождать или опросить
CКопию всей программы
DФайл на диске