В программировании понятия конкурентность и параллелизм часто воспринимаются как сверхсложные. На практике можно столкнуться с сотнями сложных объяснений, низкоуровневых API и абстракций. Но для большинства реальных задач, например, сетевого скрапинга, обработки файлов или избежания блокировок пользовательского интерфейса, вам не нужно разбираться во всех тонкостях.
Python предлагает очень простые средства для решения таких задач прямо в стандартной библиотеке — пул исполнителей (pool executors).
Что такое пул исполнителей
Пул исполнителей — это абстракция, которая скрывает детали создания и управления потоками или процессами. Вы просто говорите: «выполни эти задачи параллельно», а Python сам раздает их рабочим.
В Python это реализовано через модуль concurrent.futures, а сами исполнители — ThreadPoolExecutor (пул потоков) и ProcessPoolExecutor (пул процессов).
Пример: конкурентное скачивание страниц
Рассмотрим классическую задачу: у вас есть список URL-адресов, и нужно скачать каждую страницу и распечатать <title> из HTML. Если делать это последовательно, значит одна страница будет скачана за другой и весь процесс займет много времени из-за сетевых задержек:
import time
import re
from urllib.request import Request, urlopen
URLs = [
"https://example.com/page1",
"https://example.com/page2",
# ... ещё ссылки
]
title_pattern = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE)
def fetch_url(url):
start = time.time()
with urlopen(Request(url, headers={"User-Agent":"Mozilla/5.0"}) ) as response:
html = response.read().decode("utf-8")
match = title_pattern.search(html)
title = match.group(1) if match else "Unknown"
end = time.time()
print(f"Fetched {url} in {end - start:.2f}s")
return title
start = time.time()
for url in URLs:
title = fetch_url(url)
print(url, title)
print("Total:", time.time() - start)Такой код будет ждать завершения каждого запроса перед переходом к следующему. Если страница долго отвечает, то скрипт простаивает.
Улучшение с ThreadPoolExecutor
Теперь тот же пример, но с пулом потоков:
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=len(URLs)) as executor:
futures = {executor.submit(fetch_url, url): url for url in URLs}
for future in as_completed(futures):
url = futures[future]
title = future.result()
print(f"{url} -> {title}")Что здесь происходит:
executor.submit(fetch_url, url)отправляет задачу в пул и сразу возвращает объектFuture.as_completedпозволяет обрабатывать результаты по мере готовности, а не в порядке отправки.
Преимущество: пока один поток ждет сетевого ответа, другой уже выполняет свою работу. За счет этого общее время выполнения сокращается почти до максимального ответа из всех запросов.
Потоки vs процессы
Когда использовать потоки (ThreadPoolExecutor)
Пулы потоков подходят для операций ввода-вывода:
сетевые запросы;
чтение/запись файлов;
любые задержки, связанные с внешними сервисами.
Потоки здесь эффективны, потому что вся задержка не в вашем Python-коде, а во внешних системах.
Когда использовать процессы (ProcessPoolExecutor)
Процессы нужны, когда задачи CPU-bound, то есть нагружают процессор:
тяжёлые вычисления;
обработка больших массивов данных;
работа, не освобождающая GIL (глобальную блокировку интерпретатора Python).
Процессы позволяют использовать несколько ядер процессора, параллельно выполняя задачи на уровне OS-процессов, минуя ограничения GIL.
Пример замены:
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=5) as executor:
results = executor.map(cpu_intensive_task, inputs)Здесь каждая задача запускается в отдельном процессе, и Python-интерпретатор не блокирует выполнение других задач.
Когда это действительно полезно
Пулы исполнителей не решают все возможные проблемы многозадачности, но они идеально подходят для:
ускорения IO-bound операций;
обработки многозадачных, но коротких задач;
избегания блокировки основного потока (например, UI-потока);
написания аккуратного и понятного кода, который намного проще, чем вручную управлять потоками и процессами.
Примеры реальных задач
Скрапинг сайта
Если ваш скрипт должен пройтись по сотне страниц и собрать данные, пул потоков сократит общее время в разы.
with ThreadPoolExecutor(max_workers=20) as executor:
futures = [executor.submit(scrape, url) for url in list_of_urls]Архивация файлов
Если надо упаковать десятки больших файлов, пул процессов позволит разделить работу по ядрам:
with ProcessPoolExecutor(max_workers=4) as executor:
executor.map(zip_file, files)Важные нюансы
Помните про GIL: в Python потоки не дают настоящего параллелизма CPU-bound задачам. Тут помогут именно процессы.
Объекты
Futureпозволяют получать результат задачи или ловить исключения.Вызов
future.result()блокирует до готовности результата, поэтому стоит использоватьas_completed,mapили другие обходы.Для multiprocessing в Windows обязательно используйте защиту
if __name__ == "__main__"чтобы избежать бесконечного порождения процессов.