Блог

Threading, Multiprocessing и AsyncIO в Python (часть 2)

Содержание

  1. Введение в Асинхронность
    1. Что такое “асинхронный код”?
    2. Сравнение трёх способов получения асинхронного кода
  2. Потоки (threading)
    1. Потоки: создание и управление
    2. Потоки: примитивы синхронизации: Lock
    3. Потоки: примитивы синхронизации: Lock(продолжение), Event, Condition, Semaphore, Queue 🔒
    4. Использования потоков для скрейпинга вебсайтов 🔒
  3. Мультипроцессы (multiprocessing) 🔒
    1. Мультипроцессы : создание и управление 🔒
    2. Мультипроцессы: примитивы синхронизации 🔒
  4. Пакет asyncio
    1. Генератор, как асинхронная функция
    2. Корутины (Coroutines), Задачи (Tasks) и Цикл событий (Event Loop)
    3. Переход от генераторов к корутинам и задачам 🔒
    4. Скрейпинг сайтов с помощью пакета aiohttp 🔒
    5. Работа с файлами с помощью пакета aiofiles 🔒
    6. Примитивы синхронизации для asyncio 🔒
  5. Дополнительные пакеты и методы создания асинхронности 🔒
    1. Пакет subprocess 🔒
    2. Пакет concurrent.futures 🔒
    3. Сокеты - метод timeout() и пакет select 🔒
    4. Пакеты curio и trio 🔒

1 Введение в Асинхронность
1.2 Сравнение трёх способов получения асинхронного кода

Принцип работы всех трёх способов получения асинхронного кода удобнее всего будет понять с помощью следующей схемы:

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

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

Собственно, в этом и заключается принципиальное различие между пакетом multiprocessing, где вычисления и в самом деле идут параллельно и двумя другими пакетами (threading и asyncio), где эффект параллелизма достигается очень быстрым переключением между несколькими “независимыми” частями программы в рамках одного процесса.

Технология реального параллельного вычисления получила название parallelism (параллелизм), а технология имитации параллельных вычислений посредством быстрых переключений — concurrency (конкаренси). Последний термин на русский язык тоже переводится как “параллелизм”, что согласитесь, крайне неудобно при одновременном употреблении с настоящим параллелизмом. (Особенно, если оба термина используются в рамках одного предложения!). Поэтому самым логичным в данной ситуации будет сохранение нативного термина — конкаренси.

Также, иногда можно встретить и другой перевод этого термина - “конкурентность”, что выглядит более чем спорным как с логической, так и с лингвистической точек зрения:

  • во-первых, эти процессы выполняются параллельно и (почти) независимо друг от друга, поэтому ни о какой “конкуренции” между ними не может быть и речи. Даже, если внешне это иногда и выглядит как конкуренция, например при возникновении эффекта гонок, по факту это совсем не так, хотя бы потому, что изначально у потоков (процессов или корутин) нет цели прийти к финишу раньше других;
  • а, во-вторых, термин “конкуренция” имеет совершенно другой перевод — “competition”.

Параллельные вычисления, с точки зрения использования ресурсов компьютера, — удовольствие не из дешёвых, поскольку в этом случае задействуются несколько (или даже все!) ядра процессора, дополнительная оперативная память и т.д. Поэтому, такое решение оправдано только в случае выполнения задач сложных вычислений , где без постоянной и непрерывной работы процессора никак не обойтись (CPU-bound tasks).

На практике же, чаще всего приходится иметь дело с процессами относительно медленными: запросы к базе данных, сетевые взаимодействия и т.д. Это, так называемые, задачи, связанные с вводом-выводом (IO-bound tasks), в которых процессор, отправляя запрос к относительно медленному стороннему ресурсу, вынужден простаивать в ожидании ответа.

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

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

И здесь стоит отметить принципиальное технологическое отличие пакетов threading и asyncio.

В случае потоков (пакет threading) интерпретатор Python обращается за дополнительной помощью к операционной системе (ОС). Создавая новый поток он как бы говорит ей: “Теперь мне нужно, чтобы задача основного потока выполнялась бы одновременно с задачей нового потока до тех пор, пока этот новый поток не завершится”. И в этом случае ОС через строго равные промежутки времени попеременном переключается то на одну, то на другую задачу. Переключение длится доли секунды, поэтому для стороннего наблюдателя обе задачи выполняются как бы параллельно и одновременно.

