popov . dev

Main

Library

Articles

Разработка много...

Разработка многопоточных и многопроцессорных приложений на Python

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

Глобальная блокировка интерпретатора (GIL) в Python

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

Что такое GIL?

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

Почему Python использует GIL?

Упрощает управление памятью: управление памятью в Python, особенно подсчет ссылок для сборки мусора, не является потокобезопасным процессом. GIL гарантирует, что операции управления памятью, такие как увеличение или уменьшение количества ссылок, являются атомарными и не зависят от неопределённости параллелизма (race conditions или конкуренция).

Используя GIL, интерпретатор Python позволяет избежать сложностей и потенциальных ошибок, связанных с потокобезопасным управлением памятью.

Простота интеграции с библиотеками C: Python часто используется в качестве языка сценариев для взаимодействия с библиотеками C. Многие библиотеки C не являются потокобезопасными. GIL предоставляет простой способ обеспечить безопасность и согласованность взаимодействия Python с этими библиотеками.

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

Исторический контекст: GIL был представлен в начале истории Python, когда основные варианты использования языка не предполагали интенсивной многопоточности. В то время преимущества GIL в простоте и производительности перевешивали недостатки.

Удаление GIL потребовало бы существенной перестройки систем управления памятью и сбора мусора в Python.

Влияние GIL

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

import threading
import time

def cpu_bound_task():
    count = 0
    for i in range(10 ** 7):
        count += 1
    print(f"Задача завершена со значением count = {count}")


# Измерение времени для однократного выполнения
# выполняется дважды последовательно
start_time = time.time()
cpu_bound_task()
cpu_bound_task()
print(f"Длительность однократного выполнения (выполняется дважды): {time.time() - start_time:.2f} сек.")

# Измерение времени для двух потоков,
# выполняющих задачу одновременно
thread1 = threading.Thread(target=cpu_bound_task)
thread2 = threading.Thread(target=cpu_bound_task)

start_time = time.time()
thread1.start()
thread2.start()

thread1.join()
thread2.join()
print(f"Продолжительность двух потоков: {time.time() - start_time:.2f} сек.")

В этом примере cpu_bound_task выполняется дважды последовательно в первой части, а затем одновременно с использованием двух потоков во второй части. Несмотря на использование двух потоков, общее время выполнения потоков будет примерно таким же или немного хуже, чем при выполнении задачи дважды последовательно из-за GIL.

Многопоточность

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

Когда следует использовать многопоточность:

  1. Когда программа включает в себя задачи, связанные с вводом-выводом, такие как чтение из файлов или запись в них, сетевое взаимодействие или операции с базой данных.
  2. Когда задачи могут выполняться одновременно и не требуют больших затрат процессора.
  3. Когда приложению необходимо поддерживать общее состояние или память.

Плюсы:

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

Минусы:

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

Приведем подробный пример использования многопоточности в Python:

import threading
import time

def print_numbers():
    for i in range(10):
        print(f"Число: {i}")
        time.sleep(1)

def print_letters():
    for letter in 'abcdefghij':
        print(f"Буква: {letter}")
        time.sleep(1)

if __name__ == '__main__':
    # Создание потоков
    thread1 = threading.Thread(target=print_numbers)
    thread2 = threading.Thread(target=print_letters)

    # Запуск потоков
    thread1.start()
    thread2.start()

    # Ожидание завершения потоков
    thread1.join()
    thread2.join()

    print("Все задачи выполнены!")

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

Многопроцессорность

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

Когда следует использовать многопроцессорную обработку:

  1. Для задач, связанных с центральным процессором, таких как математические вычисления, обработка данных или любая другая операция, требующая значительных ресурсов центрального процессора.
  2. Когда задачи должны быть действительно параллельными.
  3. Когда выгодно выделять отдельные области памяти для задач, это позволяет избежать проблем с общей памятью.

Плюсы:

  1. Истинный параллелизм особенно полезен для задач, связанных с процессором, поскольку каждый процесс может выполняться на отдельном ядре.
  2. Каждый процесс имеет свое собственное пространство памяти, что снижает риск повреждения памяти.
  3. Более высокая производительность в многоядерных системах.

