Это серия из 5 частей, где вы узнаете все, что вам нужно об asyncio.

Вот как это происходит:

  1. Общий обзор многопроцессорности, многопоточности и Asyncio
  2. Узнаем, как работает Asyncio под капотом
  3. Как использовать Асинцио
  4. Как совместить блокирующий и неблокирующий код
  5. Как отлаживать неблокирующие

Если вы просто хотите научиться работать с asyncio, вы можете сразу перейти к части 3. Но я рекомендую пройти части 1 и 2, чтобы узнать о внутренностях параллельной обработки.

Часть 1. Общий обзор многопроцессорности, многопоточности и Asyncio

Цель этой статьи — привести всех нас к единому мнению. Мы освежим знания о параллельной обработке, и я покажу вам превью библиотеки asynio. В следующих частях мы углубимся в asyncio. Если вы еще не знакомы с многопоточностью и многопроцессорностью в Python, я оставил несколько ссылок внизу статьи, ссылаясь на некоторые хорошие материалы, чтобы начать работу по этим темам. Удачи!

Прежде чем перейти к asyncio, давайте сначала разберемся, что такое параллельная обработка и как она работает в Python. Есть два способа заставить вашу программу работать параллельно: многопроцессорность и многопоточность.

Процесс против потока

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

Процесс

Как показано на этом изображении, процесс, работающий на одном ядре ЦП, имеет собственный стек (память). По сути, если вы хотите запустить несколько экземпляров своей программы, вы можете сделать это с помощью многопроцессорной обработки.

  • Создается операционной системой для запуска программ
  • Процессы могут иметь несколько потоков
  • Два процесса могут одновременно выполнять код в одной и той же программе Python.
  • Процессы имеют больше накладных расходов, чем потоки, поскольку процессы открытия и закрытия занимают больше времени.
  • Обмен информацией между процессами происходит медленнее, чем обмен между потоками, поскольку процессы не используют совместное пространство памяти. В Python они обмениваются информацией, собирая структуры данных, такие как массивы, что требует времени ввода-вывода.
  • Многопроцессорность обычно используется в ситуациях, когда задача требует высокой загрузки ЦП.

Нить

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

  • Потоки похожи на мини-процессы, которые живут внутри процесса.
  • Они разделяют пространство памяти и эффективно читают и записывают одни и те же переменные.
  • Два потока не могут выполнять код одновременно в одной и той же программе Python (хотя есть обходные пути*)
  • Многопоточность обычно используется в ситуациях, когда задача включает операции ввода-вывода.

Задачи, связанные с вводом-выводом, и задачи, связанные с ЦП

В предыдущем разделе мы упоминали, что многопроцессорность обычно используется в ситуациях, когда задача требует интенсивного использования ЦП, а многопоточность используется с задачами, включающими операции ввода-вывода. Так что же такое задачи ввода-вывода и ЦП?

Задачи, связанные с вводом-выводом:

  • Скачивание файлов из интернета
  • Чтение данных из файла или базы данных
  • Ожидание ввода пользователя
  • Ожидание завершения сетевых запросов
  • Ожидание событий клавиатуры или мыши

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

Задачи, связанные с процессором:

  • Сортировка больших объемов данных
  • Выполнение численных расчетов
  • Обработка изображений или видео
  • Запуск алгоритмов машинного обучения
  • Шифрование или дешифрование данных

Эти задачи включают тяжелые вычисления и обработку, которые могут занимать много времени и потреблять много ресурсов ЦП.

Проще говоря, тяжелые задачи ЦП — это задачи, которые выполняют какие-то вычисления на ЦП (на самом деле все с большим большим O. Сортировка, сложная математика, множество циклов for и т. д.), операции ввода-вывода — это операции, которые ждут вас. устройства ввода/вывода (сеть, диск, клавиатура/мышь и т. д.). Когда у вас есть тяжелые задачи ЦП, вы можете разделить их на множество процессов и запускать их параллельно. Когда у вас есть много тяжелых задач ввода-вывода, вы можете разделить их на потоки, чтобы ваша программа могла делать что-то полезное, пока вы ждете потока, скажем, для получения большого запроса к базе данных.

Давайте по-настоящему! Примеры использования многопроцессорности и многопоточности в Python

Пример многопроцессорности:

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

from multiprocessing import Process
from http.server import HTTPServer, BaseHTTPRequestHandler

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-type', 'text/html')
        self.end_headers()
        message = 'Hello, world!'
        self.wfile.write(bytes(message, "utf8"))

