При написании приложений с несколькими потоками или процессами нужно помнить о возможности состояния гонки при использовании неатомарных операций. Даже простая задача для увеличения целого числа на единицу в конкурентной программе может вызвать ошибки, с трудом поддающиеся воспроизведению. Но при использовании asyncio мы всегда работаем в одном потоке (если только явно не задействовали средства многопоточной или многопроцессной обработки), значит, можно не беспокоиться о гонках, правда? На самом деле все не так просто! Некоторые ошибки, встречающиеся в многопоточных и многопроцессных приложениях, исключены в силу однопоточной природы asyncio, но это не совсем так. Вам редко потребуется прибегать к синхронизации при работе с asyncio, однако остаются ситуации, когда с этим нужно что‑то делать. Примитивы синхронизации asyncio могут помочь предотвратить ошибки, свойственные только модели однопоточной конкурентности. В этой статье вы узнаете, зачем нужны примитивы синхронизации asyncio, а также о лучших практиках использования нескольких примитивов синхронизации. В конце статьи рассмотрим практический пример использования примитивов синхронизации в действии. Если у вас есть замечания или вы нашли ошибку, пожалуйста, используйте Ctrl+Enter и я исправлю. Спасибо! Любой, кто использовал многопоточность в Python, знает, что несколько потоков используют один и тот же блок памяти. Поэтому, когда несколько потоков одновременно выполняют неатомарные операции над одной и той же областью, возникает проблема потокобезопасности. Поскольку asyncio работает в один поток, не возникает ли у него аналогичных проблем с потокобезопасностью? Ответ: нет. Параллельные задачи в asyncio выполняются асинхронно, то есть возможно попеременное выполнение нескольких задач во времени. Ошибка параллелизма возникает, когда одна задача обращается к определенной области памяти и ожидает возврата операции ввода-вывода, а другая задача одновременно обращается к этой памяти. Чтобы избежать подобных ошибок, в Python asyncio введен примитив синхронизации, аналогичный многопоточности. Это asyncio.Lock, asyncio.Semaphore, asyncio.Event и asyncio.Condition. Кроме того, чтобы избежать одновременного обращения к ресурсу слишком большого числа задач, примитивы синхронизации asyncio позволяют защитить ресурс, ограничив число одновременно обращающихся к нему задач.  Рассмотрим ситуацию. Предположим, у нас есть параллельная задача, которой нужна копия данных сайта. Сначала она проверит, есть ли она в кэше, если есть, то получит ее из кэша, а если нет, то прочитает ее с сайта. Поскольку чтение данных сайта занимает некоторое время для возврата и обновления кэша, при одновременном выполнении нескольких параллельных задач все они предполагают, что этих данных в кэше нет, и запускают удаленные запросы одновременно, как показано в следующем коде: Это не соответствует нашему первоначальному замыслу, поэтому на помощь приходит asyncio.Lock. Мы можем проверить наличие данных в кэше, когда параллельные задачи должны получить блокировку первыми, а другие задачи, не получившие блокировку, будут ждать. Пока задача, получившая блокировку, не завершит обновление кэша и не снимет блокировку, остальные задачи могут продолжать выполнение. Вся блок-схема показана ниже: Давайте поправим наш код: Иногда нам необходимо получить доступ к ресурсу с ограниченным количеством одновременных запросов. Например, конкретная база данных допускает одновременное открытие только пяти соединений. Или, в зависимости от типа подписки, веб-интерфейс поддерживает только определенное количество одновременных запросов. В этом случае необходимо использовать asyncio.Semaphore. asyncio.Semaphore использует внутренний счетчик, который уменьшается на единицу при каждом получении блокировки Semaphore, пока не достигнет нуля. Когда счетчик asyncio.Semaphore равен нулю, другие задачи, которым нужна блокировка, будут ждать. При вызове метода release после выполнения других задач счетчик будет увеличен на единицу. Ожидающие задачи могут продолжить выполнение. Пример кода выглядит следующим образом: Таким образом, мы можем ограничить количество соединений, к которым можно получить одновременный доступ. Одна из особенностей семафоров заключается в том, что число вызовов метода release может превышать число вызовов acquire. Если мы всегда используем семафоры в сочетании с блоком async with, то такое невозможно, потому что с каждым acquire автоматическисвязывается release. Но если нам требуется более точный контроль над механизмом захвата и освобождения, то возможны проблемы. Что произойдет, если мы случайно вызовем метод release() несколько раз? Здесь мы создаем семафор с пределом 2. Затем дважды вызываем сопрограмму acquire и один раз release, т.е. всего семафор будет освобождён трижды. Первое обращение к gather завершается, по видимости, нормально: Однако при втором обращении, когда мы захватываем семафор три раза, возникают проблемы – все три захвата происходят сразу! Мы непреднамеренно превысили предел семафора: Как видно из кода, мы ограничены одновременным выполнением двух задач, но поскольку мы вызвали release более одного раза, то в следующий раз мы сможем выполнить три задачи одновременно. Для решения этой проблемы мы можем использовать asyncio.BoundedSemaphore. Bounded Semaphore - это версия Semaphore, которая вызывает ошибку ValueError в функции release(), если  внутренний счетчик увеличивается выше начального значения. Как мы знаем из исходного кода, при вызове release выдается ошибка ValueError, если значение счетчика больше значения, установленного при инициализации: Здесь второй вызов release возбудит исключение ValueError, означающее, что мы освободили семафор слишком много раз. Аналогичный результат будет иметь место, если в предыдущем примере использовать BoundedSemaphore вместо Semaphore. Если вы вызываете acquire и release вручную, так что возникает опасность динамически превыситьпредел семафора, то лучше работать с BoundedSemaphore, потому что возникшее исключение предупредит об ошибке. Event поддерживает внутреннюю булеву переменную в качестве флага. asyncio.Event имеет три общих метода: wait, set и clear. Когда задача добегает до event.wait(), она находится в состоянии ожидания. В этот момент можно вызвать event.set(), чтобы установить внутренний маркер в True, и все ожидающие задачи могут продолжить выполнение. Когда задача завершится, необходимо вызвать метод event.clear(), чтобы сбросить значение маркера в False, вернуть событие в исходное состояние, и можно продолжать использовать событие в следующий раз. В конце статьи вместо примера кода я покажу, как использовать Event для реализации шины событий. asyncio.Event хороши, когда нужно просто уведомить о том, что произошло нечто, но бывают ситуации посложнее. Допустим, что по событию требуется получить доступ к разделяемому ресурсу, т.е. захватить блокировку. Или что перед продолжением работы нужно дождаться более сложного сочетания условий, чем простое событие. Или что нужно разбудить не все задачи, а только определенное число. Во всех этих случаях могут выручить asyncio.Condition. asyncio.Condition похож на asyncio.Lock и asyncio.Event вместе взятые. Сначала мы используем async with, чтобы обеспечить получение блокировки условия, а затем вызываем condition.wait(), чтобы освободить блокировку условия и заставить задачу временно подождать. Когда condition.wait() проходит, мы возвращаем блокировку условия, чтобы гарантировать одновременное выполнение только одной задачи. Пока задача временно освобождает блокировку и переходит в состояние ожидания по методу condition.wait(), другая задача может либо асинхронизироваться с блокировкой условия и уведомить все ожидающие задачи о необходимости продолжить выполнение по методу condition.notify_all(). Блок-схема показана ниже: Мы можем продемонстрировать действие asyncio.Condition на примере кода: Иногда нам требуется, чтобы asyncio.Condition ждал наступления определенного события, прежде чем перейти к следующему шагу. Мы можем вызвать метод condition.wait_for() и передать в качестве аргумента метод. При каждом вызове condition.notify_all метод condition.wait_for проверяет результат выполнения метода-параметра и завершает ожидание, если он равен True, или продолжает ожидание, если он равен False. condition.wait_for мы можем продемонстрировать на примере. В следующем коде мы смоделируем подключение к базе данных. Перед выполнением SQL-оператора код проверит, инициализировано ли соединение с базой данных, и выполнит запрос, если инициализация соединения завершена, или подождет, пока соединение не завершит инициализацию: Объект Barrier - представляет собой объект. Непотокобезопасен. Это простой примитив синхронизации, позволяющий блокировать до тех пор, пока на нем не будет ожидать определенное количество задач. Задачи могут ожидать метод wait() и будут блокироваться до тех пор, пока заданное количество задач не закончит ожидание wait(). В этот момент все ожидающие задачи одновременно разблокируются. Можно использовать async with как альтернативу ожиданию при вызове Barrier.wait(). Класс asyncio.Barrier можно использовать повторно любое количество раз. Да, это пример из документации, в статье этого не было. По этому классу пока не нашёл более подробных примеров. Буду добавлять информацию по мере возможности. При использовании примитивов синхронизации мы, как правило, ожидаем завершения определенной операции ввода-вывода. Однако из-за флуктуаций в сети или по другим неизвестным причинам операция ввода-вывода в задаче может выполняться дольше, чем в других.В этом случае необходимо установить таймаут для операции, чтобы при слишком большом времени выполнения освободить блокировку и дать возможность другим задачам выполнить ее вовремя. В другом случае мы можем перебирать задачи в цикле. Это может заставить некоторые задачи ждать в фоновом режиме и помешать правильному завершению программы. В этом случае не забудьте воспользоваться командой cancel, чтобы прервать циклическое выполнение задачи. Все мы знаем, что преимущество asyncio заключается в том, что задача может переключаться на выполнение другой задачи в ожидании возврата IO. Но задача asyncio часто содержит как операции, связанные с IO, так и операции, связанные с процессором. Если заблокировать на задаче слишком много кода, то она не сможет вовремя переключиться на другую задачу, что скажется на производительности. Поэтому, если в этом нет необходимости, старайтесь не использовать примитивы синхронизации или блокировать только наименьшее количество ресурсов.  В asyncio нет RLock, поэтому не используйте блокировки в рекурсивном коде. Как и в случае с многопоточностью, в asyncio также существует вероятность возникновения тупиковых ситуаций, поэтому старайтесь избегать одновременного использования нескольких блокировок. Надеюсь к этому моменту вы смогли разобраться и понять как работают примитивы синхронизации в Asyncio. Давайте рассмотрим более практичный пример, рассмотрев реализацию шины событий. Как обычно, первым шагом является проектирование EventBus API. Давайте реализуем эти методы. Поскольку EventBus взаимодействует с помощью строк, а внутри я намерен использовать asyncio.Event для реализации событий, соответствующих каждой строке, начнем с реализации метода _get_event: Метод on привязывает функцию обратного вызова к определенному событию: Метод trigger может вручную вызвать событие и передать соответствующие данные: Наконец, напишем метод main, чтобы проверить действие EventBus: В конце метода main не забудьте использовать таймаут, чтобы программа не выполнялась постоянно, как я уже предупреждал.  В этой статье сначала было рассказано о том, зачем Python asyncio нужны примитивы синхронизации. Затем я представил лучшие практики для Lock, Semaphore, Event и Condition и дал несколько советов по их правильному использованию. Наконец, я привёл небольшой пример с практическим применением примитивов синхронизации asyncio, который, я надеюсь, поможет вам лучше использовать примитивы синхронизации в реальных проектах. Не стесняйтесь комментировать, делиться опытом или обсуждать со мной темы, связанные с asyncio. Пожалуйста, подпишитесь, если вы нашли статьи полезными, и получайте новые истории на свой почтовый ящик. Если у вас есть вопросы, вы можете найти меня на LinkedIn или в Twitter(X).  Ищу работу