Асинхронное программирование в Python — что это, как устроено и где применяется

Асинхронное программирование в Python — что это, как устроено и где применяется

Как пишут почти все современные приложения

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

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

Если вы пока только начинаете знакомиться с языком программирования Python, посмотрите наш мастрид, там много интересного.

Синхронное и асинхронное программирование

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

Асинхронное программирование в Python — что это, как устроено и где применяется

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

  • купить продукты в супермаркете;
  • позвать гостей;
  • забронировать дом за городом через онлайн-сервис.

В каждом пункте есть подзадачи. Например, купить мясо, торт, шампанское или позвать Мишу, Инну и Кристину.

Если применять синхронный подход, мы будем выполнять всё по очереди:

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

Так мы получаем отрезок времени, в который выполняем по одной подзадаче за раз, но иногда просто ждём и ничего не делаем:

Асинхронное программирование в Python — что это, как устроено и где применяется

При асинхронном подходе тоже выполняется по одной подзадаче за раз, но если появляется свободное время, мы проводим его с пользой:

  1. Пока катим тележку от мяса к шампанскому, обзваниваем гостей. Положили в тележку один продукт, дозвонились одному гостю, купили следующий продукт по списку.
  2. Между обзвоном гостей, если кто-то не отвечает, зашли в интернет и забронировали дом за городом.
  3. Пока ждём подтверждения аренды, дозвонились до оставшихся гостей.

Получается, что асинхронный код не тратит время на ожидания и всё время переключается между задачами:

Асинхронное программирование в Python — что это, как устроено и где применяется

Многопоточность, параллельность и конкурентность

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

  • процессы,
  • потоки,
  • параллельность,
  • многопоточность,
  • конкурентность.

Процесс (process) — это один экземпляр запущенной программы, например вкладка в браузере. У каждого процесса есть своя часть кода, стек задач и выделенные ресурсы.

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

Поток (thread) — то, из чего состоят процессы. В каждом процессе есть как минимум один поток, который называется главным. Если запустить мониторинг операционной системы, можно увидеть, сколько в ней сейчас работает процессов и потоков:

Асинхронное программирование в Python — что это, как устроено и где применяется

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

Визуально всё можно представить так: процесс — это то, как работает наша программа, а поток — это её отдельные части (отрисовка интерфейса, работа с данными и так далее):

Асинхронное программирование в Python — что это, как устроено и где применяется

Параллельность (parallel) означает одновременное исполнение нескольких процессов или потоков.

Многопоточность (multithreading) — то же самое, что параллельность, но именно про потоки. Иногда эти слова используют как синонимы, потому что смысл один: параллельное выполнение нескольких задач.

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

Конкурентность (concurrency) — это переключение между потоками, когда программа использует время оптимальным образом и никогда не простаивает, если есть задача, на которую можно переключиться. Именно поэтому в системе компьютера так много потоков — они постоянно переключаются между собой.

Асинхронное программирование использует конкурентность. Как выглядят конкурентный и параллельный подходы в реальной жизни:

Асинхронное программирование в Python — что это, как устроено и где применяется

Вот что получается:

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

Как мы уже написали, в простых программах можно без асинхронности. Давайте посмотрим, где она приносит пользу.

CPU-bound- и IO-bound-операции

Потоки внутри процессов выполняют задачи двух основных видов. Если скорость выполнения задачи зависит от мощности процессора — это CPU-bound-операция. Вот несколько примеров:

  • потоковое видео;
  • рендеринг графики в играх;
  • сложные математические вычисления;
  • обучение ML-модели.

Когда скорость выполнения задачи зависит от какой-то подсистемы, задача называется IO-bound-операцией: на них влияет не процессор, а какие-то внешние ресурсы. Обычно говорят, что IO-bound-операции зависят от подсистемы ввода-вывода (Input-Output). Вот примеры:

  • скорость запроса на сайт зависит от API сайта;
  • скорость запроса в базу данных зависит от базы;
  • скорость записи файла на USB зависит от USB.