def run_server():
    server_address = ('', 8000)
    httpd = HTTPServer(server_address, RequestHandler)
    httpd.serve_forever()

if __name__ == '__main__':
    processes = []
    for _ in range(4):                         # creating 4 separate processes and saving them into a list
        process = Process(target=run_server)
        processes.append(process)
        process.start()

    for process in processes:
        process.join()

В этом примере мы создаем простой веб-сервер, который прослушивает порт 8000 и возвращает «Hello, world!» сообщения в ответ на каждый запрос. Мы создаем четыре процесса, каждый из которых запускает экземпляр веб-сервера. Наконец, мы ждем завершения всех процессов.

Если вам нужно освежить свои знания о многопроцессорности, вы можете перейти по этой ссылке: https://zetcode.com/python/multiprocessing/

Пример многопоточности:

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

import threading

def task_one():
    print("Starting task one")
    # Perform some time-consuming task
    print("Task one complete")

def task_two():
    print("Starting task two")
    # Perform some other time-consuming task
    print("Task two complete")

if __name__ == '__main__':
    # Create two threads to run each task
    thread_one = threading.Thread(target=task_one)
    thread_two = threading.Thread(target=task_two)

    # Start both threads
    thread_one.start()
    thread_two.start()

    # Wait for both threads to complete
    thread_one.join()
    thread_two.join()

    print("All tasks complete")

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

Если вам нужно освежить свои знания о многопоточности, вы можете перейти по этой ссылке: https://www.tutorialspoint.com/python/python_multithreading.htm

В этих примерах показано, как можно использовать параллельную обработку для ускорения задач, связанных либо с вводом-выводом, либо с процессором. Разделив задачу на более мелкие подзадачи и обрабатывая их одновременно, мы можем сократить общее время, затрачиваемое на выполнение задачи.

Как работает многопоточность и зачем мне ее использовать в Python, когда есть только один поток?

Всякий раз, когда мы запускаем программу Python, запускается новая оболочка Python, которая, в свою очередь, запускает основной поток, в котором выполняется наш код Python. Вы можете создавать другие потоки, как мы обсуждали выше, и они будут называться логическими потоками. Но Python фактически запускает один поток за раз, используя механизм, называемый Global Interpreter Lock (GIL). Однако если GIL гарантирует, что в данный момент выполняется только один поток, то как тогда работает многопоточность? С многопроцессорной обработкой все просто, он просто создает процесс, и этот процесс обрабатывается ОС. С многопоточностью, с другой стороны, все немного иначе.

Многопоточность в Python реализуется с помощью комбинации планировщика потоков на уровне операционной системы и глобальной блокировки интерпретатора (GIL). Операционная система отвечает за планирование этого потока для выполнения на ядре ЦП. Каждый поток имеет собственный стек (память) и может выполнять код независимо от других потоков. Однако, поскольку GIL гарантирует, что только один поток может одновременно выполнять байт-код Python, потоки Python не могут выполнять код Python параллельно.

Вместо этого операционная система будет планировать выполнение потоков на разных ядрах ЦП и переключаться между ними по мере необходимости. GIL гарантирует, что одновременно может выполняться только один поток Python, но операционная система по-прежнему может использовать преимущества нескольких ядер ЦП, планируя разные потоки на разных ядрах.

Когда вы запускаете программу Python, создается один поток выполнения. Этот поток выполняет ваш код последовательно от начала до конца. Если ваш код включает в себя блокировку операций ввода-вывода (например, ожидание ответа сети), поток будет бездействовать и не сможет выполнять какие-либо другие задачи, пока операция ввода-вывода не будет завершена.

Однако есть определенные сценарии, в которых многопоточность все еще может быть полезна даже при работе с задачами, связанными с процессором. Одним из примеров является необходимость выполнения вычислений с привязкой к ЦП с использованием внешних библиотек C, таких как NumPy или OpenCV. Эти библиотеки могут освобождать GIL во время определенных операций, что позволяет выполнять несколько потоков параллельно.

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

Асинкио

Пакет Python Asyncio был добавлен в среду Python в версии 3.4 и стал основой для огромного количества библиотек и фреймворков Python благодаря своей впечатляющей скорости и простоте использования. Asyncio позволяет вам с легкостью писать однопоточные параллельные программы с помощью сопрограмм, которые представляют собой облегченную альтернативу традиционным потокам и не страдают теми недостатками производительности, которые обычно имеют полноценные потоки.

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

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

Начиная

