Зачем, когда и как использовать multithreading и multiprocessing в Python

Original author: Thilina Rajapakse
  • Translation
Салют, хабровчане. Прямо сейчас в OTUS открыт набор на курс «Machine Learning», в связи с этим мы перевели для вас одну очень интересную «сказочку». Поехали.




Давным-давно, в далекой-далекой галактике…

Жил в маленькой деревушке посреди пустыни мудрый и могущественный волшебник. И звали его Дамблдальф. Он был не просто мудр и могущественен, но и помогал людям, которые приезжали из далеких земель, чтобы просить помощи у волшебника. Наша история началась, когда один путник принес волшебнику магический свиток. Путник не знал, что было в свитке, он лишь знал, что если кто-то и сможет раскрыть все тайны свитка, то это именно Дамблдальф.

Глава 1: Однопоточность, однопроцессность


Если вы еще не догадались, я проводил аналогию с процессором и его функциями. Наш волшебник – это процессор, а свиток – это список ссылок, которые ведут к силе Python и знанию, чтобы овладеть ею.

Первой мыслью волшебника, который без особого труда расшифровал список, было послать своего верного друга (Гарригорна? Знаю, знаю, что звучит ужасно) в каждое из мест, которые были описаны в свитке, чтобы найти и принести то, что он сможет там отыскать.

In [1]:
import urllib.request
from concurrent.futures import ThreadPoolExecutor
In [2]:
urls = [
  'http://www.python.org',
  'https://docs.python.org/3/',
  'https://docs.python.org/3/whatsnew/3.7.html',
  'https://docs.python.org/3/tutorial/index.html',
  'https://docs.python.org/3/library/index.html',
  'https://docs.python.org/3/reference/index.html',
  'https://docs.python.org/3/using/index.html',
  'https://docs.python.org/3/howto/index.html',
  'https://docs.python.org/3/installing/index.html',
  'https://docs.python.org/3/distributing/index.html',
  'https://docs.python.org/3/extending/index.html',
  'https://docs.python.org/3/c-api/index.html',
  'https://docs.python.org/3/faq/index.html'
  ]
In [3]:
%%time

results = []
for url in urls:
    with urllib.request.urlopen(url) as src:
        results.append(src)

CPU times: user 135 ms, sys: 283 µs, total: 135 ms
Wall time: 12.3 s
In [ ]:

Как видите, мы просто перебираем URL-адреса один за другим с помощью цикла for и читаем ответ. Благодаря %%time и магии IPython, мы можем увидеть, что с моим печальным интернетом это заняло около 12 секунд.

Глава 2: Multithreading


Неспроста волшебник славился своей мудростью, он быстро смог придумать куда более эффективный способ. Вместо того, чтобы посылать одного человека в каждое место по порядку, почему бы не собрать отряд надежных соратников и не отправить их в разные концы света одновременно! Волшебник сможет разом объединить все знания, которые они принесут!

Все верно, вместо просмотра списка в цикле последовательно, мы можем использовать multithreading (многопоточность) для доступа к нескольким URL-адресам одновременно.

In [1]:
import urllib.request
from concurrent.futures import ThreadPoolExecutor
In [2]:
urls = [
  'http://www.python.org',
  'https://docs.python.org/3/',
  'https://docs.python.org/3/whatsnew/3.7.html',
  'https://docs.python.org/3/tutorial/index.html',
  'https://docs.python.org/3/library/index.html',
  'https://docs.python.org/3/reference/index.html',
  'https://docs.python.org/3/using/index.html',
  'https://docs.python.org/3/howto/index.html',
  'https://docs.python.org/3/installing/index.html',
  'https://docs.python.org/3/distributing/index.html',
  'https://docs.python.org/3/extending/index.html',
  'https://docs.python.org/3/c-api/index.html',
  'https://docs.python.org/3/faq/index.html'
  ]
In [4]:
%%time

with ThreadPoolExecutor(4) as executor:
    results = executor.map(urllib.request.urlopen, urls)

CPU times: user 122 ms, sys: 8.27 ms, total: 130 ms
Wall time: 3.83 s
In [5]:
%%time

with ThreadPoolExecutor(8) as executor:
    results = executor.map(urllib.request.urlopen, urls)

CPU times: user 122 ms, sys: 14.7 ms, total: 137 ms
Wall time: 1.79 s
In [6]:
%%time

with ThreadPoolExecutor(16) as executor:
    results = executor.map(urllib.request.urlopen, urls)

CPU times: user 143 ms, sys: 3.88 ms, total: 147 ms
Wall time: 1.32 s
In [ ]:

Гораздо лучше! Почти как… магия. Использование нескольких потоков может значительно ускорить выполнение многих задач, связанных с вводом-выводом. В моем случае большая часть времени, затраченного на чтение URL-адресов, связана с задержками сети. Программы, привязанные к вводу-выводу, проводят большую часть времени жизни в ожидании, как вы уже догадались, ввода или вывода (подобно тому, как волшебник ждет, пока его друзья съездят в места из свитка и вернутся обратно). Это может быть ввод-вывод из сети, базы данных, файла или от пользователя. Такой ввод-вывод как правило занимает много времени, поскольку источнику может потребоваться выполнить предварительную обработку перед передачей данных на ввод-вывод. Например, процессор, будет считать гораздо быстрее, чем сетевое соединение будет передавать данные (по скорости примерно, как Флэш против вашей бабушки).

