Для меня это был волшебный момент, внезапный инсайт, когда я впервые узнал о многопоточности. Меня восхитила сама возможность параллельного выполнения действий, (хотя важно заметить, что на компьютере с одноядерным процессором вычисления выполняются не строго параллельно, причем вычисления в Python распараллеливаются частично из-за наличия GIL-концепции ‑ способа синхронизации потоков в Python. Многопоточность открывает новые возможности для вычислений, но вслед за могуществом приходит и ответственность.
Имеется ряд проблем, возникающих при использовании многопоточности – попытка множества потоков получить доступ к одному и тому же фрагменту данных может привести к проблемам несовместимости или получению искаженной информации (например, фраза HWeolrldo вместо Hello World на консоли). Подобные проблемы возникают, когда компьютеру не указан способ организации потоков.
Как правильно приказать компьютеру синхронизировать потоки? Для этого используются примитивы синхронизации — простые программные механизмы, обеспечивающие гармоничное взаимодействие потоков друг с другом.
В этом посте представлены некоторые популярные примитивы синхронизации в Python, определенные в стандартном модуле threading.py. Большинство методов блокировки (то есть методов, блокирующих выполнение конкретного потока до тех пор, пока не выполнится условие) этих примитивов предоставляют дополнительные функции тайм-аута, но для простоты изложения они не будут здесь упоминаться. Также ради простоты описаны только основные функции этих объектов. Предполагается, что читатель обладает базовыми знаниями многопоточности в Python.
Изучим Locks, RLocks, Semaphores, Events, Conditionsи Barriers. Разумеется, можно создавать собственные примитивы пользовательской синхронизации, используя описанные мной в качестве подклассов. Начнем с Locks как с простейшего из примитивов и постепенно перейдем к более сложным.
Locks
Примитивы Lock вероятно, простейшие примитивы в Python. Для Lock возможны только два состояния ‑ заблокирован и разблокирован. Примитив создается в разблокированном состоянии и содержит два метода – acquire() и release(). Метод acquire() блокирует Lock и выполнение блока до тех пор, пока метод release() из другой сопрограммы не разблокирует его. Затем он снова блокирует Lock и возвращает значение True. Метод release() вызывается только в заблокированном состоянии – устанавливает состояние разблокировки и немедленно возвращает управление. Вызов release() в разблокированном состоянии приводит к RunTimeError.
Вот код, который использует примитив Lock для безопасного доступа к общей переменной:
Этот код просто дает результат в виде числа 3, но теперь мы уверены, что две функции не изменяют значение глобальной переменной g одновременно, хотя работают в двух разных потоках. Таким образом, Lock могут использоваться для предотвращения противоречивости в выходных данных, позволяя каждый раз только одному потоку изменять данные.
RLocks
Стандартный Lock не знает, какой поток блокируется в данный момент. Если блокировка сохраняется, блокируется любой из потоков, пытающихся получить доступ, даже если этот тот же самый поток, который уже удерживает блокировку. Именно для таких случаев и используется RLock — блокировка повторного входа. Вы можете расширить код в следующем фрагменте, добавив выходные инструкции для демонстрации возможностей RLock предотвращать нежелательную блокировку.
Возможно рекурсивное использование RLock — когда родительский вызов функции блокирует вложенный вызов. Таким образом RLock используются для вложенного доступа к общим ресурсам.
Семафоры
Семафоры – это просто дополнительные счетчики. Вызов acquire() будет блокироваться семафором только после превышении определенного количества запущенных потоков acquire(). Значение соответствующего счетчика уменьшается на каждый вызов на acquire() и увеличивается на каждый вызов release(). Значение ValueError будет возникать, если вызовы release() будут пытаться увеличивать значение счетчика после достижения заданного максимального значения (количества потоков, которые допустимые семафором acquire() до применения блокировки). Следующий код демонстрирует использование семафоров для простой задачи производитель-потребитель.
for thread in threads: # Waits for threads to complete before moving on with the main script.
thread.join()
print("All done.")
Модуль threading также предоставляет простой класс Semaphore. Класс Semaphore предоставляет счетчик, позволяющий вызывать release() произвольное количество раз. Однако, чтобы избежать ошибок при программировании, лучше использовать BoundedSemaphore, который вызывает ошибку, если вызов release() пытается увеличивать значение счетчика выше заданного максимального значения.
Семафоры, как правило, используются для ограничения ресурсов, например, ограничения доступа к серверу, допуская обрабатывать только 10 клиентов за раз. В этом случае несколько потоков соединений конкурируют за ограниченный ресурс (в нашем примере это сервер).
Events
Примитив синхронизации Event работает как простой коммуникатор между потоками. Он использует внутренний флаг, который потоки могут устанавливать set() или сбрасывать clear(). Другие потоки могут ожидать wait() установки внутреннего флага set(). Метод wait() блокирует пока флаг не станет истинным. Следующий фрагмент демонстрирует, как Event могут использоваться для запуска действий.
Conditions
Объект Condition является просто усовершенствованным вариантом объекта Event. Он тоже работает как коммуникатор между потоками и может применяться для уведомления notify() других потоков об изменении состояния программы. Например, его можно использовать для сигнализации доступности ресурса. Другие потоки также должны получать условие acquire() (и, следовательно, связанное с ним блокирование) до ожидания wait() для удовлетворения условия. Кроме того, поток должен освободить release() по условию Condition после завершения связанных с ним действий, так что другие потоки могут получить условие для своих целей. Нижеследующий код демонстрирует реализацию другой простой проблемы производитель-потребитель с помощью объекта Condition.
Возможны и другие применения для Condition. Например, при разработке потокового API, который уведомляет клиента о времени начала доступности данных.
Barriers
Барьеры являются простыми примитивами синхронизации и используются потоками для ожидании друг друга. Каждый поток пытается передать барьер с помощью вызова метода wait(), который будет блокироваться, пока все потоки не создадут этот вызов. Как только это произойдет, потоки будут запущены одновременно. Следующий фрагмент демонстрирует использование Barrier.
Following loop enables waiting for the threads to complete before moving on with the main script.
Для барьеров можно найти множество применений, одним из которых может стать синхронизация работы сервера и клиента, поскольку серверу часто приходится ожидать клиента после инициализации.
На этом завершим обсуждение примитивов синхронизации в Python.
Этот пост написан как решение упражнения в книге «Программирование приложений на основе ядра Python» Уэсли Чана. Если этот пост вам понравился, познакомьтесь с другими моими работами из этой книги на GitHub. Исходные коды из этой статьи также доступны в моем профиле.
Читайте нас в телеграмме, vk
Перевод статьи Saurabh Chaturvedi: Let’s Synchronize Threads in Python