В CPU-bound-сервисе заменили ThreadPoolExecutor на ProcessPoolExecutor, но передают каждой задаче большой объект. Почему ускорение может исчезнуть?
Ускорение может исчезнуть из-за стоимости сериализации: аргументы задачи передаются процессу-исполнителю через межпроцессное взаимодействие, обычно с сериализацией и последующей десериализацией. Если передаваемый объект велик, время копирования и обработки данных может сравняться со временем вычисления или превысить выигрыш от параллельного выполнения.
Процессы часто используют для CPU-bound-задач в CPython, потому что каждый процесс имеет собственный интерпретатор и собственный GIL. Это позволяет выполнять Python-код на нескольких ядрах независимо, в отличие от обычных потоков одного процесса.
Обратная сторона изоляции — процессы не разделяют обычную память. Чтобы передать задачу и её аргументы исполнителю, инфраструктура должна обменяться данными между адресными пространствами процессов.
Пусть вычисление над большим объектом занимает 200 миллисекунд, а сериализация, передача и десериализация этого объекта — 500 миллисекунд. Тогда добавление процессов не ускорит полезную работу: значительная часть времени будет уходить на подготовку данных.
Дополнительные потери возникают при передаче результата обратно родительскому процессу. При частых мелких задачах накладные расходы повторяются для каждого вызова, поэтому параллельность может сделать систему даже медленнее последовательного выполнения.
При отправке задачи в ProcessPoolExecutor вызывающая сторона передаёт исполнителю вызываемый объект и его аргументы. Эти данные сериализуются, передаются через межпроцессный канал и восстанавливаются в рабочем процессе; результат проходит обратный путь.
Минимальный пример принципа:
Здесь large_data не становится общей изменяемой структурой: рабочий процесс получает отдельное восстановленное значение. Изменения внутри него не изменяют объект в родительском процессе.
Снизить накладные расходы можно несколькими способами:
Механизм запуска процесса тоже влияет на архитектуру. При spawn процесс стартует с чистого состояния и заново импортирует необходимые модули. При fork на Unix часть памяти может быть унаследована эффективно благодаря copy-on-write, но это не отменяет сериализацию аргументов отдельных заданий и имеет ограничения при работе с уже созданными потоками.
Процессы полезны, когда вычисление достаточно тяжёлое, данные компактны или могут быть подготовлены внутри исполнителя. Если функция вызывает библиотеку, которая освобождает GIL, потоки иногда оказываются выгоднее: они не требуют межпроцессной передачи объектов.
Сервис обрабатывает изображения. Сначала каждое изображение целиком передавали в ProcessPoolExecutor, но после перехода на четыре процесса пропускная способность почти не изменилась: декодирование и пересылка больших файлов заняли значительную часть времени.
Рассматривались три варианта. Потоки не требовали сериализации, но для чистого Python-кода оставались ограничены GIL. Передача через shared memory уменьшала копирование, однако усложняла управление временем жизни буферов и обработку ошибок. Увеличение размера задания снижало относительную стоимость обмена, но повышало задержку отдельных запросов.
Выбрали пакетную обработку: процесс получал несколько компактных описателей файлов, самостоятельно читал изображения и возвращал только метаданные и сжатый результат. Это уменьшило обмен между процессами и сохранило параллельное выполнение CPU-bound-этапа; при этом размер пакета ограничили, чтобы задержка одного запроса не стала чрезмерной.
Дополнительный вопрос 1: Всегда ли передача объекта в процесс означает физическое копирование всех его данных?
Нет, это зависит от способа обмена и конкретного объекта. Для обычной передачи аргумента в задание данные обычно сериализуются и восстанавливаются в другом процессе, что создаёт отдельное представление объекта. Специализированные механизмы shared memory могут предоставить общий буфер, но требуют явного управления форматом данных и синхронизацией.
Дополнительный вопрос 2: Почему увеличение числа процессов может ухудшить производительность даже при маленьких аргументах?
Процессы конкурируют за ядра CPU, память, кэш и пропускную способность межпроцессного обмена. Если процессов больше, чем эффективно выполняемых CPU-bound-работ, появляется дополнительное переключение контекста и конкуренция за ресурсы. Поэтому число процессов выбирают по измерениям, а не автоматически делают равным большому числу.
Дополнительный вопрос 3: Поможет ли fork полностью устранить расходы передачи большого объекта?
Нет. Наследование адресного пространства при fork может уменьшить первоначальное копирование неизменяемых данных благодаря copy-on-write, но аргументы конкретных заданий, отправляемые через пул, всё равно обычно проходят механизм сериализации и межпроцессного обмена. Кроме того, запись в унаследованные страницы вызывает их копирование, а использование fork после создания потоков может быть небезопасным из-за унаследованного состояния блокировок.