Введение в многопоточность и многопроцессорность в Python

Многопоточность и многопроцессорность в Python Введение в ключевые концепции

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

  1. Улучшенная производительность: Благодаря способности выполнять задачи одновременно, мы можем сократить время выполнения и улучшить общую производительность системы.
  2. Масштабируемость: Мы можем разделить большую задачу на несколько более мелких подзадач и назначить им отдельное ядро или поток для их независимого выполнения. Это может быть полезно в крупных системах.
  3. Эффективные операции ввода-вывода: Благодаря параллелизму ЦП не нужно ждать завершения процесса ввода-вывода. ЦП может немедленно начать выполнение следующего процесса, пока предыдущий занят своим вводом-выводом.
  4. Оптимизация ресурсов: Разделив ресурсы, мы можем предотвратить занятие всеми ресурсами одним процессом. Это может избежать проблемы Голодания для меньших процессов.

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

Что такое многопоточность?

Многопоточность – это один из способов достижения параллелизма в одном процессе и способен выполнять одновременные задачи. Внутри одного процесса могут быть созданы несколько потоков, которые могут выполнять более мелкие задачи параллельно внутри этого процесса.

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

Многопоточность в основном используется для выполнения операций ввода-вывода, то есть если какая-то часть программы занята операциями ввода-вывода, то остальная программа может быть отзывчивой. Однако в реализации Python многопоточность не может достичь истинного параллелизма из-за глобального блокировки интерпретатора (GIL).

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

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

Что такое демонические потоки?

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

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

Что такое многопроцессорность?

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

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

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

 

Реализация многопоточности

 

Пришло время реализовать основной пример многопоточности с использованием Python. В Python есть встроенный модуль threading, который используется для реализации многопоточности.

  1. Импорт библиотек:
import threadingimport os

 

  1. Функция для вычисления квадратов:

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

def calculate_squares(numbers):    for num in numbers:        square = num * num        print(            f"Квадрат числа {num} равен {square} | Имя потока {threading.current_thread().name} | PID процесса {os.getpid()}"        )

 

  1. Основная функция:

У нас есть список чисел, и мы разделим этот список поровну и назовем их соответственно первая_половина и вторая_половина. Теперь мы назначим два отдельных потока t1 и t2 для этих списков.

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

Функция .start() запускает выполнение этих потоков, а функция .join() блокирует выполнение основного потока, пока данный поток не будет выполнен полностью.

if __name__ == "__main__":    numbers = [1, 2, 3, 4, 5, 6, 7, 8]    half = len(numbers) // 2    first_half = numbers[:half]    second_half = numbers[half:]    t1 = threading.Thread(target=calculate_squares, name="t1", args=(first_half,))    t2 = threading.Thread(target=calculate_squares, name="t2", args=(second_half,))    t1.start()    t2.start()    t1.join()    t2.join()

 

Вывод:

Квадрат числа 1 равен 1 | Имя потока t1 | PID процесса 345Квадрат числа 2 равен 4 | Имя потока t1 | PID процесса 345Квадрат числа 5 равен 25 | Имя потока t2 | PID процесса 345Квадрат числа 3 равен 9 | Имя потока t1 | PID процесса 345Квадрат числа 6 равен 36 | Имя потока t2 | PID процесса 345Квадрат числа 4 равен 16 | Имя потока t1 | PID процесса 345Квадрат числа 7 равен 49 | Имя потока t2 | PID процесса 345Квадрат числа 8 равен 64 | Имя потока t2 | PID процесса 345

 

Примечание: Все созданные выше потоки являются не демоническими потоками. Чтобы создать демонический поток, вам нужно написать t1.setDaemon(True), чтобы сделать поток t1 демоническим.

 

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

Вы также можете заметить, что вывод не генерируется последовательно. В первой строке вы увидите вывод, сгенерированный потоком 1, затем в третьей строке вывод, сгенерированный потоком 2, затем снова потоком 1 в четвертой строке. Это явно свидетельствует о том, что эти потоки работают вместе параллельно.

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

 

Реализация многопоточности

 

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

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

  1. Импортирование библиотек:
