Python – достаточно простой и популярный язык программирования с объектно-ориентированной парадигмой. Он является функциональным и поддерживает огромное количество разнообразных фреймворков и библиотек. Изучать Python и его инструменты рекомендуется как новичкам в программировании, так и тем, кто уже имеет опыт в разработке программного обеспечения.
В процессе использования the Python предстоит решать самые разные задачи. Современные приложения проектируются так, чтобы их задачи выполнялись параллельно. Для этого в the Python имеется специальная библиотека. Она называется threading. Далее она будет рассмотрена более подробно. Также предстоит разобраться в работе многопоточности в the Python.
Принцип работы многопоточности
В информатике поток – это минимальная единица работы, которая запланирована для выполнения операционной системой. Он имеет такие особенности как:
- существование внутри процесса;
- в одном процессе допускается несколько потоков;
- поток в одном процессе разделяет состояние и память родительского процесса.
Многопоточность – это выполнение приложения сразу в нескольких потоках, которые отвечают за выполнения ее функций одновременно.
Многопоточная разработка напоминает мультипроцессорную. Эти две концепции похожи между собой. Только в первом случае программное обеспечение работает с потоками, во втором – непосредственно с процессами. Разница между этими компонентами элементарна: потоки обладают общей памятью, поэтому изменения в одном из них видны в других. Процессы же используют разные области памяти.
В одноядерных процессорах операции из разных потоков выполняются не параллельно. Одно ядро способно выполнить только одну операцию в заданную единицу времени. Из-за того, что команды обрабатываются очень быстро, складывается впечатление, что они выполняются параллельно. Такое поведение называется псевдопараллельностью. Реально параллельная работа поддерживается только на многоядерных процессорах. Там каждое ядро способно выполнять операции независимо от других.
Threading – для чего нужен
В the Python threading представляет собой специальную библиотеку для многопоточности. Изначально в рассматриваемом языке программирования используется GIL, который является однопоточным. Все потоки, которые создаются через threading, будут функционировать внутри GIL-потока. Они обрабатываются только одним ядром.
Если приложению необходимо выполнять одновременно несколько задач, предстоит пользоваться упомянутой библиотекой. Также threading потребуется в следующих ситуациях:
- Обработка нажатия кнопки в графическом интерфейсе. Пример – через Tkinter. Если по нажатию кнопки требуется осуществлять множество действий, которые требуют времени, соответствующие операции выполняются в другом потоке. Это необходимо для устранения вероятности «подвисания» графического интерфейса.
- Приложение функционирует одновременно с подключением нескольких устройств. Они могут быть подсоединены к разным COM-портам.
- Загрузка файлов из сети и одновременная обработка уже загруженных элементов.
Это лишь несколько наглядных примеров. Если требуется добиться работы приложения одновременно на нескольких физических ядрах процессора, рекомендуется обратить внимание на the Python библиотеку (модуль) – Multiprocessing.
У threading есть определенные преимуществе перед Multiprocessing:
- Простое использование. Начать работу с этой библиотекой достаточно легко.
- Простота передачи данных из потока в основное приложение. Допускается использование глобальных переменных, но в этом случае программное обеспечение должно быть грамотно спроектировано: без ошибок, которые связаны с «Состоянием гонки».
Если приложение будет запускаться на одноядерном компьютере или нагрузка на процессор окажется небольшой, threading – оптимальное решение для работы с потоками.
Параллелизм – это…
Параллелизм дает возможность нескольким вычислениям на устройстве работать одновременно в рамках единого приложения. Подобного поведения в the Python обычно удается добиться несколькими способами:
- через многопоточность threading;
- при помощи multiprocessing;
- за счет асинхронного ввода-вывода с модулем asyncio.
Асинхронный ввод-вывод не относится ни к потоковым, ни к многопроцессорным. Это однопоточная однопроцессорная парадигма, которая не имеет отношения к параллельным вычислениям.
Подключение threading
Для начала работы с библиотекой threading в the Python не придется ничего дополнительно устанавливать. Упомянутый инструмент – это стандартный модуль. Он поставляется вместе с интерпретатором. Его потребуется всего лишь подключить через специальную команду:
Import threading
Работа с потоками возможна за счет создания экземпляров класса Thread. Чтобы сделать отдельный поток, потребуется написать экземпляр класса, а затем применить к нему метод start. Ниже можно увидеть наглядный пример:
Здесь функция mydef запущена в отдельном потоке. В виде ее аргументов были переданы числа 1 и 2.
Threading.Thread()
The threading.Thread() – конструкция, позволяющая создавать новые потоки путем создания экземпляров класса Thread. Ее синтаксическая форма в the Python выглядит так:
Здесь:
- Group. Имеет значение None. Зарезервирована данная функция для будущего расширения в процессе реализации класса ThreadGroup.
- Target – функция, выполняемая в потоке через метод run(). Если передается значение None, ничего вызываться не будет.
- Name – потоковое имя. По умолчанию оно принимает значение «Thread-X», где X – это десятичное число. Имя задается разработчиком самостоятельно, вручную.
- Args – кортеж, в котором будут храниться аргументы, передаваемые в вызываемую функцию.
- Kwargs – словарь для хранения аргументов, передаваемых в функцию.
- Daemon – параметр, указывающий, является ли поток демоническим. По умолчанию у него установлено значение None. В этом случае свойство daemonic наследуется от текущего потока. Разработчик способен самостоятельно устанавливать значение соответствующего параметра.
Daemon требует более подробного рассмотрения. Не все разработчики быстро понимают, что этот параметр собой представляет.
Несколько слов о демонах
Изучая threading в the Python, необходимо не забывать о демонах. Так называют процессы, работающие в фоновом режиме. В рассматриваемом языке программирования для него имеется более конкретное название: поток демона или демонический поток. В отличие от обычных потоков, демонический завершит работу, если закрыть приложение. Программа не станет ожидать завершения daemon-потока. При ее закрытии соответствующие потоки будут уничтожены, независимо от того, в каком именно состоянии они не находились.
Дэймон-потоки используются для выполнения операций, которые работают в бесконечных циклах. В других ситуациях чаще всего используются обычные потоки, которые могут задержать закрытие программы до тех пор, пока не завершат выполнение всех операций.
Пример использования демонического потока – когда приложение полностью перезаписывает содержимое документа, а механизм этой операции реализован в упомянутом потоке, то при неожиданном выходе из программного обеспечения данные будут утрачены.
Дэймон-потоки часто помещаются в функции по рисованию графических интерфейсов. Так называется бесконечная операция, которая завершается сразу после выхода из программы. Если использовать обычный поток, это помешает закрыть программное обеспечение.
Методы для работы с потоками
В the Python для создания и управления потоками применяются разнообразные методы класса Thread. С их помощью возможно не только манипулирование, но и определение дальнейшего потокового поведения. Далее предстоит рассмотреть существующие методы, помогающие работать с многопоточностью.
Start()
Start() – первый и самый основной метод the Python в рассматриваемом вопросе. Он применяется для того, чтобы запускать созданный ранее поток. После использования threading.Thread() появляется новый поток, но он неактивен. Чтобы запустить его в программном обеспечении, обязательно применение start-метода. Выглядит это так:
Пример выше работает так, что, пока метод start не вызван программой, в ней не будет запускаться функция myfunc.
Join()
Join() – еще один метод в the Python. Он отвечает за блокировку выполнения потока, который его вызвал. Происходит это до тех пор, пока не завершится поток, метод которого был вызван программным обеспечением. Это значит, что если в thread1 был вызван метод thread2:thread2.join(), thread1 будет приостановлен. Он возобновит работу, как только thread2 будет завершен.
Соответствующий метод позволяет заставлять приложение дожидаться завершения демонического потока. Пример – при вызове метода в основном thread программа не завершается до тех пор, пока «демон» не будет выполнен.
Join() поддерживает аргумент timeout. По умолчанию у него значение установлено на значении None. Разработчик имеет право передать в него число с плавающей запятой. Если у аргумента установлено значение по умолчанию, значит the thread будет приостановлен, пока выполняется поток метода. При передаче в виде аргумента числового значения, метод join() получит время ожидания. Когда оно подойдет к концу, thread продолжит свое функционирование.
Run()
Run() – метод в the Python, помогающий описывать выполняемые в thread операции. Он применяется при явном создании экземпляра класса.
Выше можно увидеть наглядный пример применения run().
Is_alive()
Метод, позволяющий проверить выполнение thread в текущий момент времени. Он часто используется вместе с join(). С помощью is_alive() удастся грамотно управлять выполнением демонических потоков, не давая им неожиданно завершать работу при закрытии программного обеспечения.
Выше – пример практического применения is_alive() с подробными комментариями.
Потоковая остановка
Иногда рассматриваемый компонент, работающий в фоне, необходимо остановить. Пример – thread с наличием в функции run бесконечного цикла. В основном приложении требуется прекратить его функционирование. Самый простой вариант – это использование специальной переменной в the Python. Она называется stop.
В этом случае необходимо:
- Через stop. В бесконечном цикле создается постоянная проверка этой переменной. Если ее значение равняется true, цикл завершается.
- Нельзя использовать функции, способные блокировать выполнение на длительный промежуток времени. Нужно задействовать timeout.
Вот пример приложения, в котором применяется потоковая остановка:
Тут используется глобальная переменная stop. Когда необходимо остановить the thread, ей присваивается значение True. После этого останется лишь дождаться непосредственной реализации заданной «команды».
Состояние гонки
Race condition или «состояние гонки» – ошибка, которая возникает при неправильном проектировании многопоточного программного обеспечения. Она появляется, когда несколько потоков обращаются к одной и той же информации. Пример – переменная хранит число, которое одновременно пытаются скорректировать thread1 и thread2. Это приведет к непредсказуемым результатам и даже ошибкам/сбоям.
Распространены ситуации, при которых один thread проверяет значения переменных на выполнение условия для совершения того или иного действия, но в процессе реализации этой операции в работу вмешивается второй thread, изменяющий значение переменной. Это влечет за собой получение конечным пользователем неправильных результатов. Вот – наглядный пример такой ситуации:
Состояние гонки – ситуация, приводящая к самым разным проблемам:
- утечке памяти;
- потере информации;
- уязвимостям в безопасности запускаемого программного обеспечения;
- взаимным потоковым блокировкам;
- получению ошибочных (неверных) данных.
Предотвратить состояние гонки помогает управление доступом к общим ресурсам. Его необходимо изучить более детально.
Доступ к общим ресурсам – lock
Для устранения состояния гонки необходимо воспользоваться блокировкой – threading.Lock(). Она не дает нескольким потокам работать с одной и той же информацией. Lock отвечает за защиту данных от одновременного доступа.
Threading.Lock() возвращает объект, который является своеобразной «дверью». Она будет запираться, если в комнате кто-то находится. Это значит, если потом использовал Lock (осуществлен вход в комнату), другой поток должен вынужденно находиться в ожидании отказа от «предыдущего thread» (в ожидании выхода из комнаты). Полученный объект будет обладать двумя методами – release() и acquire().
Acquire()
Acquire() – это метод, который дает возможность the thread получить блокировку. Он обладает двумя аргументами: blocking и timeout. При вызове метода с аргументом blocking = true (параметр, устанавливаемый по умолчанию) Lock блокируется до тех пор, пока он не будет разблокирован. Возвращаемое значение окажется true. Если object уже заблокирован, поток окажется приостановленным. Он будет ожидать разблокировки, после чего самостоятельно его заблокирует.
Если будет вызван аргумент с False, при условии, что объект Lock разблокирован, метод будет блокировать его, а затем возвращать значение true. Если Lock заблокирован, метод ничего делать не будет. В приложении просто возвращается значение False.
Аргумент timeout (по умолчанию -1), предусматривает возможность изменения, только если аргумент blocking имеет значение true. Если в виде параметра передается положительное значение, объект блокируется на указанный промежуток времени (в секундах) с учетом ожидания блокировки. Аргумент по умолчанию указывает методу на необходимость применения бесконечного ожидания.
Release()
Release() – метод, который отвечает за разблокировку Lock. Интерпретатор позволит вызвать его из любого потока, а не только из того, который заблокировал упомянутый ранее объект на текущий момент.
The release() ничего не возвращает. Он вызывает ошибку в приложении RuntimeError, если метод будет вызван, когда Lock уже разблокирован. Ниже представлен наглядный пример использования release():
В примере выше:
- Создается Lock-объект. Он поможет безопасно считывать и корректировать информацию.
- В виде данных, подлежащих блокировке, выступает одна переменная x.
- После продемонстрировано безопасное изменение информации: сначала при помощи acquire требуется дождаться своей очереди к ним, после чего происходит ее корректировка. Она заключается в перезаписи значения переменной.
- Система выводит значение в консоль.
- Далее происходит освобождение доступа для других потоков.
Если все потоки, которым потребуется доступ к данным x, предусматривают использование Lock, получится устранить ситуацию гонки.
Deadlock
При использовании the Lock появляется серьезная проблема. Она нередко приводит к полному прекращению функционирования программного обеспечения. Если вызвать acquire(), когда the Lock уже разблокирован, то thread, который вызвал acquire(), будет находиться в ожидании вызова release() потоком, заблокировавший объект.
Если один thread вызывает блокировку (method) несколько раз подряд, его выполнение прекращается. Это происходит до тех пор, пока поток самостоятельно не обратится к вызову release(). Это невозможно, потому что выполнение the thread приостановлено. Ситуация указывает на то, что исходное приложение попадает под бесконечную блокировку.
Самоблокировка – процесс, который можно предотвратить. Для этого потребуется удалить лишний вызов acquire(). Подобная операция возможна не всегда.
Самоблокировка происходит по разным причинам. К ним можно отнести:
- появление ошибок, когда Lock остается со статусом «блокировка активирована»;
- неправильное проектирование приложения – когда одна команда вызывается другой функцией, у которой нет блокировки.
При появлении проблем достаточно воспользоваться специальной конструкцией – try-finally. Вместо нее допускается использование оператора with:
Try-finally – конструкция, с помощью которой получится удалить блокировку, даже если возникнут ошибки. Это позволяет миновать the deadblock. Вот наглядный пример реализации концепции:
С помощью the try-finally гарантируется, что код в finally всегда исполняется, независимо от ошибок и результатов, полученных в блоке try. В случае, когда блокировка вызвана неправильным проектированием, соответствующий прием не работает. Для исправления ситуации был создан специальный объект RLock.
RLock
Если Lock заблокирован, он будет блокировать любые потоки the Python, которые попытались сделать то же самое, даже если этот thread является владельцем блокировки на текущий момент. Вот пример кода, который поможет лучше понять ситуацию:
Функционирование данного фрагмента кода допускается, но существует одна проблема. Она заключается в том, что в процессе вызова функции both_parts, в ней будут вызваны команды part1 и part2. Между обращением к соответствующим операциям допускается ситуация, когда какой-нибудь thread получит доступ к информации и поменяет ее.
Для устранения соответствующей ситуации необходимо заблокировать lock1. При переписывании программного кода в both_parts получится следующий результат:
Идея здесь простая: внешняя both_parts блокирует поток на время выполнения функции part1 part2. Каждая команда также блокирует поток для суммирования своей части объекта. The Lock не позволяет это сделать. В итоге код приводит к зависанию программного обеспечения. Это связано с тем, что для Lock не имеет значения, где в потоке был вызван acquire().
RLock будет блокировать поток только тогда, когда объект заблокирован другим thread. С его помощью удается полностью исключить потоковую самоблокировку.
Использование RLock требуется для того, чтобы управлять вложенным доступом к разделяемым объектам. Для решения возникшей проблемы с Lock в приведенном ранее примере достаточно заменить строку lock1 = threading.Lock() на lock1 = threading.RLock().
В процессе работы необходимо запомнить, что, хоть вызов acquire() возможен несколько раз, методу release() потребуется аналогичного количества обращений. Каждый вызов acquire() влечет за собой увеличение рекурсии на единицу. При использовании release() она уменьшается на точно такое же значение.
Очереди и информационная передача
Класс Queue используется для того, чтобы можно было передавать данные с помощью очередей. Он располагается в библиотеке queue, которая импортируется командой:
From queue import Queue.
Данная библиотека включает в себя все инструменты, которые пригодятся для передачи информации между потоками. Она отвечает за реализацию необходимых механизмов блокировки.
Класс the Queue отвечает в первую очередь за реализацию FIFO, который функционирует так: первый компонент, вошедший в очередь, первый из него выходит. Эта очередь сравнима с вертикальной полой трубой, в которую сверху бросаются различные предметы.
У Queue предусматривается параметр maxsize. Он способен принимать исключительно целочисленные значения. Используется для указания предельного количества элементов, допустимых для помещения в очередь. При достижении максимума добавление в очередь блокируется. Это происходит до тех пор, пока в ней не появится свободное пространство (не освободится место). Если у maxsize значение больше или равно 0, очередь бесконечна.
Для взаимодействия с очередями применяется Event – это объект модуля threading. Он дает возможность потоку выполнить необходимые операции при получении сигнала от другого the thread. Поток не обязательно должен останавливать свою работу на время ожидания сигнала.
Qsize()
Для передачи информации и работы с очередями в the Python thread используются отдельные методы. Первый – это qsize(). Он отвечает за возврат примерного размера очереди. Здесь необходимо понимать следующее:
- если qsize() > 0, следующий метод get() все равно может попасть под блокировку;
- если qsize() < maxsize, следующие put() допускает блокировку.
Это не единственный метод для работы с передачей данных. Далее будут представлены другие концепции, которые применяются программистами.
Empty()
Empty() – метод, проверяющий очередь на факт содержания чего-либо. Если она пуста, система возвращает значение true. В противном случае – результатом станет false. Это не гарантирует того, что get() или put() не окажутся заблокированными.
Full()
Full() отвечает за проверку заполненности очереди. Если она заполнена, возвращается true-значение, в противном случае система выдаст результат false. Как и в прошлом случае, возврат true/false не дает никаких гарантий, что put() и get() не попадут под блокировку.
Put()
Put() – отвечает за помещение нового объекта в очередь. У него предусматривается обязательный аргумент item, а также два необязательных: block = True, timeout = None. Выглядит этот метод так:
В зависимости от используемых аргументов, ожидание места в очереди ведет себя по-разному. Допускаются следующие варианты развития событий:
- Если параметр the block будет иметь значение True, а the timeout – None, объект, который требуется загрузить в очередь, будет бесконечно ждать свободного пространства.
- Если the timeout > 0, ожидание свободного места длится не более указанного количества секунд. Если за этот период пространство не появилось, возбуждается исключение.
- При the block со значением False аргумент timeout игнорируется. Элемент может быть помещен в очередь только при наличии свободного места. В противном случае система сразу генерирует исключение.
Ниже – пример создания очереди в the Python с дальнейшим добавлением в нее компонента:
Также есть метод put(nowait). Он работает так же, как и put(item, False). С его применением осуществляется помещение элемента в очередь только при наличии места. Если оно отсутствует, приложение вызовет исключение.
Get()
Get() – отвечает за удаление и возврат элемента из очереди. У него два необязательных аргумента – как и у put:
Поведение также зависит от значений аргументов:
- При значении по умолчанию метод ожидает объект из очереди, пока он не станет доступным.
- Если timeout – это положительное число, объект из очереди ожидается заданное время. Как только оно закончится, система выдаст исключение.
- Если block имеет значение false – компонент возвращается, только если он является недоступным. В противном случае будет вызвано исключение. Аргумент timeout будет игнорироваться.
Также существует get_nowait(). Он работает точно также, как и get(False).
Task_done()
Работает вместе с методом the join(). Он указывает, что поставленная ранее задача была выполнена. После получения каждого элемента из очереди, требуется вызвать task_done() для уменьшения счетчика задач. Если система обращается к нему больше, чем количество элементов в очереди, появляется исключение ValueError.
Join()
Join() – метод, отвечающий за блокировку потока до тех пор, пока все элементы очереди не будут получены и обработаны. Каждый раз, когда в очередь добавляется новый компонент, счетчик нерешенных задач увеличивается. При вызове task_done он уменьшается и показывает, что обработка компонента в очереди успешно завершена – возможен переход к следующему. При счетчике, равном нулю, блокировка с потока снимается. Ниже – наглядный пример реализации метода:
В данном the python threading примере операции осуществляются в одном потоке. Обычно один поток пишет данные в очередь, затем – ждет их обработки с помощью join(). Другой поток при получении каждого нового значения вызывает task_done.
Интересует Python? Добро пожаловать на курс в Otus!