Учебники

28) Многопоточность в Python

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

Что такое тема?

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

Что такое процесс?

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

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

Многопоточность — это метод, который позволяет процессору выполнять несколько потоков одновременно. Эти потоки могут выполняться отдельно при совместном использовании ресурсов процесса.

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

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

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

Чтобы понять процессы и потоки, рассмотрите следующий сценарий: .exe-файл на вашем компьютере — это программа. Когда вы открываете его, ОС загружает его в память, а процессор выполняет его. Экземпляр программы, которая сейчас запущена, называется процессом.

Каждый процесс будет иметь 2 основных компонента:

  • Код
  • Данные

Теперь процесс может содержать одну или несколько частей, называемых потоками. Это зависит от архитектуры ОС. Вы можете думать о потоке как о разделе процесса, который может выполняться операционной системой отдельно.

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

В этом уроке вы узнаете,

Зачем использовать многопоточность?

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

Python MultiThreading

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

  1. Модуль потока , и
  2. Резьб модуль

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

Модули Thread и Threading

Эти два модуля , которые вы узнаете в этом руководстве , является модулем резьбы и модуль нарезания резьбы .

Тем не менее, модуль потока давно устарел. Начиная с Python 3, он был обозначен как устаревший и доступен только как __thread для обратной совместимости.

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

Модуль потока

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

thread.start_new_thread(function_name, arguments)

Хорошо, теперь вы рассмотрели базовую теорию, чтобы начать кодирование. Итак, откройте свой IDLE или блокнот и введите следующее:

import time
import _thread

def thread_test(name, wait):
   i = 0
   while i <= 3:
      time.sleep(wait)
      print("Running %s\n" %name)
      i = i + 1

   print("%s has finished execution" %name)

if __name__ == "__main__":
    
    _thread.start_new_thread(thread_test, ("First Thread", 1))
    _thread.start_new_thread(thread_test, ("Second Thread", 2))
    _thread.start_new_thread(thread_test, ("Third Thread", 3))

Сохраните файл и нажмите F5, чтобы запустить программу. Если все было сделано правильно, это вывод, который вы должны увидеть:

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

ОБЪЯСНЕНИЕ КОДА

  1. Эти операторы импортируют модуль времени и потока, который используется для обработки и задержки потоков Python.
  2. Здесь вы определили функцию с именем thread_test, которая будет вызываться методом start_new_thread . Функция запускает цикл while для четырех итераций и печатает имя потока, который ее вызвал. По завершении итерации печатается сообщение о том, что поток завершил выполнение.
  3. Это основной раздел вашей программы. Здесь, вы просто вызовите start_new_thread метод с thread_test функции в качестве аргумента.

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

Поточный модуль

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

Структура модуля Threading

Вот список некоторых полезных функций, определенных в этом модуле:

Имя функции Описание
activeCount () Возвращает количество объектов Thread, которые еще живы
currentThread () Возвращает текущий объект класса Thread.
перечисление () Перечисляет все активные объекты Thread.
isDaemon () Возвращает true, если поток является демоном.
является живым() Возвращает true, если поток еще жив.
Методы класса Thread
Начните() Запускает активность потока. Он должен вызываться только один раз для каждого потока, потому что он вызовет ошибку времени выполнения, если вызывается несколько раз.
запустить() Этот метод обозначает активность потока и может быть переопределен классом, который расширяет класс Thread.
присоединиться() Он блокирует выполнение другого кода до тех пор, пока поток, в котором был вызван метод join (), не завершится.

Предыстория: класс Thread

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

Наиболее распространенный способ создания многопоточного приложения на Python — это объявление класса, который расширяет класс Thread и переопределяет его метод run ().

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

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

  1. определить класс, который расширяет класс Thread
  2. Переопределить конструктор __init__
  3. Переопределение прогона () метод

Как только объект потока создан, метод start () может использоваться для начала выполнения этого действия, а метод join () может использоваться для блокировки всего другого кода до завершения текущего действия.

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

import time
import threading

class threadtester (threading.Thread):
    def __init__(self, id, name, i):
       threading.Thread.__init__(self)
       self.id = id
       self.name = name
       self.i = i
       
    def run(self):
       thread_test(self.name, self.i, 5)
       print ("%s has finished execution " %self.name)

def thread_test(name, wait, i):

    while i:
       time.sleep(wait)
       print ("Running %s \n" %name)
       i = i - 1

if __name__=="__main__":
    thread1 = threadtester(1, "First Thread", 1)
    thread2 = threadtester(2, "Second Thread", 2)
    thread3 = threadtester(3, "Third Thread", 3)

    thread1.start()
    thread2.start()
    thread3.start()

    thread1.join()
    thread2.join()
    thread3.join()

