Полное руководство по методам оптимизации производительности

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

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

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

Из-за своей интерпретируемой природы и глобальной блокировки интерпретатора (GIL) Python часто должен наверстать упущенное по сравнению с такими языками, как C, C++ или Java, которые компилируются и лучше оптимизированы для скорости.

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

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

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

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

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

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

Оглавление

· Измерение производительности Python
· Профилирование кода Python
Использование cProfile для профилирования примера
Использование line_profiler
Использование memory_profiler
· Какая структура данных быстрее?
Сравнение списков и массивов
Сравнение множеств и кортежей
Сравнение класса данных, словаря и именованного кортежа
· Оптимизация кода Python
Цикл против понимания списка
Конкатенация строк
LBYL против EAFP
Генераторы против понимания списка
· Использование потоков и процессов
Использование потоков
Использование процессов
· Заключение

Измерение производительности Python

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

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

Мы рассмотрим несколько простых методов измерения производительности вашего кода Python, включая использование функции time, плагина pytest-benchmark и модуля timeit.

Использование функции Pythontime

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

Мы записываем время начала в start_time перед началом процесса хеширования, а после его завершения мы записываем время окончания в end_time.

Вычитая start_time из end_time, получаем время, затраченное на операцию. В этом примере мы вычисляем хэш SHA1 строки «Turbocharge Your Python Code» три миллиона раз.

def chain_sha1_hash(input_str, iterations):
    current_hash = input_str
    for _ in range(iterations):
        current_hash = hashlib.sha1(current_hash.encode()).hexdigest()
    return current_hash

if __name__ == "__main__":
    input_str = "Turbocharge Your Python Code"

    start_time = time.time()
    result = chain_sha1_hash(input_str, 3_000_000)
    end_time = time.time()

    elapsed_time = end_time - start_time
    print(f"Time taken: {elapsed_time:.2f} seconds")

Использование теста pytest

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

Чтобы использовать pytest-benchmark, установите его с помощью pip, запустив pip install pytest-benchmark. Затем создайте тестовую функцию, которая использует фикстуру benchmark, передав целевую функцию и ее аргументы вызову benchmark(), как показано ниже:

from measuring.time_function import chain_sha1_hash

def test_benchmark_chain_sha1_hash(benchmark):
    benchmark(chain_sha1_hash, "Turbocharge Your Python Code", 100_000)

В этом примере мы определяем тестовую функцию с именем test_benchmark_chain_sha1_hash, которая использует фикстуру benchmark. В тестовой функции мы вызываем benchmark() с chain_sha1_hash в качестве целевой функции, за которой следуют ее входные аргументы: "Turbocharge Your Python Code" и 100,000 итераций. Затем плагин запускает несколько итераций функции и сообщает результат.

Использование модуля timeit

Другой вариант измерения производительности вашего кода Python — использование модуля timeit. Функция timeit предлагает более точную информацию о времени за счет использования наилучших доступных часов в вашей системе, таких как счетчик производительности Python. Это приводит к лучшему разрешению по сравнению с использованием модуля time.

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

Используя модуль timeit, вы можете получить более точные и последовательные измерения производительности для вашего кода Python. В приведенном ниже примере модуль timeit используется для тестирования функции chain_sha1_hash.

def chain_sha1_hash(input_str, iterations=100_000):
    current_hash = input_str
    for _ in range(iterations):
        current_hash = hashlib.sha1(current_hash.encode()).hexdigest()
    return current_hash

if __name__ == "__main__":
    input_str = "Turbocharge Your Python Code"

    elapsed_time = timeit.timeit(
        'chain_sha1_hash(input_str)',
        setup='from __main__ import chain_sha1_hash, input_str',
        number=10
    )

    result = chain_sha1_hash(input_str)

    print(f"Result: {result}")
    print(f"Time taken: {elapsed_time:.2f} seconds")

Профилирование кода Python

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

Python включает два профилировщика на основе событий: profile и cProfile. Модуль profile, написанный на Python, имеет значительные накладные расходы, но его можно легко расширить. С другой стороны, модуль cProfile, написанный на C, работает быстрее с меньшими накладными расходами и служит профилировщиком общего назначения.

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