from multiprocessing import Processimport os

 

Мы будем использовать модуль multiprocessing для создания независимых процессов.

  1. Функция для расчета квадратов:

Функция остается той же. Мы только удалили инструкцию печати информации о потоке.

def calculate_squares(numbers):    for num in numbers:        square = num * num        print(            f"Квадрат числа {num} равен {square} | PID процесса {os.getpid()}"        )

 

  1. Главная функция:

В функции main есть несколько изменений. Вместо потока мы просто создали отдельный процесс.

if __name__ == "__main__":    numbers = [1, 2, 3, 4, 5, 6, 7, 8]    half = len(numbers) // 2    first_half = numbers[:half]    second_half = numbers[half:]    p1 = Process(target=calculate_squares, args=(first_half,))    p2 = Process(target=calculate_squares, args=(second_half,))    p1.start()    p2.start()    p1.join()    p2.join()

 

Вывод:

Квадрат числа 1 равен 1 | PID процесса 1125Квадрат числа 2 равен 4 | PID процесса 1125Квадрат числа 3 равен 9 | PID процесса 1125Квадрат числа 4 равен 16 | PID процесса 1125Квадрат числа 5 равен 25 | PID процесса 1126Квадрат числа 6 равен 36 | PID процесса 1126Квадрат числа 7 равен 49 | PID процесса 1126Квадрат числа 8 равен 64 | PID процесса 1126 

 

Мы видим, что каждый список выполняется в отдельном процессе. Оба имеют разные идентификаторы процессов. Чтобы проверить, выполняются ли процессы параллельно, нам необходимо создать отдельную среду, о которой мы обсудим ниже.

 

Расчет времени выполнения с многопроцессорностью и без

 

Чтобы проверить, получаем ли мы истинное параллелизм, мы рассчитаем время выполнения алгоритма с и без использования многопроцессорности.

Для этого нам понадобится большой список целых чисел, содержащий более 10^6 чисел. Мы можем создать список с помощью библиотеки random. Мы будем использовать модуль time Python для расчета времени выполнения. Ниже приведена реализация для этого. Код самообъясняющийся, хотя вы всегда можете заглянуть в комментарии к коду.

from multiprocessing import Processimport osimport timeimport randomdef calculate_squares(numbers):    for num in numbers:        square = num * numif __name__ == "__main__":    numbers = [        random.randrange(1, 50, 1) for i in range(10000000)    ]  # Создание случайного списка целых чисел длиной 10^7.    half = len(numbers) // 2    first_half = numbers[:half]    second_half = numbers[half:]    # ----------------- Создание среды с одним процессом ------------------------#    start_time = time.time()  # Время начала без многопроцессорности    p1 = Process(        target=calculate_squares, args=(numbers,)    )  # Один процесс P1 выполняет весь список    p1.start()    p1.join()    end_time = time.time()  # Время окончания без многопроцессорности    print(f"Время выполнения без многопроцессорности: {(end_time-start_time)*10**3}мс")    # ----------------- Создание среды с несколькими процессами ------------------------#    start_time = time.time()  # Время начала с многопроцессорностью    p2 = Process(target=calculate_squares, args=(first_half,))    p3 = Process(target=calculate_squares, args=(second_half,))    p2.start()    p3.start()    p2.join()    p3.join()    end_time = time.time()  # Время окончания с многопроцессорностью    print(f"Время выполнения с многопроцессорностью: {(end_time-start_time)*10**3}мс")

 

Вывод:

Время выполнения без мультипроцессинга: 619.8039054870605 мс
Время выполнения с мультипроцессингом: 321.70287895202637 мс

 

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

Вы также можете прочитать эту статью Последовательное сравнение совместное параллельности от VoAGI, которая поможет вам понять основную разницу между последовательными, совместными и параллельными процессами.

[Арьян Гарг](https://www.linkedin.com/in/aryan-garg-1bbb791a3/) – студент факультета “Электротехника” на 4 курсе обучения. Он интересуется веб-разработкой и машинным обучением. Он продолжает развивать свои интересы и готов работать в этих направлениях.