Блог

Threading, Multiprocessing и AsyncIO в Python

Содержание

  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.1. Что такое “асинхронный код”?

Если попробовать перевести изначальное (английское) название нашего курса "Threading, Multiprocessing and Asyncio in Python" с помощью Google-переводчика, то мы получим: “потоки, многопроцессорность и асинхронность в Python”.

Увы, Google всего лишь подтверждает достаточно распространённое заблуждение: асинхронность, дескать, - это исключительно пакет asyncio, а всё остальное - вроде как не имеет к асинхронности никакого отношения.

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

Давайте прежде всего попробуем разобраться, в двух моментах:

  1. Что такое синхронный код?
  2. И что в этом синхронном коде такого плохого, что все так настойчиво стремятся превратить его в асинхронный?

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

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

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

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

Ну и теперь, когда мы уже (надеюсь!) получили некоторое представление об асинхронности, самое время дать строго научное определение этому явлению. Благо, в интернете их великое множество - одно непонятнее другого 😉. Лично мне понравилось определение асинхронности на сайте разработчиков Mozilla.org:

“Asynchronous programming is a technique that enables your program to start a potentially long-running task and still be able to be responsive to other events while that task runs, rather than having to wait until that task has finished.”

(Асинхронное программирование — это техника, которая позволяет вашей программе запустить потенциально длительную задачу и по-прежнему иметь возможность реагировать на другие события во время выполнения этой задачи, вместо того, чтобы ждать, пока эта задача завершится.)
https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Asynchronous/Introducing

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

Собственно этим и объясняется невероятная популярность пакетов, способных превратить синхронный код в асинхронный.

Ну, и здесь, наверное, самое время разобрать какой-нибудь пример, позволяющий ещё лучше понять и закрепить всё выше сказанное.

Предположим, у нас есть две функции (func1 и func2), которые необходимо выполнить последовательно:

import time
N = 5
DELAY = 0.5


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


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


def main():
   func1(N)
   func2(N)


if __name__ == '__main__':
   start = time.time()
   main()
   print(f'Total time: {time.time() - start}')

Главная управляющая функция здесь и дальше будет main(), а функции func1() и func2() вызываются в ней последовательно. Дополнительно, будет рассчитываться и общее время выполнения главной функции main(), очевидно равное времени выполнения всего скрипта.

В этом классическом примере синхронного кода видно, что управление второй функции func2() будет передано только после завершения первой функции func1(). Хорошо ещё, что в нашем примере значения числа повторений (N = 5) и времени задержки (DELAY = 0.5 секунды) выбраны относительно небольшими, поэтому программа успевает завершается за вполне короткое время, равное 5 секундам. Но что будет, если эти параметры будут с несколькими нулями в конце? Тогда начала выполнения функции func2 можно и не дождаться. Не говоря уже о появлении финального сообщения о завершении всех функции.

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

Более, чем подробное объяснение работы этого и следующего примеров будут чуть дальше. Сейчас же мы просто, что называется, насладимся красотой и лёгкостью преобразований синхронного кода в асинхронный.

Но прежде всего давайте добавим в наш код одну полезную деталь, а именно: декоратор, который подсчитывает время работы нашей программы. Поскольку, все наши дальнейшие задачи так или иначе будут оцениваться с точки зрения времени выполнения кода. Поэтому, если смысл оптимизировать этот подсчёт с самого начала. Тем более, что для этого уж точно не надо знать методики асинхронного программирования. Достаточно будет наших обычных “синхронных” знаний.

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

Итак, код после точки входа переходит в декоратор, который размещается в новом модуле рабочего каталога my_deco.py:

def time_counter(func):
   @functools.wraps(func)
   def wrap():
       start = time.time()
       print("======== Script started ========")
       func()
       print(f"Time end: {time.strftime('%X')}")
       print(f'======== Script execution time: {time.time() - start:0.2f} ========')

   return wrap

А сам предыдущий скрипт дополняется импортом нового декоратора, и его добавлением к функции main():

import time
from my_deco import time_counter

N = 5
DELAY = 0.5


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


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


@time_counter
def main():
   func1(N)
   func2(N)
   print(f'All functions completed')


if __name__ == '__main__':
   main()

Ну вот, теперь можно добавлять потоки. Для этого прежде всего надо их импортировать:

from threading import Thread

И слегка изменить функцию main():

@time_counter
def main():
   thread1 = Thread(target=func1, args=(N,))
   thread2 = Thread(target=func2, args=(N,))
   thread1.start()
   thread2.start()
   print(f'All functions completed')

Как видим, преобразования кода самые минимальные. Но зато какой результат!

======== Script started ========
All functions completed
======== Script execution time: 0.01 ========
--- 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

Пожалуйста, обратите внимание, что строчка о завершении работы программы (======== Script execution time: 0.01 ========), ровно как и сообщение о завершении работы всех функций (All functions completed) идут раньше, чем информация, выдаваемая самими функциями. Это подтверждает тот факт, что потенциально блокирующие наш код функции func1() и func2() таковыми больше не являются - потоки позволяют легко их “перескочить” и передать управление коду, который находится дальше . А из этого следует, наш синхронный код превратился в асинхронный. И время его выполнения сократилось с 5 секунд (или даже с бесконечности!) до 0,01 секунды.

И в заключении рассмотрения этого примера давайте зафиксируем несколько наблюдений, которые очень пригодятся нам дальше при изучении потоков:

  1. Объекты thread1 и thread2 - это два потока, в которых выполняются наши функции, переданные с помощью параметра target. Также в эти функции передаются соответствующие аргументы с помощью параметр args. Обратите внимание, что сами аргументы передаются в функции в формате tuple.
  2. Создание потока сродни созданию функции в операторе def. Например, запись def func означает, что функция func всего лишь декларируется, НО не запускается. Для её запуска необходима отдельная строка, где к имени функции добавляется invoker (скобки): func().
  3. Что-то подобное требуется и для запуска потока. Только вместо простых скобок здесь используется метод start(), добавленный к объекту потока.

Таким образом, один из методов решения проблемы синхронности кода найден - это потоки. Очевидно, что такой замечательных инструмент требует дальнейшего и более глубокого изучения, чем мы и займёмся чуть дальше.

Но, как следуют из названия курса, имеется ещё, как минимум, два механизма (способа, метода) добиться асинхронности. Так есть ли смысл отвлекаться на изучение чего-то другого, если потоки и так позволили добиться нам такого впечатляющего результата?

Попробуем найти ответ на этот вопрос в следующей теме (статье).

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



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