Использование cProfile для профилирования примера

Чтобы использовать профилировщик cProfile в своем приложении Python, вызовите его, добавив аргумент -m cProfile при запуске сценария. По завершении профилировщик отобразит различные показатели производительности, связанные с вашим кодом.

На следующем снимке экрана показан результат профилирования нашего примера chain_sha1_hash с использованием cProfile.

Встроенные профилировщики profile и cProfile — отличные отправные точки для профилирования кода Python. Однако важно знать об их ограничениях. Во-первых, они могут быть медленными и создавать значительные накладные расходы. Во-вторых, эти встроенные профилировщики не предназначены для профилирования многопоточных приложений.

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

Некоторые из самых популярных и широко используемых внешних профилировщиков для Python включают в себя:

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

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

Использование line_profiler

Чтобы использовать line_profiler, вы должны сначала установить его, выполнив команду pip install line_profiler.. После установки требуется небольшая модификация исходного кода, чтобы включить профилирование вашего приложения.

В частности, вы должны включить декоратор @profile над функцией, которую хотите профилировать, как показано ниже. Этот шаг гарантирует, что функция совместима с line_profiler.

@profile
def chain_sha1_hash(input_str, iterations):
    current_hash = input_str
    for _ in range(iterations):
        current_hash = hashlib.sha1(current_hash.encode()).hexdigest()
    return current_hash

if __name__ == "__main__":
    input_str = "Turbocharge Your Python Code"
    result = chain_sha1_hash(input_str, 100_000)

Чтобы профилировать скрипт с помощью line_profiler, выполните команду kernprof -lv time_function_line_profiler.py. Результирующий профиль будет отображаться, как показано в примере ниже. Обратите внимание на столбец «% времени», в котором указан процент от общего времени выполнения, затраченного на каждую строку кода.

Использование memory_profiler

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

Чтобы использовать Memory Profiler, вам обычно нужно установить его через pip (pip install memory-profiler), а затем декорировать функции, которые вы хотите профилировать, с помощью декоратора @profile. После запуска сценария с включенным профилировщиком памяти вы получите подробный отчет об использовании памяти, включая построчный анализ для профилированных функций, что позволит вам точно определить области с интенсивным использованием памяти в вашем коде и соответствующим образом оптимизировать их.

Давайте применим Memory Profiler к нашему примеру. Посмотрите, как Memory Profiler отображает использование памяти для каждой строки в нашем примере, предоставляя ценную информацию о потреблении памяти кодом.

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

Какая структура данных быстрее?

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

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

Сравнение списков и массивов

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

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

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

Давайте сравним производительность массива NumPy и списка Python, выполнив следующий код. Вместо использования инструмента командной строки line_profiler мы включим модуль LineProfiler непосредственно в наш исходный код.

Мы создадим массив и список, содержащий 10 миллионов случайных чисел, а затем умножим каждое число на два как в списке, так и в массиве.

def double_list(initial_list):
    return [x * 2 for x in initial_list]

def double_array(initial_array):
    return initial_array * 2

size = 10_000_000
initial_list = [random.randint(0, 100) for _ in range(size)]
initial_array = np.random.randint(0, 100, size)

lp = LineProfiler()
lp.add_function(double_list)
lp.add_function(double_array)

lp.runctx('double_list(initial_list)', globals(), locals())
lp.runctx('double_array(initial_array)', globals(), locals())

lp.print_stats()

После выполнения сценария и исследования вывода удвоение элементов в списке Python требует 0,768 секунды, в то время как та же операция с массивом NumPy занимает всего 0,012 секунды. Это указывает на то, что массив NumPy примерно в 64 раза быстрее, чем список Python.

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

Сравнение наборов и кортежей

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

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

Давайте проанализируем следующий скрипт, который генерирует список, набор и кортеж, каждый из которых содержит один миллион элементов. Затем он ищет 1000 раз случайное число в каждой структуре данных.

@profile
def search_items(items_to_search, collection):
    count = 0
    for item in items_to_search:
        if item in collection:
            count += 1
    return count

