Что использовать: ThreadPoolExecutor или ProcessPoolExecutor

автор рубрика
Что использовать: ThreadPoolExecutor или ProcessPoolExecutor

В языке Python есть классы ThreadPoolExecutor и ProcessPoolExecutor, которые предназначены для параллельной обработки данных. Какой из стоит них использовать и в каких случаях. В этой статье вы узнаете, почему операции ввода-вывода стоит использовать в тредах, а задачи с большими вычислениями в процессах.

Что такое ThreadPoolExecutor

Каждый тред принадлежит процессу (читай: программе) и может иметь разделяемые данные с другими тредами этого того же процесса. Треды создаются самой операционной системой по запросу процесса.

Вы можете создавать пул тредов (набор управляемых тредов) путем инициализации объекта ThreadPoolExecutor и указания количества тредов через параметр max_workers. Например, так:

executor = ThreadPoolExecutor(max_workers=10)

Теперь можно передать задачу для выполнения этому пулу через методы map и submit.

Метод map работает так же как и встроенная в Python функция map. Он принимает на вход целевую функции и итерируемый объект (например, список). Целевая функция затем вызывается для каждого элемента итерируемого объекта. Возвращается модифицированный итерируемый объект. Самый простой пример — нужно умножить каждый элемент списка на число.

Потоковая обработка в Apache Spark

Код курса
SPOT
Ближайшая дата курса
19 сентября, 2022
Длительность обучения
16 ак.часов
Стоимость обучения
40 000 руб.

Сам вызов map не блокируется, но результат каждого преобразования будет заблокирован, пока задача не будет выполнена. Например:

# Вызов функции для каждого элемента списка
for result in executor.map(task, items):
    # выполнение над результатом

Также задачи можно дать методу submit, который принимает целевую функцию и любое количество параметров. Возвращает объект Future.

Объект Future может быть использован для запроса статуса задачи (завершена done, выполняется running, отменена cancelled). Также он может для получения результата или исключения, вызванное выполненной задачей. Вызовы result и exception блокируются, пока задача, ассоциированная с Future, не завершена. Например:

# передать задачу пулу и получить объект Future немедленно
future = executor.submit(task, item)
# получить результат как только задача завершена
result = future.result()

Пул тред останавливается вызовом shutdown. После него освобождаются ресурсы, занятые тредами.

executor.shutdown()

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

# создание пула
with ThreadPoolExecutor(max_workers=10) as executor:
    # вызов функции для каждого элемента списка
    for result in executor.map(task, items):
        # обработка результата
    # ...
# остановка пула после выхода из контекстного менеджера

Что такое ProcessPoolExecutor

Процесс имеет один главный тред и дополнительные треды, если они были запущены. Сами процессы создаются системным вызовом fork в Unix-системах. Они создаются и управляются операционной системой.

Пул процессов инициализируется классом ProcessPoolExecutor, количество желаемых процессов передается через параметр max_workers:

executor = ProcessPoolExecutor(max_workers=10)

Передача задачи пулу процессов осуществляется через map и submit. И далее такой же метод для остановки пула процессов. И также метод map возвращает Future. “Пул тредов” замените на “пул процессов” и получите все то, что написано выше. Получается, что worker может означать и тред, и процесс.

В чем разница между ProcessPoolExecutor и ThreadPoolExecutor?

В первую очередь, у этих классов разные сущности (и есть тот самый worker). Процесс обладает тредами, а тред принадлежит процессу. И то, и то управляется ОС. Разница между ними стоит заметить при использование разделяемых данных, межпроцессорном взаимодействии и глобальной блокировки интерпретатора.

Разделяемая память и межпроцессорная взаимодействие

Треды могут разделять память внутри одного процесса. Это означает, что один тред может получить доступ к одним и тем же данным и состоянием процесса. Поэтому обращение к памяти у тредов прямое, без всяких сложностей.

Процессы же не имеют разделяемой памяти.

Вместо этого, сами данные должны быть передаваться по процессам. Это называется межпроцессорным взаимодействием. Несмотря на то, что это происходит где-то на уровне ядра ОС, это накладывает ограничения на то, какие данные и состояние могут передаваться между процессами. И это верно, ведь процессы работают только в рамках собственной памяти, а чтобы взаимодействовать с другими процессам нужно ядро ОС, которое преодолевает “защиту памяти”.