Минусы:

  1. Более высокий overhead из-за создания отдельных процессов.
  2. Более сложное взаимодействие между процессами (IPC) по сравнению с потоковой передачей.
  3. Увеличенное использование памяти, поскольку каждый процесс имеет свое собственное пространство памяти.

Рассмотрим пример использования многопроцессорности в Python:

import multiprocessing
import time

def print_numbers():
    for i in range(10):
        print(f"Число: {i}")
        time.sleep(1)

def print_letters():
    for letter in 'abcdefghij':
        print(f"Буква: {letter}")
        time.sleep(1)


if __name__ == '__main__':
    # Создание процессов
    process1 = multiprocessing.Process(target=print_numbers)
    process2 = multiprocessing.Process(target=print_letters)

    # Запуск процессов
    process1.start()
    process2.start()

    # Ожидание завершения работы процессов
    process1.join()
    process2.join()

    print("Все задачи завершены!")

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

Ключевые отличия

  1. Совместное использование памяти: потоки используют одно и то же пространство памяти, что упрощает взаимодействие, но может привести к повреждению памяти. Процессы имеют отдельные области памяти, что делает их более безопасными, но требует больше памяти.
  2. Ограничение GIL: GIL в Python влияет на многопоточность, предотвращая истинный параллелизм в задачах, привязанных к процессору. Многопроцессорная обработка обходит GIL, обеспечивая истинное параллельное выполнение.
  3. Накладные расходы (Overhead): потоки имеют меньший overhead из-за общей памяти, в то время как процессы имеют более высокий overhead , поскольку им требуются отдельные области памяти.

Выбирая между многопоточностью и многопроцессорностью

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

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

Сравнение многопоточности и многопроцессорности

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

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

def cpu_bound_task():
    count = 0
    for i in range(10**7):
        count += 1
    return count

Пример многопоточности

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

import threading
import time

def cpu_bound_task():
    count = 0
    for i in range(10 ** 7):
        count += 1
    return count

def thread_task():
    result = cpu_bound_task()
    print(f"Задача выполнена со значением count = {result}")

if __name__ == '__main__':
    start_time = time.time()

    # Создание 10 потоков
    threads = []
    for _ in range(10):
        thread = threading.Thread(target=thread_task)
        threads.append(thread)
        thread.start()

    # Ожидание пока пока все потоки выполнят работу
    for thread in threads:
        thread.join()

    print(f"Длительность многопоточности: {time.time() - start_time:.2f} сек.")

Вывод:

Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Длительность многопоточности: 2.10 сек

Пример многопроцессорной реализацией

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

import multiprocessing
import time

def cpu_bound_task():
    count = 0
    for i in range(10 ** 7):
        count += 1
    return count

def process_task():
    result = cpu_bound_task()
    print(f"Задача выполнена со значением count = {result}")

if __name__ == '__main__':
    start_time = time.time()

    # Создано 10 процессов
    processes = []
    for _ in range(10):
        process = multiprocessing.Process(target=process_task)
        processes.append(process)
        process.start()

    # Ожидаем пока все процессы будут завершены
    for process in processes:
        process.join()

    print(f"Длительность многопроцессорности: {time.time() - start_time:.2f} сек.")
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Задача выполнена со значением count = 10000000
Длительность многопроцессорности: 0.72 сек

Подведение итогов

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

  1. Длительность многопоточности: 2.10 секунд
  2. Длительность многопроцессорности: 0.72 секунд

Ключевые моменты

  1. Многопроцессорность значительно превосходит многопоточность в задачах, связанных с процессором, и выполняется менее чем за треть времени.
  2. Глобальная блокировка интерпретатора (GIL) ограничивает эффективность многопоточности в Python для операций с интенсивной нагрузкой на процессор, поскольку препятствует истинному параллельному выполнению потоков.
  3. Многопроцессорность использует несколько ядер процессора за счет запуска отдельных процессов, каждый из которых имеет свой собственный объем памяти и GIL, обеспечивая истинный параллелизм и эффективное использование ресурсов процессора.

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

Когда многопоточность эффективнее?

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

Разберем пример ситуации, в которой многопоточность полезна:

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

import threading
import requests
import time