@profile
def main():
    size = 1000000
    big_list = list(range(size))
    big_set = set(big_list)
    big_tuple = tuple(big_list)

    items_to_find = [random.randint(0, size) for _ in range(1000)]

    count_list = search_items(items_to_find, big_list)
    count_set = search_items(items_to_find, big_set)
    count_tuple = search_items(items_to_find, big_tuple)

    print(f"Found {count_list} items in list")
    print(f"Found {count_set} items in set")
    print(f"Found {count_tuple} items in tuple")

if __name__ == "__main__":
    main()

Снова запускаем профилировщик линий.

Создание структур данных (список, набор и кортеж) заняло небольшую часть общего времени выполнения. Больше всего времени ушло на поиск случайных целых чисел в списке и кортеже, при этом поиск в списке занял 51,5 % от общего времени, а поиск в кортеже — 48,1 % от общего времени.

Поиск в наборе был значительно быстрее, всего за 1247 микросекунд.

Мы также запускаем профилировщик памяти для того же кода, который дает следующий результат.

Создание списка, набора и кортежа увеличило использование памяти до 60,203 МБ, 122,750 МБ и 130,391 МБ соответственно. Наиболее ресурсоемкой структурой данных был набор с приращением 62,547 МБ, за которым следовал список с приращением 38,25 МБ и кортеж с приращением 7,641 МБ.

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

Сравнение Dataclass, Dictionary и NamedTuple

В этом разделе мы сравним три популярные структуры данных в Python: классы данных, словари и именованные кортежи.

Классы данных, представленные в Python 3.7, позволяют легко создавать классы, используемые в основном для хранения данных, с минимальным количеством шаблонного кода.

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

С другой стороны, именованные кортежи — это более удобочитаемая и экономичная альтернатива обычным кортежам, поскольку они предоставляют именованные поля для доступа к элементам.

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

Мы снова используем профилировщик строк для профилирования производительности скрипта.

@profile
def create_datastructures():
    size = 100000

    # Dictionary
    d = {i: (2 * i, i ** 2) for i in range(size)}

    # Named Tuple
    Order = namedtuple('Order', 'order_id double_val square_val')
    namedtuples = [Order(i, 2 * i, i ** 2) for i in range(size)]

    # Data Class
    @dataclass
    class OrderDataClass:
        order_id: int
        double_val: int
        square_val: int

    dataclasses = [OrderDataClass(i, 2 * i, i ** 2) for i in range(size)]

    return d, namedtuples, dataclasses

def main():
    create_datastructures()

if __name__ == "__main__":
    main()

Следующие результаты показывают, что словари предлагают самое быстрое время создания (17%), за ними следуют именованные кортежи (39,5%), а классы данных занимают последнее место по скорости (42,9)%.

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

Оптимизация кода Python

В следующем разделе мы рассмотрим и сравним производительность различных конструкций и методов программирования Python, в том числе «Цикл for против понимания списка», «Конкатенация строк», «Разрешение или прощение» и «Генераторы».

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

Для понимания цикла и списка

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

Циклы for обеспечивают гибкость, что делает их подходящими для более сложной бизнес-логики; однако они могут быть длинными и медленными, чем понимание списков для более простых задач. Напротив, списковые включения являются краткими, быстрыми и отлично подходят для простой логики, но предлагают меньшую гибкость.

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

def for_loop(orders: List[int]) -> List[int]:
    result = []
    for amount in orders:
        if amount > 50:
            result.append(amount * 2)
    return result

def list_comprehension(orders: List[int]) -> List[int]:
    return [amount * 2 for amount in orders if amount > 50]

@profile
def main():
    orders = [random.randint(0, 100) for _ in range(100000)]
    for_loop_result = for_loop(orders)
    list_comprehension_result = list_comprehension(orders)

if __name__ == "__main__":
    main()

Конкатенация строк