Один процесс может включать чередующиеся потоки с разными задачами: сначала вычисления CPU, потом запрос в подсистему, после её ответа снова вычисления.

Асинхронный подход означает выполнение CPU-bound-операций одновременно с выполнением IO-bound-операций где-то на внешних ресурсах:

Асинхронное программирование в Python — что это, как устроено и где применяется

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

Для CPU-bound-задач асинхронное программирование бесполезно. Оно предполагает переключение между потоками, когда есть свободные ресурсы. А в CPU-bound-операции свободных ресурсов нет: компьютеру нужно сосредоточиться на одной операции и выполнить её как можно скорее.

Для операций CPU Bound хорошо подходит многопоточность: выполнение сложных операций одновременно в параллельных потоках. Но в Python такой подход применить невозможно, и вот почему.

Что такое GIL в Python

Python устроен так, что в единицу времени может работать только один поток. Это свойство называется глобальной блокировкой интерпретатора, или GIL (Global Interpreter Lock).

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

В Python тоже есть многопоточность. Она реализована в библиотеках, которые написаны на языке C, но их можно подключить и использовать. Например, NumPy или SciPy.

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

Что в итоге используется и почему

Итак, мы разобрали, что программирование бывает:

  • синхронным и асинхронным;
  • однопоточным и многопоточным;

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

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

Можно использовать сложные и быстрые языки, где есть и асинхронность, и многопоточность, например Go. Но у такого подхода есть минусы:

  • Сложные языки сложнее освоить. А чтобы начать писать асинхронный код на Python, достаточно знать основной синтаксис и понимать работу одного встроенного модуля.
  • Высокая производительность многопоточного асинхронного программирования может просто не понадобиться. 
  • На многопоточное выполнение программы расходуется больше ресурсов.

Если какая-то часть однопоточной программы должна работать очень быстро, её можно выделить в отдельный фрагмент программы и написать его на другом языке программирования с поддержкой многопоточности. Поэтому сегодня самый распространённый подход у современных бэкенд-разработчиков — асинхронное однопоточное программирование на Python.

Что ещё нужно знать для работы

Разберём несколько компонентов асинхронного подхода.

Модуль asyncio. Асинхронные задачи имеют свой синтаксис, который нужно импортировать одной строчкой в начале Python-скрипта:

import asyncio

Дополнительно устанавливать ничего не нужно, это встроенный модуль для всех версий Python начиная с 3.4.

Корутины, или асинхронные функции. Обычные функции мы создаём с помощью ключевого слова def:

def func():
   print('Я — обычная функция')

Асинхронные функции выглядят немного по-другому:

async def async_func():
   print('Я — асинхронная функция')

Если мы попробуем вывести эту функцию на экран командой print(async_func()), то получим объект корутины:

<coroutine object async_func at 0x105676380>

При этом вместе с этим сообщением мы получим ошибку:

coroutine 'async_func' was never awaited

Так происходит потому, что у корутины нет разрешения на выполнение. Для этого внутри неё должно быть ключевое слово await. Если команда return позволяет получить результат работы функции, то await говорит, чего ожидать.

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

# функция для демонстрации ожидания io операции
async def io_simulate(delay):
   # в реальной жизни код ниже ожидает выполнения операции
   # на внешней системе, например ответа от сервера
   await asyncio.sleep(delay)
   # после этого функция возвращает какие-то данные,
   # которые может получить другая функция
   return 'Какие-то данные'

Event loop — планировщик или цикл асинхронных событий. Все асинхронные задачи выполняются только внутри event loop: он следит, какая корутина выполняется сейчас, а какая ожидает выполнения какой-то io-операции.

Что дальше

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

Редактор:

Инна Долога

Обложка:

Алексей Сухов

Корректор:

Ирина Михеева

Вёрстка:

Маша Климентьева

Соцсети:

Юлия Зубарева

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