Так, делиться данными между тредами легко и просто, а делиться данными между процессами сложно и затратно.

Нейронные сети на Python

Код курса
PYNN
Ближайшая дата курса
18 июля, 2022
Длительность обучения
24 ак.часов
Стоимость обучения
45 000 руб.

Глобальная блокировка интерпретатора (GIL)

Треды подчиняются механизму GIL (Глобальная блокировка интерпретатора). В то время как дочерние процессы, вызванные ProcessPoolExecutor, — нет.

Интерпретатор CPython использует синхронизацию для того, чтобы только один тред выполнялся в один момент времени внутри запущенного процесса (анимация в Википедии очень информативная).

GIL есть в каждом процессе, запущенным CPython, но этой блокировки нет между процессами. Это означает, что процессы от ProcessPoolExecutor могут выполняться в один момент времени.

Когда нужно использовать параллельную обработку данных?

Когда стоит вообще использовать параллельную обработку данных? Выделим некоторые кейсы. Используйте параллельную обработку когда:

  • задача может быть выражена одной функцией, которая не имеет побочных эффектов (не делает ничего лишнего);
  • эта функция Python должна быть предельна проста;
  • требуется выполнить ту же самую задачу множество раз, т.е. не должно выполняться что-то разное;
  • требуется применить одну и ту же функцию к каждому элементу в цикле.

Оба исполнителя показывают наилучшие результаты, когда применяется одна и та же функция без побочных эффектов к разным данным (гомогенная задача — гетерогенные данные). Именно поэтому такое выполнение зарекомендовало себя в технологии MapReduce.

Параллельная обработка — очень сложная дисциплина в компьютерных науках. Поэтому следует ее применять ровно тогда, когда вы знаете, что делаете. Те случаи, когда ее не нужно использовать:

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

Когда стоит использовать ThreadPoolExecutor?

Треды не подходят для всех случаев, когда нужно запустить задачу в фоновом режиме.

А вот ThreadPoolExecutor стоит использовать в задачах ввода-вывода. Задача ввода-вывода подразумевает чтение и запись с диска, файла или сокета по сети.

Скорость операций ввод-вывода ограничена скоростью дисков и пропускной способностью сети. По сравнению с процессором (CPU) память очень и очень медленная, что бы под памятью не подразумевалось (HDD, SDD, оперативная или флеш-память).

Большую часть времени при выполнении этих операций процессор будет стоять в ожидании. Чтобы ускорить этот процесс операции ввода-вывода можно растащить по тредам.

Примерами операций ввода-вывода являются:

  • чтение и/или запись файла с диска;
  • чтение и/или запись в стандартные потоки (stdin, stdout, stderr), т.е. в терминал;
  • печать документа;
  • загрузка в/из сети файла;
  • запрашивание к серверу;
  • запрашивание к базе данных;
  • фотографирование или запись видео;

Когда не нужно использовать ThreadPoolExecutor? Вы не должны использовать ThreadPoolExecutor в задачах, где задействованы ресурсы процессора. Мы уже говорили о GIL: интерпретатор CPython дает выполняться только одному треду и блокирует все остальные. Поэтому прироста в производительности не будет, какой бы многопоточной ваша обработка не была.

Альтернативной CPython является IronPython, Jython и PyPy. Они не используют GIL.

Когда стоит использовать ProcessPoolExecutor?

Так же как и с тредами, ProcessPoolExecutor не стоит использовать в качестве процесса в фоновом режиме. Зато его можно использовать для задач, в основном требующих вычислений процессора.

Примерами таких ситуаций, когда стоит использовать ProcessPoolExecutor:

  • нахождение числа Pi, e;
  • нахождение простых множителей заданных чисел;
  • парсинг HTML, JSON и проч.;
  • обработка текста;
  • запуск симуляций.

Тем не менее, ProcessPoolExecutor имеет такой же уровень владения как и ThreadPoolExecutor. Поэтому его стоит применять с не менее осторожностью.

Еще больше подробностей о многопоточной обработке данных на Python вы узнаете на наших образовательных курсах в лицензированном учебном центре обучения и повышения квалификации руководителей и ИТ-специалистов (менеджеров, архитекторов, инженеров, администраторов, Data Scientist’ов и аналитиков Big Data) в Москве:

Источники
  1. Оригинал статьи
Комментировать