В Python существует несколько методов объединения строк. Это самые распространенные.

  • Использование оператора +: Оператор + может объединять две или более строк. Этот метод прост и интуитивно понятен, но неэффективен при объединении большого количества строк.
  • Использование метода join(): метод join() объединяет несколько строк из итерируемого объекта (например, списка или кортежа) в одну строку. Он более эффективен, чем оператор +, особенно при объединении большого количества строк.
  • Использование интерполяции строк. Интерполяция строк — это метод, который позволяет встраивать переменные в строку. В Python вы можете использовать f-строки, str.format() или оператор % для интерполяции строк. Этот метод особенно полезен при включении переменных или выражений в строку.

Следующий пример демонстрирует разницу в производительности между оператором + и методом join() при объединении 10 000 строк.

def random_string(length):
    return "".join(random.choices(string.ascii_lowercase, k=length))

def plus_operator(strings):
    result = ""
    for s in strings:
        result += s
    return result

def join_method(strings):
    return "".join(strings)

@profile
def main():
    strings = [random_string(10) for _ in range(10000)]

    plus_operator(strings)
    join_method(strings)

if __name__ == "__main__":
    main()

Судя по результатам профилирования, метод join_method() значительно быстрее, чем метод plus_operator() для объединения строк в этом сценарии.

LBYL против EAFP

В этом разделе исследуется производительность двух разных стилей кодирования. Первый подход, Look Before You Leap (LBYL), включает в себя проверку выполнения заданного условия перед выполнением операции.

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

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

В примере мы создаем словарь, содержащий 100 000 элементов. Затем мы используем оба подхода для извлечения значения из словаря.

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

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

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

import random
import string

# Generate a large dataset
data = {"".join(random.choices(string.ascii_lowercase, k=10)):
        random.randint(1, 100) for _ in range(100000)}

@profile
def lbyl_style(key, data):
    if key in data:
        return data[key]
    return None

@profile
def eafp_style(key, data):
    try:
        return data[key]
    except KeyError:
        return None

def main(hit_ratio):
    # Generate a list of random keys, some of which may not be in the dataset
    num_keys_to_check = 10000
    num_present_keys = int(num_keys_to_check * hit_ratio)
    num_missing_keys = num_keys_to_check - num_present_keys

    present_keys = random.sample(list(data.keys()), num_present_keys)
    missing_keys = ["".join(random.choices(string.ascii_lowercase, k=10))
                    for _ in range(num_missing_keys)]

    keys_to_check = present_keys + missing_keys
    random.shuffle(keys_to_check)

    for key in keys_to_check:
        lbyl_style(key, data)
        eafp_style(key, data)

if __name__ == "__main__":
    main(hit_ratio=0.5)  # You can adjust this value to change the ratio of keys present in the dataset.

При коэффициенте попаданий 50% подход LBYL (функция lbyl_style) занимает общее время 0,0022 секунды. Подход EAFP (функция eafp_style) занимает общее время 0,003345 секунды, что больше, чем подход LBYL.

При коэффициенте попаданий 50% подход LBYL работает быстрее, чем подход EAFP.

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

Проверим это на дополнительном примере. В этом сценарии мы изменяем коэффициент попаданий от 0% до 100% и используем Matplotlib для создания графика, показывающего разницу в производительности между LBYL и EAFP.

График показывает, что производительность стиля EAFP улучшается по мере увеличения коэффициента попаданий, в то время как производительность стиля LBYL остается относительно стабильной.

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

Генераторы против списков

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

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

Давайте создадим пример для сравнения потребления памяти как генераторными выражениями, так и списковыми включениями.

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

def random_numbers(n):
    return [random.randint(1, 100) for _ in range(n)]

@profile
def list_comprehension(numbers):
    return [x ** 2 for x in numbers]

@profile
def generator_expression(numbers):
    return (x ** 2 for x in numbers)

def main():
    numbers = random_numbers(1000000)
    squared_numbers_list = list_comprehension(numbers)
    squared_numbers_gen = generator_expression(numbers)

if __name__ == "__main__":
    main()

Судя по приведенным ниже результатам профилировщика памяти, понимание списка потребляет значительно больше памяти, чем выражение генератора. Потребление памяти увеличилось на 40,641 МБ при использовании понимания списка, тогда как выражение генератора не увеличило использование памяти.

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

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

Использование потоков и процессов

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

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

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

Сначала мы рассмотрим потоки, а затем перейдем к процессам.

Использование потоков