Это будет вывод, когда вы выполните приведенный выше код:

ОБЪЯСНЕНИЕ КОДА

  1. Эта часть такая же, как в нашем предыдущем примере. Здесь вы импортируете модуль времени и потока, который используется для обработки выполнения и задержек потоков Python.
  2. В этом бите вы создаете класс с именем threadtester, который наследует или расширяет класс Thread модуля Threading. Это один из самых распространенных способов создания потоков в Python. Однако вам следует переопределить только конструктор и метод run () в вашем приложении. Как вы можете видеть в приведенном выше примере кода, метод __init__ (конструктор) был переопределен.

    Точно так же вы также переопределили метод run () . Он содержит код, который вы хотите выполнить внутри потока. В этом примере вы вызвали функцию thread_test ().

  3. Это метод thread_test (), который принимает значение i в качестве аргумента, уменьшает его на 1 на каждой итерации и перебирает оставшуюся часть кода до тех пор, пока i не станет равным 0. На каждой итерации он печатает имя текущего выполняющегося потока и спит в течение секунд ожидания (что также принимается в качестве аргумента).
  4. thread1 = threadtester (1, «Первая нить», 1)

    Здесь мы создаем поток и передаем три параметра, которые мы объявили в __init__. Первый параметр — это идентификатор потока, второй параметр — имя потока, а третий параметр — счетчик, который определяет, сколько раз должен выполняться цикл while.

  5. thread2.start ()

    Метод start используется для запуска выполнения потока. Внутри функция start () вызывает метод run () вашего класса.

  6. thread3.join ()

    Метод join () блокирует выполнение другого кода и ожидает завершения потока, в котором он был вызван.

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

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

Тупики и условия гонки

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

  • Критический раздел

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

  • Переключение контекста

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

Тупики

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

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

Пять философов сидят за круглым столом с пятью тарелками спагетти (разновидность макарон) и пятью вилками, как показано на схеме.

Столовая Философы Проблема

В любой момент времени философ должен либо есть, либо думать.

Кроме того, философ должен взять две вилки рядом с ним (то есть левую и правую вилки), прежде чем он сможет съесть спагетти. Проблема тупика возникает, когда все пять философов одновременно поднимают свои правильные вилки.

Поскольку у каждого из философов есть одна вилка, они все будут ждать, пока другие положат свою вилку. В результате никто из них не сможет съесть спагетти.

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

Условия гонки

Состояние гонки — это нежелательное состояние программы, которое возникает, когда система выполняет две или более операций одновременно. Например, рассмотрим этот простой цикл for:

i=0; # a global variable
for x in range(100):
    print(i)
    i+=1;

Если вы создаете n потоков, которые одновременно запускают этот код, вы не сможете определить значение i (которое совместно используется потоками), когда программа завершит выполнение. Это связано с тем, что в реальной среде многопоточности потоки могут перекрываться, и значение i, которое было извлечено и изменено потоком, может меняться между ними, когда какой-то другой поток обращается к нему.

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

Синхронизация потоков

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

Блокировка — это низкоуровневый примитив синхронизации, реализованный модулем __thread . В любой момент времени блокировка может находиться в одном из двух состояний: заблокирована или разблокирована. Поддерживает два метода:

  1. приобретать ()

    Когда состояние блокировки разблокировано, вызов метода acqu () изменит состояние на заблокированное и вернет его. Однако, если состояние заблокировано, вызов acqu () блокируется до тех пор, пока метод release () не будет вызван каким-либо другим потоком.

  2. релиз()

    Метод release () используется для установки состояния unlocked, т. Е. Для снятия блокировки. Он может быть вызван любым потоком, не обязательно тем, который получил блокировку.

Вот пример использования блокировок в ваших приложениях. Запустите ваш IDLE и введите следующее:

import threading
lock = threading.Lock()

def first_function():
    for i in range(5):
        lock.acquire()
        print ('lock acquired')
        print ('Executing the first funcion')
        lock.release()

def second_function():
    for i in range(5):
        lock.acquire()
        print ('lock acquired')
        print ('Executing the second funcion')
        lock.release()

if __name__=="__main__":
    thread_one = threading.Thread(target=first_function)
    thread_two = threading.Thread(target=second_function)

    thread_one.start()
    thread_two.start()

    thread_one.join()
    thread_two.join()

Теперь нажмите F5. Вы должны увидеть результат вроде этого:

ОБЪЯСНЕНИЕ КОДА

  1. Здесь вы просто создаете новую блокировку, вызывая функцию фабрики threading.Lock () . Внутренне, Lock () возвращает экземпляр наиболее эффективного конкретного класса Lock, который поддерживается платформой.
  2. В первом утверждении вы получаете блокировку, вызывая метод acqu (). Когда блокировка предоставлена, вы печатаете «блокировка получена» на консоли. Как только весь код, который вы хотите запустить, завершил выполнение, вы снимаете блокировку, вызывая метод release ().

Теория в порядке, но откуда вы знаете, что блокировка действительно работает? Если вы посмотрите на вывод, вы увидите, что каждый из операторов печати печатает ровно одну строку за раз. Напомним, что в более раннем примере выходные данные из print были случайными, потому что несколько потоков обращались к методу print () одновременно. Здесь функция печати вызывается только после получения блокировки. Таким образом, выходные данные отображаются по одному и построчно.

Помимо блокировок, python также поддерживает некоторые другие механизмы для обработки синхронизации потоков, как указано ниже:

  1. RLocks
  2. семафоры
  3. условия
  4. События и
  5. барьеры

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

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

  1. Код с привязкой к ЦП: это относится к любому коду, который будет непосредственно выполняться ЦП.
  2. Код, связанный с вводом / выводом: это может быть любой код, который обращается к файловой системе через ОС
  3. CPython: это эталонная реализация Python и может быть описана как интерпретатор, написанный на C и Python (язык программирования).

Что такое GIL?

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

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

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

  1. Интерпретатор Python создает новый процесс и порождает потоки
  2. Когда поток 1 начинает работать, он сначала получает GIL и блокирует его.
  3. Если поток 2 хочет выполнить сейчас, ему придется ждать освобождения GIL, даже если другой процессор свободен.
  4. Теперь предположим, что поток-1 ожидает операции ввода-вывода. В это время он выпустит GIL, а thread-2 получит его.
  5. После завершения операций ввода-вывода, если поток-1 хочет выполнить сейчас, ему снова придется ждать, пока поток-GIL не будет выпущен потоком-2.

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

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

Зачем нужен GIL?

Сборщик мусора CPython использует эффективную технику управления памятью, известную как подсчет ссылок. Вот как это работает: Каждый объект в Python имеет счетчик ссылок, который увеличивается, когда он присваивается новому имени переменной или добавляется в контейнер (например, кортежи, списки и т. Д.). Аналогично, счетчик ссылок уменьшается, когда ссылка выходит из области видимости или когда вызывается оператор del. Когда счетчик ссылок объекта достигает 0, он подвергается сборке мусора, и выделенная память освобождается.

Но проблема в том, что переменная подсчета ссылок склонна к условиям гонки, как и любая другая глобальная переменная. Чтобы решить эту проблему, разработчики python решили использовать глобальную блокировку интерпретатора. Другим вариантом было добавить блокировку к каждому объекту, что привело бы к взаимным блокировкам и увеличению накладных расходов от вызовов acqu () и release ().

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

Резюме

  • Python поддерживает 2 модуля для многопоточности:
    1. Модуль __thread : обеспечивает низкоуровневую реализацию для многопоточности и является устаревшим.
    2. Модуль потоков : обеспечивает высокоуровневую реализацию многопоточности и является текущим стандартом.
  • Чтобы создать поток с помощью модуля потоков, вы должны сделать следующее:
    1. Создайте класс, который расширяет класс Thread .
    2. Переопределите его конструктор (__init__).
    3. Переопределите его метод run () .
    4. Создайте объект этого класса.
  • Поток можно выполнить, вызвав метод start () .
  • Метод join () может использоваться для блокировки других потоков, пока этот поток (тот, в котором было вызвано соединение) не завершит выполнение.
  • Состояние гонки возникает, когда несколько потоков одновременно получают доступ или изменяют общий ресурс.
  • Этого можно избежать, синхронизируя потоки.
  • Python поддерживает 6 способов синхронизации потоков:
    1. Замки
    2. RLocks
    3. семафоры
    4. условия
    5. События и
    6. барьеры
  • Блокировки позволяют только определенному потоку, который получил блокировку, войти в критическую секцию.
  • У блокировки есть 2 основных метода:
    1. приобретать () : это устанавливает состояние блокировки в заблокированное. При вызове заблокированного объекта он блокируется до тех пор, пока ресурс не станет свободным.
    2. release () : устанавливает состояние блокировки как разблокированное и возвращает. Если вызывается для разблокированного объекта, он возвращает false.
  • Глобальная блокировка интерпретатора — это механизм, посредством которого одновременно может выполняться только 1 процесс интерпретатора CPython.
  • Он был использован для облегчения подсчета ссылок в сборщике мусора CPythons.
  • Чтобы создавать приложения Python с интенсивными операциями, связанными с процессором, вы должны использовать многопроцессорный модуль