Во всех наших Python-проектах мы пишем обычный код, который выполняется строка за строкой. Если в каком-то месте программе нужно сделать запрос, например на сервер погоды, она ждёт ответа, а затем продолжает работу. Такой код называется синхронным: программа ждёт, пока сервисы синхронизируются между собой, и продолжает работу после этого. В простых проектах, где мало запросов или время ожидания некритично, это работает.
В высоконагруженных сервисах такой подход не годится. Поэтому в современных сложных Python-программах чаще всего применяют асинхронный код. Сегодня расскажем, как он работает и как можно ускорить работу программ.
Если вы пока только начинаете знакомиться с языком программирования Python, посмотрите наш мастрид, там много интересного.
Синхронное и асинхронное программирование
Синхронный код выполняется от первой строки к последней. Если на какой-то строке нужно дождаться выполнения операции, программа ждёт. Если такие остановки небольшие или у программы немного пользователей, это не страшно:
Асинхронный код тоже выполняется от первой строки к последней, но работает иначе. Допустим, в программе есть много задач и все их нужно выполнить. Задачи не связаны между собой напрямую. Это похоже на список задач в реальной жизни. Например, мы хотим устроить для кого-то день рождения. Вот что нужно сделать:
- купить продукты в супермаркете;
- позвать гостей;
- забронировать дом за городом через онлайн-сервис.
В каждом пункте есть подзадачи. Например, купить мясо, торт, шампанское или позвать Мишу, Инну и Кристину.
Если применять синхронный подход, мы будем выполнять всё по очереди:
- Сначала купим продукты. Мы обойдём весь супермаркет и не будем ни на что отвлекаться.
- Потом обзвоним всех гостей. Если до кого-то не дозваниваемся, не звоним следующему контакту, а продолжаем звонить этому, пока не ответит.
- Наконец, зайдём в интернет, найдём сайт по аренде домов за городом и забронируем дом.
Так мы получаем отрезок времени, в который выполняем по одной подзадаче за раз, но иногда просто ждём и ничего не делаем:
При асинхронном подходе тоже выполняется по одной подзадаче за раз, но если появляется свободное время, мы проводим его с пользой:
- Пока катим тележку от мяса к шампанскому, обзваниваем гостей. Положили в тележку один продукт, дозвонились одному гостю, купили следующий продукт по списку.
- Между обзвоном гостей, если кто-то не отвечает, зашли в интернет и забронировали дом за городом.
- Пока ждём подтверждения аренды, дозвонились до оставшихся гостей.
Получается, что асинхронный код не тратит время на ожидания и всё время переключается между задачами:
Многопоточность, параллельность и конкурентность
Компьютерная программа — это набор инструкций, которые должна выполнить машина. Инструкции могут выполняться по-разному. Чтобы понять отличия и пользу асинхронности, сначала разберём несколько важных понятий:
- процессы,
- потоки,
- параллельность,
- многопоточность,
- конкурентность.
Процесс (process) — это один экземпляр запущенной программы, например вкладка в браузере. У каждого процесса есть своя часть кода, стек задач и выделенные ресурсы.
Программа становится процессом, когда её инструкции загружены в оперативную память и начали выполняться процессором. У процессов есть важное свойство: они изолированы друг от друга. Поэтому, если одна вкладка браузера не отвечает, остальные продолжают работать.
Поток (thread) — то, из чего состоят процессы. В каждом процессе есть как минимум один поток, который называется главным. Если запустить мониторинг операционной системы, можно увидеть, сколько в ней сейчас работает процессов и потоков:
Потоки в одном процессе используют общие ресурсы и не изолированы друг от друга. Поэтому, если сломался один поток, он тянет за собой весь процесс.
Визуально всё можно представить так: процесс — это то, как работает наша программа, а поток — это её отдельные части (отрисовка интерфейса, работа с данными и так далее):
Параллельность (parallel) означает одновременное исполнение нескольких процессов или потоков.
Многопоточность (multithreading) — то же самое, что параллельность, но именно про потоки. Иногда эти слова используют как синонимы, потому что смысл один: параллельное выполнение нескольких задач.
Максимальное количество одновременно запущенных потоков зависит от количества ядер процессора. Мониторинг системы показывает сотни процессов и тысячи потоков, но это не означает одновременное выполнение для всех них. Компьютер много раз за секунду переключается между задачами, но за минимальную единицу времени параллельно выполняется столько потоков, сколько ядер у CPU.
Конкурентность (concurrency) — это переключение между потоками, когда программа использует время оптимальным образом и никогда не простаивает, если есть задача, на которую можно переключиться. Именно поэтому в системе компьютера так много потоков — они постоянно переключаются между собой.
Асинхронное программирование использует конкурентность. Как выглядят конкурентный и параллельный подходы в реальной жизни:
Вот что получается:
- Программа делится на процессы.
- Процессы делятся на потоки.
- Количество одновременно работающих потоков за минимальное машинное время равно количеству ядер процессора.
- Компьютер постоянно переключается между разными потоками, чтобы успеть всё.
Как мы уже написали, в простых программах можно без асинхронности. Давайте посмотрим, где она приносит пользу.
CPU-bound- и IO-bound-операции
Потоки внутри процессов выполняют задачи двух основных видов. Если скорость выполнения задачи зависит от мощности процессора — это CPU-bound-операция. Вот несколько примеров:
- потоковое видео;
- рендеринг графики в играх;
- сложные математические вычисления;
- обучение ML-модели.
Когда скорость выполнения задачи зависит от какой-то подсистемы, задача называется IO-bound-операцией: на них влияет не процессор, а какие-то внешние ресурсы. Обычно говорят, что IO-bound-операции зависят от подсистемы ввода-вывода (Input-Output). Вот примеры:
- скорость запроса на сайт зависит от API сайта;
- скорость запроса в базу данных зависит от базы;
- скорость записи файла на USB зависит от USB.
Один процесс может включать чередующиеся потоки с разными задачами: сначала вычисления CPU, потом запрос в подсистему, после её ответа снова вычисления.
Асинхронный подход означает выполнение CPU-bound-операций одновременно с выполнением IO-bound-операций где-то на внешних ресурсах:
Асинхронное программирование идеально подходит для программ с 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
и напишем несколько асинхронных функций.