Для использования потоков в Python используется модуль threading. Существует два подхода к созданию потоков: создание подкласса класса Thread или использование функции Thread с целевым аргументом.

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

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

Загрузка изображений — это задача, связанная с вводом-выводом, и в этих случаях потоки Python могут значительно повысить производительность.

import threading
import urllib.request
import time

def download_image(image_url, save_as):
    print(f"Downloading {image_url}...")
    urllib.request.urlretrieve(image_url, save_as)
    print(f"Downloaded {image_url} as {save_as}.")

base_url = "https://commons.wikimedia.org/wiki/Special:NewFiles#/media/File:"
image_urls = [
    base_url + "Christopher_Street_Day_Berlin_2019_510.jpg",
    base_url + "CSD_Frankfurt_Slubice_2021_029.jpg",
    base_url + "21.04.2023_MUC-Stammtisch-Erkundung_18.jpg",
    base_url + "SAZANKA_STREET_(52478707745).jpg",
]

start_time = time.time()

for i, image_url in enumerate(image_urls):
    download_image(image_url, f"image{i+1}.jpg")

sequential_time = time.time() - start_time
print(f"\nSequential download time: {sequential_time:.2f} seconds\n")

start_time = time.time()

threads = []

for i, image_url in enumerate(image_urls):
    thread = threading.Thread(target=download_image, args=(image_url, f"image_threaded{i+1}.jpg"))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

multithreaded_time = time.time() - start_time
print(f"\nMultithreaded download time: {multithreaded_time:.2f} seconds\n")

print(f"Performance improvement using threads: {sequential_time/multithreaded_time:.2f} times")

На моей машине и моем интернет-соединении последовательная загрузка заняла 2,48 секунды, а многопоточная версия — 0,71 секунды. Что является значительным улучшением производительности в 3,47 раза.

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

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

Использование процессов

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

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

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

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

def download_image(image_url, save_as):
    print(f"Downloading {image_url}...")
    urllib.request.urlretrieve(image_url, save_as)
    print(f"Downloaded {image_url} as {save_as}.")

def main():
    base = "https://commons.wikimedia.org/wiki/Special:NewFiles#/media/File:"
    image_urls = [
        base + "Christopher_Street_Day_Berlin_2019_510.jpg",
        base + "CSD_Frankfurt_Slubice_2021_029.jpg",
        base + "21.04.2023_MUC-Stammtisch-Erkundung_18.jpg",
        base + "SAZANKA_STREET_(52478707745).jpg",
    ]

    start_time = time.time()

    for i, image_url in enumerate(image_urls):
        download_image(image_url, f"image{i+1}.jpg")

    sequential_time = time.time() - start_time
    print(f"\nSequential download time: {sequential_time:.2f} seconds\n")

    start_time = time.time()

    processes = []

    for i, image_url in enumerate(image_urls):
        process = multiprocessing.Process(target=download_image,
                                          args=(image_url,
                                                f"image_processed{i+1}.jpg"))
        processes.append(process)
        process.start()

    for process in processes:
        process.join()

    multiprocessed_time = time.time() - start_time
    print(f"\nMultiprocess download time: {multiprocessed_time:.2f} seconds\n")

    print(f"Perf impr process: {sequential_time/multiprocessed_time:.2f}x")

if __name__ == "__main__":
    multiprocessing.freeze_support()
    main()

На моей машине и моем интернет-соединении последовательная загрузка заняла 2,73 секунды, а версия с несколькими процессами — 0,74 секунды. Что является значительным улучшением производительности в 3,67 раза.

Использование нескольких процессов повышает производительность приложений, интенсивно использующих ЦП, но также создает проблемы при обмене данными и координации.

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

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

Заключение

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

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

Мы сравнили производительность различных стилей и методов кодирования, в том числе циклов for и списков, методов конкатенации строк, LBYL и EAFP, а также генераторов и списков. Важно понимать компромиссы и использовать наиболее подходящий подход к проблеме.

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

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

Благодаря знаниям и инструментам, представленным в этой статье, вы теперь хорошо подготовлены для анализа, оптимизации и повышения производительности ваших приложений Python.

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