Плюс этого метода очевиден: сам Python-код для каждого потока остаётся совершенно неизменным (здесь имеется в виду функция, которая передаётся в поток с параметром target). Сам поток — это всего лишь экземпляр класса Thread, а его управление осуществляется с помощью двух методов start() и join() (с точки зрения синтаксиса языка тоже ничего принципиально нового!).

Минусы не сразу бросаются в глаза, но они всё же есть:

  • Данные по каждому “спящему” поток надо где-то хранить, а это требует дополнительных ресурсов памяти.
  • Само переключение потоков с чтением / записью данных тоже требует времени. И чем больше задействовано потоков, тем ощутимее эта величина.
  • Управлением потоками занимается не интерпретатор Python, а ОС. Следовательно, переключение происходит не по принципу актуальности выполнения того или иного потока, а по принципу очерёдности, произвольно определяемой ОС. Что не всегда рационально.

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

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

import time
import asyncio
from my_deco import async_time_counter

N = 5
DELAY = 0.5

async def func1(n):
   for i in range(n):
       await asyncio.sleep(DELAY)
       print(f'--- line #{i} from {n} is completed')

async def func2(n):
   for i in range(n):
       await asyncio.sleep(DELAY)
       print(f'=== line #{i} from {n} is completed')

@async_time_counter
async def main():
   print(f'All functions completed')

async def run():
   task0 = asyncio.create_task(main())
   task1 = asyncio.create_task(func1(N))
   task2 = asyncio.create_task(func2(N))
   await task0
   await task1
   await task2


if __name__ == '__main__':
   asyncio.run(run())

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

======== Script started ========
All functions completed
======== Script execution time: 0.00 ========
--- line #0 from 5 is completed
=== line #0 from 5 is completed
--- line #1 from 5 is completed
=== line #1 from 5 is completed
. . . . . . . . . . . . . . . .
Process finished with exit code 0

Вероятно, для новичков, только что прошедших основы Python может возникнуть резонный вопрос: “А на каком языке написан этот код”? Собственно, в этом вопросе нет ничего удивительного, поскольку:

  • Определение функций происходит с новым оператором async.
  • В теле этих функция используются незнакомый оператор await.
  • Строго говоря, применение двух вышеупомянутых операторов превращает функции в совершенно иную сущность языка Python - эти функции уже больше не функции, а корутины (coroutines).
  • Вместо привычной задержки с помощью таймера time.sleep(DELEY) используется его “асинхронный” аналог asyncio.sleep(DELEY), несущий в себе не только саму задержку, но и элемент управления, переключающий управление с текущий функции (пардон, с текущей корутины!) на другую.
  • Предыдущий декоратор @time_counter здесь тоже не сможет работать, поскольку корутину main(), в отличии от функции main(), больше нельзя запустить с помощью простых скобок. Это особенность придётся учесть в новом декораторе @async_time_counter.
  • Ну и, наконец, запустить весь этот код обычным способом уже не получится - для этого нужна специальная конструкция вроде этой: asyncio.run().

Получается, что и у этого способа тоже существуют свои минусы, и весьма значительные.

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

Поэтому ответ на вопрос “Какой вариант выбрать?” будет на удивление банальным: тот, что максимально подходит под конкретные требования вашей текущей задачи. Разумеется, при этом крайне важно одинаково хорошо владеть каждой из перечисленных технологий, чтобы в нужный момент остановить свой выбор именно на наиболее оптимальной, а не на той, которая лучше всех известна.

Вероятно, сейчас будет уместным вопрос: “А есть ли другие способы сделать код асинхронным, кроме тех трёх, что анонсированы в названии курса?”

Ответ - разумеется, да.

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

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

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

Рассмотренные выше примеры можно отнести к разряду универсальных пакетов, способных практически любой синхронных код сделать асинхронном. Помимо этого, имеются также узкоспециализированные пакеты, позволяющие добиться асинхронности для вполне определённых программ и приложений. Например, пакет select используется для организации асинхронной работы сокетов (пакет socket).

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

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

Итак, переходим к подробному изучению трёх основных пакетов, перечисленных в названии курса. И начнём это изучение с потоков (пакет threading).

Более подробно со всеми деталями этой темы вы сможете познакомиться из этого видео:



Переход на следующую тему