Для начала, что такое параллелизм? Википедия говорит, что это свойство систем, при котором несколько вычислений выполняются одновременно, и при этом, возможно, взаимодействуют друг с другом. И, не смотря на скепсис, в питоне это можно делать несколькими способами:
- С помощью потоков [
threading
], позволяя нескольким потокам работать по очереди. - Запустить задачу и продолжить работать с другой пока ждем ответа от диска или сети [
asyncio
]. - Мы можем исполнять несколько задач одновременно, используя несколько процессорных ядер [
multiprocessing
]
Разница между потоками и процессами
Поток - это независимая последовательность процессов, при этом все процессы делят память между другими процессами принадлежащими вашей программе. Python по умолчанию имеет один основной поток. Но вы можете создать их больше чем один и позволить Python переключаться между ними.
В отличие от потоков, процесс имеет собственное пространство памяти, которое не разделяется с другими процессами. Процесс может клонировать себя, создавая два и более экземпляра.
У Python есть одна особенность, которая усложняет параллельное программирование. Она называется GIL, сокращение от Global Interpreter Lock
. GIL гарантирует, что в любой момент времени будет работать только один поток. Не буду подробно объяснять для чего это сделано, но поверьте, для этого есть реальные причины. Например чтобы два параллельных потока не изменили одни и те же данные. Но не волнуйтесь, есть способ обойти это.
Для примера, есть переменная с присвоенным значением:
a = 2
Теперь предположим, что у нас есть два потока, thread_one
и thread_two
и они выполняют следующие операции:
- thread_one:
a = a + 2
- thread_two:
a = a * 3
И в зависимости от очередности исполнения результат будет принципиально разный. И даже если потоки исполнятся одновременно, то результат вас тоже удивит.
Порядок исполнения однозначно имеет значение. Непоследовательность исполнения порождает непредсказуемое поведение программы. Именно поэтому у Python есть GIL и он реально облегчает жизнь. Но при этом он реально сдерживает нас в плане параллельности.
Ниже будут примеры как все таки мы можем подружить параллелизм с Питоном.
Multiprocessing в Python
Для начала создадим функцию которую мы будем использовать во всех наших примерах.
def heavy(n, myid):
for x in range(1, n):
for y in range(1, n):
x**y
print(myid, "is done")
На самом деле это достаточно “тяжелая” функция. По сути - это вложенный цикл который выполняет умножение. Если вы запустите её то увидите, что уровень загрузки процессора при ее исполнении приближается к 100%.
Попробуем выполнить эту функцию разными способами.
Однопоточный режим
Каждая программа на Python имеет как минимум один поток. Здесь вы видите однопоточную версию, которая является базовой по скорости работы. Она выполняет нашу функцию последовательно 80 раз:
from lib import heavy
import time
def heavy(n, myid):
for x in range(1, n):
for y in range(1, n):
x**y
print(myid, "is done")
def sequential(n):
for i in range(n):
heavy(500, i)
start = time.time()
sequential(80)
end = time.time()
print("Took: ", end - start)
# время исполнения
# не многим более 46 секунд.
Используем потоки [threading]
В этом примере попробуем использовать потоки. Каждый вызов у нас получит свой поток.
Вы наверно подумали что в данном случае программа исполнится быстрее, но по факту она даже медленнее чем предыдущий вариант. Потому что в данном случае в работу вмешался GIL и, плюс ко всему, в предыдущем варианте нашей программы нет накладных расходов связанных с созданием потоков и переключением между ними.
Но если бы в нашей функции появился I/O все стало бы гораздо оптимистичней. С имитируем в нашем примере ввод/вывод функцией time.sleep()
:
import threading
import time
def heavy(n, myid):
time.sleep(2)
print(myid, "is done")
def threaded(n):
threads = []
for i in range(n):
t = threading.Thread(target=heavy, args=(500,i,))
threads.append(t)
t.start()
for t in threads:
t.join()
start = time.time()
threaded(80)
end = time.time()
print("Took: ", end - start)
# исполнение чуть более 2s
Все заканчивается в пределах трех секунд. И это уже asyncio
. Или проще говоря пока программа ждет завершения операции ввода вывода она тратит это время на другие задачи и планирует другие потоки.
Используем мультипроцессинг [multiprocessing]
На очереди истинный параллелизм с мультипроцессингом.
import time
import multiprocessing
def heavy(n, myid):
for x in range(1, n):
for y in range(1, n):
x**y
print(myid, "is done")
def multiproc(n):
processes = []
for i in range(n):
p = multiprocessing.Process(target=heavy, args=(500,i,))
processes.append(p)
p.start()
for p in processes:
p.join()
start = time.time()
multiproc(80)
end = time.time()
print("Took: ", end - start)
# в моем случае чуть более 10 сек
Очень похоже на наш однопоточный вариант. Но не смотря на это этот вариант значительно быстрее. И чем больше ядер в вашем процессоре тем быстрее вы получите результат. Мультипроцессорная реализация демонстрируем нам линейное увеличение скорости.
И в заключение
Используйте asyncio
и это даст реальный прирост производительности. Но если у вас нет ввода вывода, то и нет и особого смысла.
Используйте multiprocessing
и это тоже даст реальный прирост. Но при условии, что у вас более чем одно ядро в процессоре.
Многие возразят, что здесь описаны банальные истины, но если бы вы знали сколько раз я видел людей которые считают asincio
и multiprocessing
фактически одним и тем же. И ничуть не меньше людей которые искренне уверены, что с параллелизмом в Питоне абсолютная и непоправимая беда. На самом деле истина где то посередине. Все не так просто и прозрачно как в golang
, но и не так и плохо, как может показаться, на первый взгляд.