Примечание: multithreading может быть очень полезна в таких задачах, как очистка веб-страниц.

Глава 3: Multiprocessing


Шли годы, слава о добром волшебнике росла, а вместе с ней росла и зависть одного нелицеприятного темного волшебника (Саруморта? Или может Воландемана?). Вооруженной немеряной хитростью и движимый завистью, темный волшебник наложил на Дамблдальфа страшное проклятие. Когда проклятие настигло его, Дамблдальф понял, что у него есть всего несколько мгновений, чтобы отразить его. В отчаянии он рылся в своих книгах заклинаний и быстро нашел одно контрзаклятие, которое должно было сработать. Единственная проблема заключалась в том, что волшебнику нужно было вычислить сумму всех простых чисел меньше 1 000 000. Странное, конечно, заклятье, но что имеем.

Волшебник знал, что вычисление значения будет тривиальным, если у него будет достаточно времени, но такой роскоши у него не было. Несмотря на то, что он великий волшебник, все же и он ограничен своей человечностью и может проверять на простоту всего одно число за раз. Если бы он решил просто просуммировать простые числа друг за другом, времени ушло бы слишком много. Когда до того, чтобы применить контрзаклятье остались считанные секунды, он вдруг вспомнил заклятье multiprocessing, которое узнал из магического свитка много лет назад. Это заклинание позволит ему копировать себя, чтобы распределить числа между своими копиями и проверять несколько одновременно. И в итоге все, что ему нужно будет сделать – это просто сложить числа, которые он и его копии обнаружат.

In [1]:
from multiprocessing import Pool
In [2]:
def if_prime(x):
    if x <= 1:
        return 0
    elif x <= 3:
        return x
    elif x % 2 == 0 or x % 3 == 0:
        return 0
    i = 5
    while i**2 <= x:
        if x % i == 0 or x % (i + 2) == 0:
            return 0
        i += 6
    return x
In [17]:
%%time

answer = 0

for i in range(1000000):
    answer += if_prime(i)

CPU times: user 3.48 s, sys: 0 ns, total: 3.48 s
Wall time: 3.48 s
In [18]:
%%time

if __name__ == '__main__':
    with Pool(2) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

CPU times: user 114 ms, sys: 4.07 ms, total: 118 ms
Wall time: 1.91 s
In [19]:
%%time

if __name__ == '__main__':
    with Pool(4) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

CPU times: user 99.5 ms, sys: 30.5 ms, total: 130 ms
Wall time: 1.12 s
In [20]:
%%timeit

if __name__ == '__main__':
    with Pool(8) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

729 ms ± 3.02 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [21]:
%%timeit

if __name__ == '__main__':
    with Pool(16) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

512 ms ± 39.8 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [22]:
%%timeit

if __name__ == '__main__':
    with Pool(32) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

518 ms ± 13.3 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [23]:
%%timeit

if __name__ == '__main__':
    with Pool(64) as p:
        answer = sum(p.map(if_prime, list(range(1000000))))

621 ms ± 10.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
In [ ]:

У современных процессоров больше одного ядра, поэтому мы можем ускорить выполнение задач, используя модуль многопроцессной обработки multiprocessing. Задачи, завязанные на процессоре – это программы, которые большую часть времени своей работы выполняют вычисления на процессоре (тривиальные математические вычисления, обработку изображений и т.д.). Если вычисления могут выполняться независимо друг от друга, мы имеем возможность разделить их между доступными ядрами процессора, тем самым получив значительный прирост в скорости обработки.

Все, что вам нужно сделать, это:

  1. Определить применяемую функцию
  2. Подготовить список элементов, к которым будет применена функция;
  3. Породить процессы с помощью multiprocessing.Pool. Число, которое будет передано в Pool(), будет равно числу порожденных процессов. Встраивание оператора with гарантирует, что все процессы будут убиты после завершения их работы.
  4. Объедините выходные данные из процесса Pool с помощью функции map. Входными данными для map будет функция, применяемая к каждому элементу, и сам список элементов.

Примечание: Функцию можно определить так, чтобы выполнять любую задачу, которая может быть выполнена параллельно. Например, функция может содержать код для записи результата вычисления в файл.

Итак, зачем нам разделять multiprocessing и multithreading? Если вы когда-либо пытались повысить производительность выполнения задачи на процессоре с помощью multithreading, в итоге эффект получался ровно обратный. Это просто ужасно! Давайте разберёмся, как так вышло.

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