# Список URL адресов для скрапинга
urls = [
    "http://example.com",
    "http://example.org",
    "http://example.net"
]

def fetch_url(url: str):
    try:
        response = requests.get(url)
        print(f"Получен {url} с кодом: {response.status_code}")
    except requests.RequestException as e:
        print(f"Ошибка получения {url}: {e}")

def fetch_all_urls():
    threads = []
    for url in urls:
        thread = threading.Thread(target=fetch_url, args=(url,))
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

if __name__ == '__main__':
    start_time = time.time()
    fetch_all_urls()
    print(f"Длительность многопоточности: {time.time() - start_time:.2f} сек.")

В этом примере определен список URL-адресов для сбора данных с нескольких веб-сайтов. Функция fetch_url отправляет HTTP-запрос GET на указанный URL-адрес и выводит код состояния ответа. Если во время запроса возникает ошибка, он перехватывает исключение и выводит сообщение об ошибке.

Функция fetch_all_urls создает поток для каждого URL-адреса в списке. Она запускает все потоки, а затем ожидает завершения каждого потока с помощью функции join(). Это позволяет инициировать и обрабатывать все сетевые запросы одновременно.

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

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

Альтернативы многопоточности и многопроцессорности

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

Asyncio (асинхронный ввод-вывод)

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

Плюсы:

  1. Подходит для задач, связанных с вводом-выводом.
  2. Не требует наличия нескольких потоков или процессов, что позволяет избежать связанных с ними накладных расходов.
  3. Может быть более эффективным с точки зрения использования памяти и процессора.

Минусы:

  1. Требуется другая модель программирования (async/await), которая может быть более сложной для понимания и реализации.

Пример:

import asyncio

async def print_numbers():
    for i in range(10):
        print(f"Число: {i}")
        await asyncio.sleep(1)  # Неблокирующий sleep

async def print_letters():
    for letter in 'abcdefghij':
        print(f"Буква: {letter}")
        await asyncio.sleep(1)  # Неблокирующий sleep

async def main():
    # Запускаем корутины (сопрограммы) одновременно
    await asyncio.gather(print_numbers(), print_letters())

# Запускаем main
asyncio.run(main())

В этом примере print_numbers и print_letters являются асинхронными функциями (корутинами или сопрограммами). Режим ожидания asyncio.sleep(1) - это неблокирующий режим ожидания, который позволяет другим задачам выполняться во время ожидания. Функция asyncio.gather запускает несколько сопрограмм одновременно, а asyncio.run(main()) запускает основную сопрограмму, которая выполняет задачи.

Concurrent.futures

Модуль concurrent.futures предоставляет высокоуровневый интерфейс для асинхронного выполнения вызываемых объектов с использованием потоков или процессов.

Плюсы:

  1. Упрощает работу с потоками и процессами благодаря высокоуровневому интерфейсу.
  2. Абстрагируется от низкоуровневых деталей управления потоками и процессами.

Минусы:

  1. По-прежнему применяется GIL для потоков, что приводит к более высоким накладным расходам на процессы.

Пример:

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time

def print_numbers():
    for i in range(10):
        print(f"Число: {i}")
        time.sleep(1)

def print_letters():
    for letter in 'abcdefghij':
        print(f"Буква: {letter}")
        time.sleep(1)

# Используем ThreadPoolExecutor
with ThreadPoolExecutor() as executor:
    futures = [executor.submit(print_numbers), executor.submit(print_letters)]
    for future in futures:
        future.result()

print("Все задачи с ThreadPoolExecutor выполнены")

# Используем ProcessPoolExecutor
with ProcessPoolExecutor() as executor:
    futures = [executor.submit(print_numbers), executor.submit(print_letters)]
    for future in futures:
        future.result()

print("Все задачи с ProcessPoolExecutor выполнены")

В этом примере ThreadPoolExecutor управляет пулом потоков для выполнения задач, где executor.submit(print_numbers) планирует выполнение задачи в потоке, а future.result() ожидает завершения задачи и извлекает результат. Аналогично, ProcessPoolExecutor управляет пулом процессов для выполнения задач.

Заключение

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

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

Comments

In order to leave your opinion, you need to register on the website