Когда говорят о выполнении программ, то под «асинхронным выполнением» понимают такую ситуацию, когда программа не ждёт завершения некоего процесса, а продолжает работу независимо от него. В качестве примера асинхронного программирования можно привести утилиту, которая, работая асинхронно, делает записи в лог-файл. Хотя такая утилита и может дать сбой (например, из-за нехватки свободного места на диске), в большинстве случаев она будет работать правильно и ей можно будет пользоваться в различных программах. Они смогут её вызывать, передавая ей данные для записи, а после этого смогут продолжать заниматься своими делами.
Применение асинхронных механизмов при написании некоей программы означает, что эта программа будет выполняться быстрее, чем без использования подобных механизмов. При этом то, что планируется запускать асинхронно, вроде утилиты для логирования, должно быть написано с учётом возникновения нештатных ситуаций. Например, утилита для логирования, если место на диске закончилось, может просто прекратить логирование, а не «обваливать» ошибкой основную программу.
Выполнение асинхронного кода обычно подразумевает работу такого кода в отдельном потоке. Это — если речь идёт о системе с одноядерным процессором. В системах с многоядерными процессорами подобный код вполне может выполняться процессом, пользующимся отдельным ядром. Одноядерный процессор в некий момент времени может считывать и выполнять лишь одну инструкцию. Это напоминает чтение книг. Нельзя читать две книги одновременно.
Если вы читаете книгу, а кто-то даёт вам ещё одну книгу, вы можете взять эту вторую книгу и приступить к её чтению. Но первую придётся отложить. По такому же принципу устроено и многопоточное выполнение кода. А если бы несколько ваших копий читало бы сразу несколько книг, то это было бы похоже на то, как работают многопроцессорные системы.
Если на одноядерном процессоре очень быстро переключаться между задачами, требующими разной вычислительной мощности (например — между некими вычислениями и чтением данных с диска), тогда может возникнуть такое ощущение, что единственное процессорное ядро одновременно делает несколько дел. Или, скажем, подобное происходит в том случае, если попытаться открыть в браузере сразу несколько сайтов. Если для загрузки каждой из страниц браузер использует отдельный поток — тогда всё будет сделано гораздо быстрее, чем если бы эти страницы загружались бы по одной. Загрузка страницы — не такая уж и сложная задача, она не использует ресурсы системы по максимуму, в результате одновременный запуск нескольких таких задач оказывается весьма эффективным ходом.
Асинхронное программирование в Python
Изначально в Python для решения задач асинхронного программирования использовались корутины, основанные на генераторах. Потом, в Python 3.4, появился модуль asyncio
(иногда его название записывают как async IO
), в котором реализованы механизмы асинхронного программирования. В Python 3.5 появилась конструкция async/await.
Для того чтобы заниматься асинхронной разработкой на Python, нужно разобраться с парой понятий. Это — корутины (coroutine) и задачи (task).
Корутины
Обычно корутина — это асинхронная (async) функция. Корутина может быть и объектом, возвращённым из корутины-функции.
Если при объявлении функции указано то, что она является асинхронной, то вызывать её можно с использованием ключевого слова await
:
await say_after(1, ‘hello’)
Такая конструкция означает, что программа будет выполняться до тех пор, пока не встретит await-выражение, после чего вызовет функцию и приостановит своё выполнение до тех пор, пока работа вызванной функции не завершится. После этого возможность запуститься появится и у других корутин.
Приостановка выполнения программы означает, что управление возвращается циклу событий. Когда используют модуль asyncio
, цикл событий выполняет все асинхронные задачи, производит операции ввода-вывода и выполняет подпроцессы. В большинстве случаев для запуска корутин используются задачи.
Задачи
Задачи позволяют запускать корутины в цикле событий. Это упрощает управление выполнением нескольких корутин. Вот пример, в котором используются корутины и задачи. Обратите внимание на то, что сущности, объявленные с помощью конструкции async def
— это корутины. Этот пример взят из официальной документации Python.
import asyncio
import time
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
task1 = asyncio.create_task(
say_after(1, 'hello'))
task2 = asyncio.create_task(
say_after(2, 'world'))
print(f"started at {time.strftime('%X')}")
# Ждём завершения обеих задач (это должно занять
# около 2 секунд.)
await task1
await task2
print(f"finished at {time.strftime('%X')}")
asyncio.run(main())
Функция say_after()
имеет префикс async
, в результате перед нами — корутина. Если немного отвлечься от этого примера, то можно сказать, что данную функцию можно вызвать так:
await say_after(1, 'hello')
await say_after(2, 'world')
При таком подходе, однако, корутины вызываются последовательно и на их выполнение уходит около 3 секунд. В нашем же примере осуществляется их конкурентный запуск. Для каждой из них используется задача. В результате время выполнения всей программы составляет около 2 секунд. Обратите внимание на то, что для работы подобной программы недостаточно просто объявить функцию main()
с ключевым словом async
. В подобных ситуациях нужно пользоваться модулем asyncio
.
Если запустить код примера, то на экран будет выведен текст, подобный следующему:
started at 20:19:39
hello
world
finished at 20:19:41
Обратите внимание на то, что отметки времени в первой и последней строках отличаются на 2 секунды. Если же запустить этот пример с последовательным вызовом корутин, то разница между отметками времени составит уже 3 секунды.
Пример
В этом примере производится нахождение количества операций, необходимых на вычисление суммы десяти элементов последовательности чисел. Вычисления производятся, начиная с конца последовательности. Рекурсивная функция начинает работу, получая число 10, потом вызывает сама себя с числами 9 и 8, складывая то, что будет возвращено. Подобное продолжается до завершения вычислений. В результате оказывается, например, что сумма последовательности чисел от 1 до 10 составляет 55. При этом наша функция весьма неэффективна, здесь используется конструкция time.sleep(0.1)
.
Вот код функции:
import time
def fib(n):
global count
count=count+1
time.sleep(0.1)
if n > 1:
return fib(n-1) + fib(n-2)
return n
start=time.time()
global count
count = 0
result = fib(10)
print(result,count)
print(time.time()-start)
Что произойдёт, если переписать этот код с использованием асинхронных механизмов и применить здесь конструкцию asyncio.gather
, которая отвечает за выполнение двух задач и ожидает момента их завершения?
import asyncio,time
async def fib(n):
global count
count=count+1
time.sleep(0.1)
event_loop = asyncio.get_event_loop()
if n > 1:
task1 = asyncio.create_task(fib(n-1))
task2 = asyncio.create_task(fib(n-2))
await asyncio.gather(task1,task2)
return task1.result()+task2.result()
return n
На самом деле, этот пример работает даже немного медленнее предыдущего, так как всё выполняется в одном потоке, а вызовы create_task
, gather
и прочие подобные создают дополнительную нагрузку на систему. Однако цель этого примера в том, чтобы продемонстрировать возможности по конкурентному запуску нескольких задач и по ожиданию их выполнения.
Итоги
Существуют ситуации, в которых использование задач и корутин оказывается весьма полезным Например, если в программе присутствует смесь операций ввода-вывода и вычислений, или если в одной и той же программе выполняются разные вычисления, можно решать эти задачи, запуская код в конкурентном, а не в последовательном режиме. Это способствует сокращению времени, необходимого программе на выполнение определённых действий. Однако это не позволяет, например, выполнять вычисления одновременно. Для организации подобных вычислений применяется мультипроцессинг. Это — отдельная большая тема.
Уважаемые читатели! Как вы пишете асинхронный Python-код?
Автор: ru_vds