Для задачи, связанной с вводом-выводом, такая ситуация совершенно нормальна. Один поток отправляет запрос на один URL-адрес и ждет ответа, только потом этот поток может быть заменен другим, который отправит другой запрос на другой URL-адрес. Поскольку поток не должен ничего делать, пока он не получит ответа, нет никакой разницы, что в данный момент времени выполняется всего один поток.

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

Чтобы обойти это «ограничение», мы используем модуль multiprocessing. Вместо использования потоков, multiprocessing использует, как бы вам это сказать… несколько процессов. Каждый процесс получает свой личный интерпретатор и пространство в памяти, поэтому GIL не будет вас ограничивать. По сути, каждый процесс будет использовать свое ядро процессора и работать со своим уникальным числом, и выполняться это будет одновременно с работой других процессов. Как мило с их стороны!

Вы можете заметить, что нагрузка на центральный процессор будет выше, когда вы будете использовать multiprocessing по сравнению с обычным циклом for или даже multithreading. Так происходит, потому что ваша программа использует не одно ядро, а несколько. И это хорошо!

Помните, что multiprocessing имеет свои издержки на управление несколькими процессами, которые обычно серьезнее, чем издержки multithreading. (Multiprocessing порождает отдельные интерпретаторы и назначает каждому процессу свою область памяти, так что да!) То есть, как правило, лучше использовать облегченную версию multithreading, когда вы хотите выкрутиться таким способом (вспомните про задачи, связанные с вводом-выводом). А вот когда вычисление на процессоре становится бутылочным горлышком, приходит время модуля multiprocessing. Но помните, что с большой силой приходит большая ответственность.

Если вы породите больше процессов, чем ваш процессор может обработать в единицу времени, то заметите, что производительность начнет падать. Так происходит, потому что операционная система должна делать больше работы, тасуя процессы между ядрами процессора, потому что процессов больше. В реальности все может быть еще сложнее, чем я рассказал сегодня, но основную идею я донес. Например, в моей системе производительность упадет, когда количество процессов будет равно 16. Все потому, что в моем процессоре всего 16 логических ядер.

Глава 4: Заключение


  • В задачах, связанных с вводом-выводом, multithreading может повысить производительность.
  • В задачах, связанных с вводом-выводом, multiprocessing также может повысить производительность, но издержки, как правило, оказываются выше, чем при использовании multithreading.
  • Существование Python GIL дает нам понять, что в любой момент времени в программе может выполняться всего один поток.
  • В задачах, связанных с процессором, использование multithreading может понизить производительность.
  • В задачах, связанных с процессором, использование multiprocessing может повысить производительность.
  • Волшебники потрясающие!

На этом мы сегодня закончим знакомство с multithreading и multiprocessing в Python. А теперь идите и побеждайте!



«Моделирование COVID-19 с помощью анализа графов и парсинга открытых данных». Бесплатный урок.
OTUS
Цифровые навыки от ведущих экспертов

Comments 8

    +1
    Спасибо за перевод. Вопрос: если я не сторонник внутрипитоновской мультипроцессности, а распараллеливание вычислений делаю микросервисной архитектурой (есть диспетчер и есть одинаковые воркеры, которые запускаются в некоторых количествах на одном или на нескольких серверах), то существует ли способ внутри питоновской программы принудительно указать какое логическое ядро я хочу использовать? Например, имеем 16 ядер, запускаем 16 воркеров, смотрим на загрузку- 15 ядер загружены, а одно- нет — значит работа идет не оптимальная- 16 программ работаеют на 15ти ядрах. Хочется иметь возможность принудительно указать воркеру номер ядра на котором он будет исполняться. Интересуют ваши соображения по этому поводу как для линукса так и для винды. Заранее признателен за ответ.
      +1
      В случае с Unix системами можно попробовать поиграться с os.sched_setaffinity. Если же Вы видите неравномерную нагрузку на ядра ЦПУ, то это скорее не проблема Python или Вашего приложения, это проблема ОС и ее планировщика.
        0
        Да, именно так. Это проблема планировщика ОС, который раскидывает задачи по ядрам. И когда он это делает не оптимально (для меня) — хочется вмешаться и явно сказать что и на каком ядре запускать.
          +1
          И когда он это делает не оптимально (для меня) — хочется вмешаться и явно сказать что и на каком ядре запускать.
          Выглядит очень странно. Операционка обычно равномерно нагружает ядра. Возможно ваши воркеры никогда не завершаются, и тогда у операционки нет возможности перераспределить нагрузку. Простым решением в вашем случае должен быть запуск каждой поступающей задачи в новом потоке, и завершать поток при завершении задачи. Но это не точно.
        0
        Как вариант использовать psutil и его Process.cpu_affinity
        0
        а теперь вместо потоков попробовать использовать event-driven engine, тот же Twisted
          0
          я не опнял первый пример. Без потоков получилось 135 милисекунд, с потоками получилось 147. И в выводах пишут что это успех. Но ведь стало медленнее???
            0

            Хорошо было бы добавить обзор asyncio, а ещё лучше, сравнить многопоточность и event loop для задач ввода-вывода.

            Only users with full accounts can post comments. Log in, please.