March 27, 2020

Работа с процессами в Python

С появлением многоядерных процессоров стала общеупотребительной практика распространять нагрузку на все доступные ядра. Существует два основных подхода в распределении нагрузки: использование процессов и потоков. О первом мы как раз сейчас и поговорим.

Немного истории

Модуль multiprocessing изначально был добавлен в Python 2.6. Этот модуль позволяет создавать процессы таким же образом, как при создании потоков при помощи модуля threading.

Суть в том, что, в связи с тем, что теперь мы создаем процессы, появляется возможность обойти GIL (Global Interpreter Lock) и воспользоваться возможностью использования нескольких процессоров (или даже ядер) на компьютере.

Пакет multiprocessing также включает ряд API, которых вообще нет в модуле threading. Например, там есть очень удобный класс Pool, который можно использовать для параллельного выполнения функции между несколькими входами. Мы рассмотрим Pool немного позже. А начнем пожалуй с класса Process модуля multiprocessing.

Приступим к работе с Multiprocessing

Класс Process очень похож на класс Thread модуля threading. Давайте попробуем создать несколько процессов, которые вызывают одну и ту же функцию, и посмотрим, как это сработает.

Начнем с того, что создадим функцию func. Внутри func возводим переданное число number в квадрат. Мы также используем модуль os для того, чтобы получить id текущего процесса. С помощью него можно определить какой именно процесс вызывает функцию.

import os
from multiprocessing import Process


def func(number):
    proc = os.getpid()
    print(f'{number} squared to {number ** 2} by process id: {proc}')

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

def main():
    procs = []
    numbers = [1, 2, 3, 4, 5]

    for number in numbers:
        proc = Process(target=func, args=(number,))
        procs.append(proc)
        proc.start()

    for proc in procs:
        proc.join()
Здесь хотелось бы обсудить несколько важных моментов. Во-первых, f-строки работают только в версиях Python 3.6 или выше. Во-вторых, аргументы в функции Process это всегда кортеж, даже если там всего один элемент.

В функции main, в нижнем блоке кода, мы создаем несколько процессов и стартуем их с помощью функции start().

Самый последний цикл только вызывает метод join() для каждого из процессов, что говорит Python подождать, пока процесс завершится. Если вам нужно остановить процесс, вы можете вызвать метод terminate().

Запускаем полученный скрипт.

if __name__ == '__main__':
    main()

Вывод будет примерно таким.

1 squared to 1 by process id: 1600
2 squared to 4 by process id: 1601
3 squared to 9 by process id: 1602
4 squared to 16 by process id: 1603
5 squared to 25 by process id: 1604

Иногда приятно иметь читабельное название процессов. К счастью, multiprocessing дает возможность получить доступ к названию нашего процесса.

from multiprocessing import Process, current_process


def func(number):
    proc = current_process().name
    print(f'{number} squared to {number ** 2} by process {proc}')

def main():
    procs = []
    numbers = [1, 2, 3, 4, 5]

    for number in numbers:
        proc = Process(target=func, args=(number,))
        procs.append(proc)
        proc.start()

    for proc in procs:
        proc.join()


if __name__ == '__main__':
    main()

Вывод будет таким.

1 squared to 1 by process Process-1
2 squared to 4 by process Process-2
3 squared to 9 by process Process-3
4 squared to 16 by process Process-4
5 squared to 25 by process Process-5

Кроме того, есть возможность напрямую назначить имя процессу.

proc = Process(target=func, name='My Process', args=(number,))

Класс Pool

Класс Pool используется для создания пула рабочих процессов. Он включает в себя методы, которые позволяют вам разгружать задачи к рабочим процессам. Давайте посмотрим на простейший пример.

from multiprocessing import Pool


def func(number):
    return number ** 2
    
def main():
    numbers = [1, 2, 3]
    with Pool(processes=3) as pool:
        print(pool.map(func, numbers))
        
        
if __name__ == '__main__':
    main()

Здесь я создал экземпляр Pool с помощью контекстного менеджера with...as... и указал ему создать три рабочих процесса. Далее я использовал метод map для отображения функции для каждого процесса. Наконец вывожу результат, который в данном случае является списком [1, 4, 9].

Реальный пример

Попробуем применить метод requests.get() к некоторому списку сайтов. Последовательное выполнение задачи отнимет много времени, но что если сделать это параллельно?

import requests
from multiprocessing import Pool


def main(processes):
    urls = [
        'http://www.python.org',
        'http://www.python.org/about/',
        'http://www.onlamp.com/pub/a/python/2003/04/17/metaclasses.html',
        'http://www.python.org/doc/',
        'http://www.python.org/download/',
        'http://www.python.org/getit/',
        'http://www.python.org/community/',
        'https://wiki.python.org/moin/',
        'http://planet.python.org/',
        'https://wiki.python.org/moin/LocalUserGroups',
        'http://www.python.org/psf/',
        'http://docs.python.org/devguide/',
        'http://www.python.org/community/awards/'
    ]
    
    # создадим пул работников
    with Pool(processes=processes) as pool:
        # получим результат с помощью функции map
        results = pool.map(requests.get, urls)

Теперь сравним скорости обработки при различном размере пула.

if __name__ == '__main__':
    # -- 13 Pool -- #
    main(processes=13)

    # -- 8 Pool -- #
    main(processes=8)

    # -- 4 Pool -- #
    main(processes=4)

    # -- Single -- #
    main(processes=1)
Вообще говоря, скорость выполнения таких процессов сильно зависит от скорости и качества интернет соединения. Кроме того, логически более правильно здесь было бы использовать async, а не multiprocessing. Однако даже в таком примере виде прирост скорости.

На моей машине получилось вот так:

13 Pool: 2.6185 sec
8 Pool: 2.7000 sec
4 Pool: 3.0071 sec
Single: 7.3520 sec

Итог

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

Мы затронули только некоторые простые вопросы, связанные многопроцессорным программированием на Python. Разумеется, в документации Python представлено намного больше развернутой информации, так что рекомендую с ней ознакомиться.


Статья подготовлена образовательной организацией Python Academy