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

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

Пул (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Файл на диске
py
Курс по теме
Пройдите курс «Python с нуля» — по шагам, с проверкой
8 уроков · ~14 ч · теория, упражнения и экзамен с бейджем
Открыть курс →