Примечание: это общий обзор библиотеки. В частях 3, 4 и 5 мы подробно рассмотрим, как его использовать.

Чтобы начать работу с Asyncio, вам понадобится цикл обработки событий. Всем системам на основе Asyncio требуется цикл событий, который является ядром производительности программы. Цикл событий планирует сопрограммы Asyncio и выполняет всю тяжелую работу.

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

import asyncio

async def myCoroutine():
    print("Simple Event Loop Example")

def main():
    loop = Asyncio.get_event_loop()
    loop.run_until_complete(myCoroutine())
    loop.close()

if __name__ == '__main__':
    main()

Хотя этот пример не дает особых преимуществ, он показывает преимущества производительности Asyncio в более сложных сценариях. Для более сложного примера рекомендуется ознакомиться с руководством по созданию REST API в aiohttp и Python.

Корутины

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

import asyncio

async def myFunc1():
    print("Coroutine 1")

Задания

Создание задач в Asyncio:

import asyncio

async def myCoroutine(future):
    await asyncio.sleep(1)
    future.set_result("My Coroutine-turned-future has completed")

async def main():
    future = asyncio.Future()
    await asyncio.create_task(myCoroutine(future))
    print(future.result())

loop = asyncio.get_event_loop()
try:
    loop.run_until_complete(main())
finally:
    loop.close()

Несколько сопрограмм

Чтобы воспользоваться возможностью Asyncio одновременно запускать несколько сопрограмм, вы можете использовать функцию collect(). Вот пример того, как сгенерировать десять задач и дождаться их завершения. Мы также измеряем комбинированное время выполнения и реальное время выполнения.

import asyncio
import random
import time
runtime = 0

def record_time(function):
    def wrap(*args, **kwargs):
        start_time = time.time()
        function_return = function(*args, **kwargs)
        print(f"Run time {round(time.time() - start_time, 2)} seconds")
        return function_return
    return wrap

async def myCoroutine(id):
  global runtime
  process_time = random.randint(1,5)
  runtime += process_time
  await asyncio.sleep(process_time)
  print(f"Coroutine: {id}, has successfully completed after {process_time} seconds")

async def runtasks():
    tasks = []
    for i in range(10):
        tasks.append(asyncio.create_task(myCoroutine(i)))

    await asyncio.gather(*tasks)

@record_time
def main():
  loop = asyncio.get_event_loop()
  try:
      loop.run_until_complete(runtasks())
  finally:
      loop.close()
  print('Combined runtime:', runtime)

if __name__ == '__main__':
  main()

Когда вы запустите это, вы должны увидеть что-то вроде этого:

Coroutine: 9, has successfully completed after 1 seconds
Coroutine: 7, has successfully completed after 2 seconds
Coroutine: 0, has successfully completed after 3 seconds
Coroutine: 2, has successfully completed after 3 seconds
Coroutine: 3, has successfully completed after 3 seconds
Coroutine: 4, has successfully completed after 3 seconds
Coroutine: 1, has successfully completed after 4 seconds
Coroutine: 6, has successfully completed after 4 seconds
Coroutine: 8, has successfully completed after 4 seconds
Coroutine: 5, has successfully completed after 5 seconds
Combined runtime: 32
Run time 5.0 seconds

В целом общее время выполнения этой программы составляет около 5 секунд. Если бы вы запускали его без использования Asyncio, это заняло бы 32 секунды. С Asyncio почти в 7 раз (!!!) быстрее.

Краткое содержание

  • Многопроцессорность — несколько экземпляров вашей программы выполняются одновременно.
  • Многопоточность — множество потоков в одном экземпляре вашей программы, работающей одновременно.
  • GIL (глобальная блокировка интерпретатора) — механизм, обеспечивающий выполнение только одного потока в данный момент времени в рамках одного процесса.
  • Хотя многопоточность на самом деле не является особенностью Python, есть некоторые исключения, когда настоящая многопоточность имеет место. В основном с библиотеками, использующими C в своей основе, такими как nympy, opencv и т. д.…
  • Asyncio — имитирует поведение многопоточности, но использует только один поток. Это позволяет вам выполнять операции ввода-вывода, не тратя процессорное время, то есть асинхронно.

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

Использованная литература:

https://zetcode.com/python/multiprocessing/

https://www.tutorialspoint.com/python/python_multithreading.htm

https://medium.com/@bfortuner/python-multithreading-vs-multiprocessing-73072ce5600b

https://tutorialedge.net/python/concurrency/начало-с-Asyncio-python/

https://docs.python.org/3/library/asyncio.html